neo4j: remove unused curl handle (using a now private class)
[nit.git] / lib / neo4j / neo4j.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 # Neo4j connector through its JSON REST API using curl.
16 #
17 # For ease of use and testing this module provide a wrapper to the `neo4j` command:
18 #
19 # # Start the Neo4j server
20 # var srv = new Neo4jServer
21 # assert srv.start_quiet
22 #
23 # In order to connect to Neo4j you need a connector:
24 #
25 # # Create new Neo4j client
26 # var client = new Neo4jClient("http://localhost:7474")
27 # assert client.is_ok
28 #
29 # The fundamental units that form a graph are nodes and relationships.
30 #
31 # Nodes are used to represent entities stored in base:
32 #
33 # # Create a disconnected node
34 # var andres = new NeoNode
35 # andres["name"] = "Andres"
36 # # Connect the node to Neo4j
37 # client.save_node(andres)
38 # assert andres.is_linked
39 #
40 # # Create a second node
41 # var kate = new NeoNode
42 # kate["name"] = "Kate"
43 # client.save_node(kate)
44 # assert kate.is_linked
45 #
46 # Relationships between nodes are a key part of a graph database.
47 # They allow for finding related data. Just like nodes, relationships can have properties.
48 #
49 # # Create a relationship
50 # var loves = new NeoEdge(andres, "LOVES", kate)
51 # client.save_edge(loves)
52 # assert loves.is_linked
53 #
54 # Nodes can also be loaded fron Neo4j:
55 #
56 # # Get a node from DB and explore edges
57 # var url = andres.url.to_s
58 # var from = client.load_node(url)
59 # assert from["name"].to_s == "Andres"
60 # var to = from.out_nodes("LOVES").first # follow the first LOVES relationship
61 # assert to["name"].to_s == "Kate"
62 #
63 # For more details, see http://docs.neo4j.org/chunked/milestone/rest-api.html
64 module neo4j
65
66 import curl_json
67 import error
68
69 # Handles Neo4j server start and stop command
70 #
71 # `neo4j` binary must be in `PATH` in order to work
72 class Neo4jServer
73
74 # Start the local Neo4j server instance
75 fun start: Bool do
76 sys.system("neo4j start console")
77 return true
78 end
79
80 # Like `start` but redirect the console output to `/dev/null`
81 fun start_quiet: Bool do
82 sys.system("neo4j start console > /dev/null")
83 return true
84 end
85
86 # Stop the local Neo4j server instance
87 fun stop: Bool do
88 sys.system("neo4j stop")
89 return true
90 end
91
92 # Like `stop` but redirect the console output to `/dev/null`
93 fun stop_quiet: Bool do
94 sys.system("neo4j stop > /dev/null")
95 return true
96 end
97 end
98
99 # `Neo4jClient` is needed to communicate through the REST API
100 #
101 # var client = new Neo4jClient("http://localhost:7474")
102 # assert client.is_ok
103 class Neo4jClient
104
105 # Neo4j REST services baseurl
106 var base_url: String
107 # REST service to get node data
108 private var node_url: String
109 # REST service to batch
110 private var batch_url: String
111 # REST service to send cypher requests
112 private var cypher_url: String
113
114 init(base_url: String) do
115 self.base_url = base_url
116 var root = service_root
117 assert root isa JsonObject else
118 sys.stderr.write "Neo4jClientError: cannot connect to server at <{base_url}>.\n"
119 end
120 self.node_url = root["node"].to_s
121 self.batch_url = root["batch"].to_s
122 self.cypher_url = root["cypher"].to_s
123 end
124
125 fun service_root: Serializable do return get(base_url / "db/data")
126
127 # Is the connection with the Neo4j server ok?
128 fun is_ok: Bool do return service_root isa JsonObject
129
130 # Empty the graph
131 fun clear_graph do
132 cypher(new CypherQuery.from_string("MATCH (n) OPTIONAL MATCH n-[r]-() DELETE r, n"))
133 end
134
135 # Last errors
136 var errors = new Array[String]
137
138 # Nodes view stored locally
139 private var local_nodes = new HashMap[String, nullable NeoNode]
140
141 # Save the node in base
142 #
143 # var client = new Neo4jClient("http://localhost:7474")
144 #
145 # # Create a node
146 # var andres = new NeoNode
147 # andres["name"] = "Andres"
148 # client.save_node(andres)
149 # assert andres.is_linked
150 #
151 # Once linked, nodes cannot be created twice:
152 #
153 # var oldurl = andres.url
154 # client.save_node(andres) # do nothing
155 # assert andres.url == oldurl
156 fun save_node(node: NeoNode): Bool do
157 if node.is_linked then return true
158 node.neo = self
159 var batch = new NeoBatch(self)
160 batch.save_node(node)
161 # batch.create_edges(node.out_edges)
162 var errors = batch.execute
163 if not errors.is_empty then
164 errors.add_all errors
165 return false
166 end
167 local_nodes[node.url.to_s] = node
168 return true
169 end
170
171 # Load a node from base
172 # Data, labels and edges will be loaded lazily.
173 fun load_node(url: String): NeoNode do
174 if local_nodes.has_key(url) then
175 var node = local_nodes[url]
176 if node != null then return node
177 end
178 var node = new NeoNode.from_neo(self, url)
179 local_nodes[url] = node
180 return node
181 end
182
183 # Remove the entity from base
184 fun delete_node(node: NeoNode): Bool do
185 if not node.is_linked then return false
186 var url = node.url.to_s
187 delete(url)
188 local_nodes[url] = null
189 node.url = null
190 return true
191 end
192
193 # Edges view stored locally
194 private var local_edges = new HashMap[String, nullable NeoEdge]
195
196 # Save the edge in base
197 # From and to nodes will be created.
198 #
199 # var client = new Neo4jClient("http://localhost:7474")
200 #
201 # var andres = new NeoNode
202 # var kate = new NeoNode
203 # var edge = new NeoEdge(andres, "LOVES", kate)
204 # client.save_edge(edge)
205 # assert andres.is_linked
206 # assert kate.is_linked
207 # assert edge.is_linked
208 fun save_edge(edge: NeoEdge): Bool do
209 if edge.is_linked then return true
210 edge.neo = self
211 edge.from.out_edges.add edge
212 edge.to.in_edges.add edge
213 var batch = new NeoBatch(self)
214 batch.save_edge(edge)
215 var errors = batch.execute
216 if not errors.is_empty then
217 errors.add_all errors
218 return false
219 end
220 local_edges[edge.url.to_s] = edge
221 return true
222 end
223
224 # Load a edge from base
225 # Data will be loaded lazily.
226 fun load_edge(url: String): NeoEdge do
227 if local_edges.has_key(url) then
228 var node = local_edges[url]
229 if node != null then return node
230 end
231 var edge = new NeoEdge.from_neo(self, url)
232 local_edges[url] = edge
233 return edge
234 end
235
236 # Remove the edge from base
237 fun delete_edge(edge: NeoEdge): Bool do
238 if not edge.is_linked then return false
239 var url = edge.url.to_s
240 delete(url)
241 local_edges[url] = null
242 edge.url = null
243 return true
244 end
245
246 # Retrieve all nodes with specified `lbl`
247 #
248 # var client = new Neo4jClient("http://localhost:7474")
249 #
250 # var andres = new NeoNode
251 # andres.labels.add_all(["Human", "Male"])
252 # client.save_node(andres)
253 # var kate = new NeoNode
254 # kate.labels.add_all(["Human", "Female"])
255 # client.save_node(kate)
256 #
257 # var nodes = client.nodes_with_label("Human")
258 # assert nodes.has(andres)
259 # assert nodes.has(kate)
260 fun nodes_with_label(lbl: String): Array[NeoNode] do
261 var res = get(base_url / "db/data/label/{lbl.to_percent_encoding}/nodes")
262 var nodes = new Array[NeoNode]
263 for json in res.as(JsonArray) do
264 var obj = json.as(JsonObject)
265 var node = load_node(obj["self"].to_s)
266 node.internal_properties = obj["data"].as(JsonObject)
267 nodes.add node
268 end
269 return nodes
270 end
271
272 # Retrieve nodes belonging to all the specified `labels`.
273 #
274 # var client = new Neo4jClient("http://localhost:7474")
275 #
276 # var andres = new NeoNode
277 # andres.labels.add_all(["Human", "Male"])
278 # client.save_node(andres)
279 # var kate = new NeoNode
280 # kate.labels.add_all(["Human", "Female"])
281 # client.save_node(kate)
282 #
283 # var nodes = client.nodes_with_labels(["Human", "Male"])
284 # assert nodes.has(andres)
285 # assert not nodes.has(kate)
286 fun nodes_with_labels(labels: Array[String]): Array[NeoNode] do
287 assert not labels.is_empty
288
289 # Build the query.
290 var buffer = new Buffer
291 buffer.append "match (n) where \{label_0\} in labels(n)"
292 for i in [1..labels.length[ do
293 buffer.append " and \{label_{i}\} in labels(n)"
294 end
295 buffer.append " return n"
296 var query = new CypherQuery.from_string(buffer.write_to_string)
297 for i in [0..labels.length[ do
298 query.params["label_{i}"] = labels[i]
299 end
300
301 # Retrieve the answer.
302 var res = cypher(query)
303 var nodes = new Array[NeoNode]
304 for json in res.as(JsonObject)["data"].as(JsonArray) do
305 var obj = json.as(JsonArray).first.as(JsonObject)
306 var node = load_node(obj["self"].to_s)
307 node.internal_properties = obj["data"].as(JsonObject)
308 nodes.add node
309 end
310 return nodes
311 end
312
313 # Perform a `CypherQuery`
314 # see: CypherQuery
315 fun cypher(query: CypherQuery): Serializable do
316 return post("{cypher_url}", query.to_rest)
317 end
318
319 # GET JSON data from `url`
320 fun get(url: String): Serializable do
321 var request = new JsonGET(url)
322 var response = request.execute
323 return parse_response(response)
324 end
325
326 # POST `params` to `url`
327 fun post(url: String, params: Serializable): Serializable do
328 var request = new JsonPOST(url)
329 request.json_data = params
330 var response = request.execute
331 return parse_response(response)
332 end
333
334 # PUT `params` at `url`
335 fun put(url: String, params: Serializable): Serializable do
336 var request = new JsonPUT(url)
337 request.json_data = params
338 var response = request.execute
339 return parse_response(response)
340 end
341
342 # DELETE `url`
343 fun delete(url: String): Serializable do
344 var request = new JsonDELETE(url)
345 var response = request.execute
346 return parse_response(response)
347 end
348
349 # Parse the cURL `response` as a JSON string
350 private fun parse_response(response: CurlResponse): Serializable do
351 if response isa CurlResponseSuccess then
352 var str = response.body_str
353 if str.is_empty then return new JsonObject
354 var res = str.parse_json
355 if res isa JsonParseError then
356 var e = new NeoError(res.to_s, "JsonParseError")
357 e.cause = res
358 return e
359 end
360 if res == null then
361 # empty response wrap it in empty object
362 return new JsonObject
363 else if res isa JsonObject and res.has_key("exception") then
364 var error = "Neo4jError::{res["exception"] or else "null"}"
365 var msg = ""
366 if res.has_key("message") then
367 msg = res["message"].to_s
368 end
369 return new NeoError(msg, error)
370 else
371 return res
372 end
373 else if response isa CurlResponseFailed then
374 return new NeoError("{response.error_msg} ({response.error_code})", "CurlError")
375 else
376 return new NeoError("Unexpected response \"{response}\".", "CurlError")
377 end
378 end
379 end
380
381 # A Cypher query for Neo4j REST API
382 #
383 # The Neo4j REST API allows querying with Cypher.
384 # The results are returned as a list of string headers (columns), and a data part,
385 # consisting of a list of all rows, every row consisting of a list of REST representations
386 # of the field value - Node, Relationship, Path or any simple value like String.
387 #
388 # Example:
389 #
390 # var client = new Neo4jClient("http://localhost:7474")
391 # var query = new CypherQuery
392 # query.nmatch("(n)-[r:LOVES]->(m)")
393 # query.nwhere("n.name=\"Andres\"")
394 # query.nreturn("m.name")
395 # var res = client.cypher(query).as(JsonObject)
396 # assert res["data"].as(JsonArray).first.as(JsonArray).first == "Kate"
397 #
398 # For more details, see: http://docs.neo4j.org/chunked/milestone/rest-api-cypher.html
399 class CypherQuery
400 # Query string to perform
401 private var query: String = ""
402
403 # `params` to embed in the query like in prepared statements
404 var params = new JsonObject
405
406 # init the query from a query string
407 init from_string(query: String) do
408 self.query = query
409 end
410
411 # init the query with parameters
412 init with_params(params: JsonObject) do
413 self.params = params
414 end
415
416 # Pass the argument `value` as the parameter `key`.
417 #
418 # SEE: `set`
419 fun []=(key: String, value: nullable Serializable) do
420 params[key] = value
421 end
422
423 # Add a `CREATE` statement to the query
424 fun ncreate(query: String): CypherQuery do
425 self.query = "{self.query}CREATE {query} "
426 return self
427 end
428
429 # Add a `START` statement to the query
430 fun nstart(query: String): CypherQuery do
431 self.query = "{self.query}START {query} "
432 return self
433 end
434
435 # Add a `MATCH` statement to the query
436 fun nmatch(query: String): CypherQuery do
437 self.query = "{self.query}MATCH {query} "
438 return self
439 end
440
441 # Add a `WHERE` statement to the query
442 fun nwhere(query: String): CypherQuery do
443 self.query = "{self.query}WHERE {query} "
444 return self
445 end
446
447 # Add a `AND` statement to the query
448 fun nand(query: String): CypherQuery do
449 self.query = "{self.query}AND {query} "
450 return self
451 end
452
453 # Add a `RETURN` statement to the query
454 fun nreturn(query: String): CypherQuery do
455 self.query = "{self.query}RETURN {query} "
456 return self
457 end
458
459 # Pass the argument `value` as the parameter `key`.
460 #
461 # Return `self`.
462 #
463 # ```
464 # var query = (new CypherQuery).
465 # nmatch("(n)").
466 # nwhere("n.key = \{key\}").
467 # set("key", "foo")
468 #
469 # assert query.params["key"] == "foo"
470 # ```
471 #
472 # SEE: `[]=`
473 fun set(key: String, value: nullable Serializable): SELF do
474 self[key] = value
475 return self
476 end
477
478 # Translate the query to the body of a corresponding Neo4j REST request.
479 fun to_rest: JsonObject do
480 var obj = new JsonObject
481 obj["query"] = query
482 if not params.is_empty then
483 obj["params"] = params
484 end
485 return obj
486 end
487
488 redef fun to_s do return to_rest.to_s
489 end
490
491 # The fundamental units that form a graph are nodes and relationships.
492 #
493 # Entities can have two states:
494 #
495 # * linked: the NeoEntity references an existing node or edge in Neo4j
496 # * unlinked: the NeoEntity is not yet created in Neo4j
497 #
498 # If the entity is initialized unlinked from neo4j:
499 #
500 # # Create a disconnected node
501 # var andres = new NeoNode
502 # andres["name"] = "Andres"
503 # # At this point, the node is not linked
504 # assert not andres.is_linked
505 #
506 # Then we can link the entity to the base:
507 #
508 # # Init client
509 # var client = new Neo4jClient("http://localhost:7474")
510 # client.save_node(andres)
511 # # The node is now linked
512 # assert andres.is_linked
513 #
514 # Entities can also be loaded from Neo4j:
515 #
516 # # Get a node from Neo4j
517 # var url = andres.url.to_s
518 # var node = client.load_node(url)
519 # assert node.is_linked
520 #
521 # When working in connected mode, all reading operations are executed lazily on the base:
522 #
523 # # Get the node `name` property
524 # assert node["name"] == "Andres" # loaded lazily from base
525 abstract class NeoEntity
526 # Neo4j client connector
527 private var neo: Neo4jClient is noinit
528
529 # Entity unique URL in Neo4j REST API
530 var url: nullable String = null
531
532 # Temp id used in batch mode to update the entity
533 private var batch_id: nullable Int = null
534
535 # Load the entity from base
536 private init from_neo(neo: Neo4jClient, url: String) is nosuper do
537 self.neo = neo
538 self.url = url
539 end
540
541 # Init entity from JSON representation
542 private init from_json(neo: Neo4jClient, obj: JsonObject) is nosuper do
543 self.neo = neo
544 self.url = obj["self"].to_s
545 self.internal_properties = obj["data"].as(JsonObject)
546 end
547
548 # Create a empty (and not-connected) entity
549 init do
550 self.internal_properties = new JsonObject
551 end
552
553 # Is the entity linked to a Neo4j database?
554 fun is_linked: Bool do return url != null
555
556 # In Neo4j, both nodes and relationships can contain properties.
557 # Properties are key-value pairs where the key is a string.
558 # Property values are JSON formatted.
559 #
560 # Properties are loaded lazily
561 fun properties: JsonObject do return internal_properties or else load_properties
562
563 private var internal_properties: nullable JsonObject = null
564
565 private fun load_properties: JsonObject do
566 var obj = neo.get(url.to_s / "properties").as(JsonObject)
567 internal_properties = obj
568 return obj
569 end
570
571 # Get the entity `id` if connected to base
572 fun id: nullable Int do
573 if url == null then return null
574 return url.split("/").last.to_i
575 end
576
577 # Get the entity property at `key`
578 fun [](key: String): nullable Serializable do
579 if not properties.has_key(key) then return null
580 return properties[key]
581 end
582
583 # Set the entity property `value` at `key`
584 fun []=(key: String, value: nullable Serializable) do properties[key] = value
585
586 # Is the property `key` set?
587 fun has_key(key: String): Bool do return properties.has_key(key)
588 end
589
590 # Nodes are used to represent entities stored in base.
591 # Apart from properties and relationships (edges),
592 # nodes can also be labeled with zero or more labels.
593 #
594 # A label is a `String` that is used to group nodes into sets.
595 # All nodes labeled with the same label belongs to the same set.
596 # A node may be labeled with any number of labels, including none,
597 # making labels an optional addition to the graph.
598 #
599 # Creating new nodes:
600 #
601 # var client = new Neo4jClient("http://localhost:7474")
602 #
603 # var andres = new NeoNode
604 # andres.labels.add "Person"
605 # andres["name"] = "Andres"
606 # andres["age"] = 22
607 # client.save_node(andres)
608 # assert andres.is_linked
609 #
610 # Get nodes from Neo4j:
611 #
612 # var url = andres.url.to_s
613 # var node = client.load_node(url)
614 # assert node["name"] == "Andres"
615 # assert node["age"].to_s.to_i == 22
616 class NeoNode
617 super NeoEntity
618
619 private var internal_labels: nullable Array[String] = null
620 private var internal_in_edges: nullable List[NeoEdge] = null
621 private var internal_out_edges: nullable List[NeoEdge] = null
622
623 init do
624 super
625 self.internal_labels = new Array[String]
626 self.internal_in_edges = new List[NeoEdge]
627 self.internal_out_edges = new List[NeoEdge]
628 end
629
630 redef fun to_s do
631 var tpl = new FlatBuffer
632 tpl.append "\{"
633 tpl.append "labels: [{labels.join(", ")}],"
634 tpl.append "data: {properties.to_json}"
635 tpl.append "\}"
636 return tpl.write_to_string
637 end
638
639 # A label is a `String` that is used to group nodes into sets.
640 # A node may be labeled with any number of labels, including none.
641 # All nodes labeled with the same label belongs to the same set.
642 #
643 # Many database queries can work with these sets instead of the whole graph,
644 # making queries easier to write and more efficient.
645 #
646 # Labels are loaded lazily
647 fun labels: Array[String] do return internal_labels or else load_labels
648
649 private fun load_labels: Array[String] do
650 var labels = new Array[String]
651 var res = neo.get(url.to_s / "labels")
652 if res isa JsonArray then
653 for val in res do labels.add val.to_s
654 end
655 internal_labels = labels
656 return labels
657 end
658
659 # Get the list of `NeoEdge` pointing to `self`
660 #
661 # Edges are loaded lazily
662 fun in_edges: List[NeoEdge] do return internal_in_edges or else load_in_edges
663
664 private fun load_in_edges: List[NeoEdge] do
665 var edges = new List[NeoEdge]
666 var res = neo.get(url.to_s / "relationships/in").as(JsonArray)
667 for obj in res do
668 edges.add(new NeoEdge.from_json(neo, obj.as(JsonObject)))
669 end
670 internal_in_edges = edges
671 return edges
672 end
673
674 # Get the list of `NeoEdge` pointing from `self`
675 #
676 # Edges are loaded lazily
677 fun out_edges: List[NeoEdge] do return internal_out_edges or else load_out_edges
678
679 private fun load_out_edges: List[NeoEdge] do
680 var edges = new List[NeoEdge]
681 var res = neo.get(url.to_s / "relationships/out")
682 for obj in res.as(JsonArray) do
683 edges.add(new NeoEdge.from_json(neo, obj.as(JsonObject)))
684 end
685 internal_out_edges = edges
686 return edges
687 end
688
689 # Get nodes pointed by `self` following a `rel_type` edge
690 fun out_nodes(rel_type: String): Array[NeoNode] do
691 var res = new Array[NeoNode]
692 for edge in out_edges do
693 if edge.rel_type == rel_type then res.add edge.to
694 end
695 return res
696 end
697
698 # Get nodes pointing to `self` following a `rel_type` edge
699 fun in_nodes(rel_type: String): Array[NeoNode] do
700 var res = new Array[NeoNode]
701 for edge in in_edges do
702 if edge.rel_type == rel_type then res.add edge.from
703 end
704 return res
705 end
706 end
707
708 # A relationship between two nodes.
709 # Relationships between nodes are a key part of a graph database.
710 # They allow for finding related data. Just like nodes, relationships can have properties.
711 #
712 # Create a relationship:
713 #
714 # var client = new Neo4jClient("http://localhost:7474")
715 # # Create nodes
716 # var andres = new NeoNode
717 # andres["name"] = "Andres"
718 # var kate = new NeoNode
719 # kate["name"] = "Kate"
720 # # Create a relationship of type `LOVES`
721 # var loves = new NeoEdge(andres, "LOVES", kate)
722 # client.save_edge(loves)
723 # assert loves.is_linked
724 #
725 # Get an edge from DB:
726 #
727 # var url = loves.url.to_s
728 # var edge = client.load_edge(url)
729 # assert edge.from["name"].to_s == "Andres"
730 # assert edge.to["name"].to_s == "Kate"
731 class NeoEdge
732 super NeoEntity
733
734 private var internal_from: nullable NeoNode
735 private var internal_to: nullable NeoNode
736 private var internal_type: nullable String
737 private var internal_from_url: nullable String
738 private var internal_to_url: nullable String
739
740 init(from: NeoNode, rel_type: String, to: NeoNode) do
741 self.internal_from = from
742 self.internal_to = to
743 self.internal_type = rel_type
744 end
745
746 redef init from_neo(neo, url) do
747 super
748 var obj = neo.get(url).as(JsonObject)
749 self.internal_type = obj["type"].to_s
750 self.internal_from_url = obj["start"].to_s
751 self.internal_to_url = obj["end"].to_s
752 end
753
754 redef init from_json(neo, obj) do
755 super
756 self.internal_type = obj["type"].to_s
757 self.internal_from_url = obj["start"].to_s
758 self.internal_to_url = obj["end"].to_s
759 end
760
761 # Get `from` node
762 fun from: NeoNode do return internal_from or else load_from
763
764 private fun load_from: NeoNode do
765 var node = neo.load_node(internal_from_url.to_s)
766 internal_from = node
767 return node
768 end
769
770 # Get `to` node
771 fun to: NeoNode do return internal_to or else load_to
772
773 private fun load_to: NeoNode do
774 var node = neo.load_node(internal_to_url.to_s)
775 internal_to = node
776 return node
777 end
778
779 # Get edge type
780 fun rel_type: nullable String do return internal_type
781
782 # Get the JSON body of a REST request that create the relationship.
783 private fun to_rest: JsonObject do
784 var obj = new JsonObject
785 if to.is_linked then
786 obj["to"] = to.url
787 else
788 obj["to"] = "\{{to.batch_id.to_s}\}"
789 end
790 obj["type"] = rel_type
791 obj["data"] = properties
792 return obj
793 end
794 end
795
796 # Batches are used to perform multiple operations on the REST API in one cURL request.
797 # This can significantly improve performance for large insert and update operations.
798 #
799 # see: http://docs.neo4j.org/chunked/milestone/rest-api-batch-ops.html
800 #
801 # This service is transactional.
802 # If any of the operations performed fails (returns a non-2xx HTTP status code),
803 # the transaction will be rolled back and all changes will be undone.
804 #
805 # Example:
806 #
807 # var client = new Neo4jClient("http://localhost:7474")
808 #
809 # var node1 = new NeoNode
810 # var node2 = new NeoNode
811 # var edge = new NeoEdge(node1, "TO", node2)
812 #
813 # var batch = new NeoBatch(client)
814 # batch.save_node(node1)
815 # batch.save_node(node2)
816 # batch.save_edge(edge)
817 # batch.execute
818 #
819 # assert node1.is_linked
820 # assert node2.is_linked
821 # assert edge.is_linked
822 class NeoBatch
823
824 # Neo4j client connector
825 var client: Neo4jClient
826
827 # Jobs to perform in this batch
828 #
829 # The batch service expects an array of job descriptions as input,
830 # each job description describing an action to be performed via the normal server API.
831 var jobs = new HashMap[Int, NeoJob]
832
833 # Append a new job to the batch in JSON Format
834 # see `NeoJob`
835 fun new_job(nentity: NeoEntity): NeoJob do
836 var id = jobs.length
837 var job = new NeoJob(id, nentity)
838 jobs[id] = job
839 return job
840 end
841
842 # Load a node in batch mode also load labels, data and edges
843 fun load_node(node: NeoNode) do
844 var job = new_job(node)
845 job.action = load_node_data_action
846 job.method = "GET"
847 if node.id != null then
848 job.to = "/node/{node.id.to_s}"
849 else
850 job.to = "\{{node.batch_id.to_s}\}"
851 end
852 job = new_job(node)
853 job.action = load_node_labels_action
854 job.method = "GET"
855 if node.id != null then
856 job.to = "/node/{node.id.to_s}/labels"
857 else
858 job.to = "\{{node.batch_id.to_s}\}/labels"
859 end
860 end
861
862 # Load in and out edges into node
863 fun load_node_edges(node: NeoNode) do
864 var job = new_job(node)
865 job.action = load_node_in_edges_action
866 job.method = "GET"
867 if node.id != null then
868 job.to = "/node/{node.id.to_s}/relationships/in"
869 else
870 job.to = "\{{node.batch_id.to_s}\}/relationships/in"
871 end
872 job = new_job(node)
873 job.action = load_node_out_edges_action
874 job.method = "GET"
875 if node.id != null then
876 job.to = "/node/{node.id.to_s}/relationships/out"
877 else
878 job.to = "\{{node.batch_id.to_s}\}/relationships/out"
879 end
880 end
881
882 # Create a `NeoNode` or a `NeoEdge` in batch mode.
883 fun save_entity(nentity: NeoEntity) do
884 if nentity isa NeoNode then
885 save_node(nentity)
886 else if nentity isa NeoEdge then
887 save_edge(nentity)
888 else abort
889 end
890
891 # Create a node in batch mode also create labels and edges
892 fun save_node(node: NeoNode) do
893 if node.id != null or node.batch_id != null then return
894 # create node
895 var job = new_job(node)
896 node.batch_id = job.id
897 job.action = create_node_action
898 job.method = "POST"
899 job.to = "/node"
900 job.body = node.properties
901 # add labels
902 job = new_job(node)
903 job.method = "POST"
904 job.to = "\{{node.batch_id.to_s}\}/labels"
905 job.body = new JsonArray.from(node.labels)
906 # add edges
907 #save_edges(node.out_edges)
908 end
909
910 # Create multiple nodes
911 # also create labels and edges
912 fun save_nodes(nodes: Collection[NeoNode]) do for node in nodes do save_node(node)
913
914 # Create an edge
915 # nodes `edge.from` and `edge.to` will be created if not in base
916 fun save_edge(edge: NeoEdge) do
917 if edge.id != null or edge.batch_id != null then return
918 # create nodes
919 save_node(edge.from)
920 save_node(edge.to)
921 # create edge
922 var job = new_job(edge)
923 edge.batch_id = job.id
924 job.action = create_edge_action
925 job.method = "POST"
926 if edge.from.id != null then
927 job.to = "/node/{edge.from.id.to_s}/relationships"
928 else
929 job.to = "\{{edge.from.batch_id.to_s}\}/relationships"
930 end
931 job.body = edge.to_rest
932 end
933
934 # Create multiple edges
935 fun save_edges(edges: Collection[NeoEdge]) do for edge in edges do save_edge(edge)
936
937 # Execute the batch and update local nodes
938 fun execute: List[NeoError] do
939 var request = new JsonPOST(client.batch_url)
940 # request.headers["X-Stream"] = "true"
941 var json_jobs = new JsonArray
942 for job in jobs.values do json_jobs.add job.to_rest
943 request.json_data = json_jobs
944 var response = request.execute
945 var res = client.parse_response(response)
946 return finalize_batch(res)
947 end
948
949 # Associate data from response in original nodes and edges
950 private fun finalize_batch(response: Serializable): List[NeoError] do
951 var errors = new List[NeoError]
952 if not response isa JsonArray then
953 errors.add(new NeoError("Unexpected batch response format.", "Neo4jError"))
954 return errors
955 end
956 # print " {res.length} jobs executed"
957 for res in response do
958 if not res isa JsonObject then
959 errors.add(new NeoError("Unexpected job format in batch response.", "Neo4jError"))
960 continue
961 end
962 var id = res["id"].as(Int)
963 var job = jobs[id]
964 if job.action == create_node_action then
965 var node = job.entity.as(NeoNode)
966 node.batch_id = null
967 node.url = res["location"].to_s
968 else if job.action == create_edge_action then
969 var edge = job.entity.as(NeoEdge)
970 edge.batch_id = null
971 edge.url = res["location"].to_s
972 else if job.action == load_node_data_action then
973 var node = job.entity.as(NeoNode)
974 node.internal_properties = res["body"].as(JsonObject)["data"].as(JsonObject)
975 else if job.action == load_node_labels_action then
976 var node = job.entity.as(NeoNode)
977 var labels = new Array[String]
978 for l in res["body"].as(JsonArray) do labels.add l.to_s
979 node.internal_labels = labels
980 else if job.action == load_node_in_edges_action then
981 var node = job.entity.as(NeoNode)
982 var edges = res["body"].as(JsonArray)
983 node.internal_in_edges = new List[NeoEdge]
984 for edge in edges do
985 node.internal_in_edges.add client.load_edge(edge.as(JsonObject)["self"].to_s)
986 end
987 else if job.action == load_node_out_edges_action then
988 var node = job.entity.as(NeoNode)
989 var edges = res["body"].as(JsonArray)
990 node.internal_out_edges = new List[NeoEdge]
991 for edge in edges do
992 node.internal_out_edges.add client.load_edge(edge.as(JsonObject)["self"].to_s)
993 end
994 end
995 end
996 return errors
997 end
998
999 # JobActions
1000 # TODO replace with enum
1001
1002 private fun create_node_action: Int do return 1
1003 private fun create_edge_action: Int do return 2
1004 private fun load_node_data_action: Int do return 3
1005 private fun load_node_labels_action: Int do return 4
1006 private fun load_node_in_edges_action: Int do return 5
1007 private fun load_node_out_edges_action: Int do return 6
1008 end
1009
1010 # A job that can be executed in a `NeoBatch`
1011 # This is a representation of a neo job in JSON Format
1012 #
1013 # Each job description should contain a `to` attribute, with a value relative to the data API root
1014 # (so http://localhost:7474/db/data/node becomes just /node), and a `method` attribute containing
1015 # HTTP verb to use.
1016 #
1017 # Optionally you may provide a `body` attribute, and an `id` attribute to help you keep track
1018 # of responses, although responses are guaranteed to be returned in the same order the job
1019 # descriptions are received.
1020 class NeoJob
1021 # The job uniq `id`
1022 var id: Int
1023 # Entity targeted by the job
1024 var entity: NeoEntity
1025
1026 init(id: Int, entity: NeoEntity) do
1027 self.id = id
1028 self.entity = entity
1029 end
1030
1031 # What kind of action do the job
1032 # used to attach responses to original Neo objets
1033 private var action: nullable Int = null
1034
1035 # Job HTTP method: `GET`, `POST`, `PUT`, `DELETE`...
1036 var method: String
1037 # Job service target: `/node`, `/labels` etc...
1038 var to: String
1039 # Body to send with the job service request
1040 var body: nullable Serializable = null
1041
1042 # JSON formated job
1043 fun to_rest: JsonObject do
1044 var job = new JsonObject
1045 job["id"] = id
1046 job["method"] = method
1047 job["to"] = to
1048 if not body == null then
1049 job["body"] = body
1050 end
1051 return job
1052 end
1053 end