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