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