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