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