--- /dev/null
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Introduce base classes and services for JSON handling.
+module jsonable
+
+import standard
+private import json::json_parser
+private import json::json_lexer
+
+# Something that can be translated to JSON
+interface Jsonable
+ # Get the JSON representation of `self`
+ fun to_json: String is abstract
+end
+
+redef class String
+ super Jsonable
+
+ redef fun to_json do
+ var res = new FlatBuffer
+ res.add '\"'
+ for i in [0..self.length[ do
+ var char = self[i]
+ if char == '\\' then
+ res.append("\\\\")
+ continue
+ else if char == '\"' then
+ res.append("\\\"")
+ continue
+ else if char == '\/' then
+ res.append("\\/")
+ continue
+ else if char == '\n' then
+ res.append("\\n")
+ continue
+ else if char == '\r' then
+ res.append("\\r")
+ continue
+ else if char == '\t' then
+ res.append("\\t")
+ continue
+ end
+ res.add char
+ end
+ res.add '\"'
+ return res.write_to_string
+ end
+end
+
+redef class Int
+ super Jsonable
+
+ redef fun to_json do return self.to_s
+end
+
+redef class Float
+ super Jsonable
+
+ redef fun to_json do return self.to_s
+end
+
+redef class Bool
+ super Jsonable
+
+ redef fun to_json do return self.to_s
+end
+
+# A JSON Object representation that behaves like a `Map`
+class JsonObject
+ super Jsonable
+ super Map[String, nullable Jsonable]
+
+ private var map = new HashMap[String, nullable Jsonable]
+
+ # Create an empty `JsonObject`
+ #
+ # var obj = new JsonObject
+ # assert obj.is_empty
+ init do end
+
+ # Init the JSON Object from a Nit `Map`
+ #
+ # var map = new HashMap[String, String]
+ # map["foo"] = "bar"
+ # map["goo"] = "baz"
+ # var obj = new JsonObject.from(map)
+ # assert obj.length == 2
+ # assert obj["foo"] == "bar"
+ # assert obj["goo"] == "baz"
+ init from(items: Map[String, nullable Jsonable]) do
+ for k, v in items do map[k] = v
+ end
+
+ redef fun [](key) do return map[key]
+ redef fun []=(key, value) do map[key] = value
+ redef fun clear do map.clear
+ redef fun has_key(key) do return map.has_key(key)
+ redef fun is_empty do return map.is_empty
+ redef fun iterator do return map.iterator
+ redef fun keys do return map.keys
+ redef fun values do return map.values
+ redef fun length do return map.length
+
+ # Advanced query to get a value within `self` or its children.
+ #
+ # A query is composed of the keys to each object seperated by '.'.
+ #
+ # REQUIRE `self.has_key(query)`
+ #
+ # var obj1 = new JsonObject
+ # obj1["baz"] = "foobarbaz"
+ # var obj2 = new JsonObject
+ # obj2["bar"] = obj1
+ # var obj3 = new JsonObject
+ # obj3["foo"] = obj2
+ # assert obj3.get("foo.bar.baz") == "foobarbaz"
+ fun get(query: String): nullable Jsonable do
+ var keys = query.split(".").reversed
+ var key = keys.pop
+
+ assert has_key(key)
+ var node = self[key]
+
+ while not keys.is_empty do
+ key = keys.pop
+ assert node isa JsonObject and node.has_key(key)
+ node = node[key]
+ end
+ return node
+ end
+
+ # Create an empty `JsonObject`
+ #
+ # var obj = new JsonObject
+ # obj["foo"] = "bar"
+ # assert obj.to_json == "\{\"foo\": \"bar\"\}"
+ redef fun to_json do
+ var tpl = new Array[String]
+ tpl.add "\{"
+ var vals = new Array[String]
+ for k, v in self do
+ if v == null then
+ vals.add "{k.to_json}: null"
+ else
+ vals.add "{k.to_json}: {v.to_json}"
+ end
+ end
+ tpl.add vals.join(",")
+ tpl.add "\}"
+ return tpl.join("")
+ end
+
+ redef fun to_s do return to_json
+end
+
+# A JSON Array representation that behaves like a `Sequence`
+class JsonArray
+ super Jsonable
+ super Sequence[nullable Jsonable]
+
+ private var array = new Array[nullable Jsonable]
+
+ init do end
+
+ # init the JSON Array from a Nit `Collection`
+ init from(items: Collection[nullable Jsonable]) do
+ array.add_all(items)
+ end
+
+ redef fun [](key) do return array[key]
+ redef fun []=(key, value) do array[key] = value
+ redef fun add(value) do array.add(value)
+ redef fun clear do array.clear
+ redef fun is_empty do return array.is_empty
+ redef fun iterator do return array.iterator
+ redef fun length do return array.length
+
+ redef fun to_json do
+ var tpl = new Array[String]
+ tpl.add "["
+ var vals = new Array[String]
+ for v in self do
+ if v == null then
+ vals.add "null"
+ else
+ vals.add v.to_json
+ end
+ end
+ tpl.add vals.join(",")
+ tpl.add "]"
+ return tpl.join("")
+ end
+
+ redef fun to_s do return to_json
+end
+
+# An error in JSON format that can be returned by tools using JSON like parsers.
+#
+# var error = new JsonError("ErrorCode", "ErrorMessage")
+# assert error.to_s == "ErrorCode: ErrorMessage"
+# assert error.to_json == "\{\"error\": \"ErrorCode\", \"message\": \"ErrorMessage\"\}"
+class JsonError
+ super Jsonable
+
+ # The error code
+ var error: String
+
+ # The error message
+ var message: String
+
+ redef fun to_json do
+ var tpl = new Array[String]
+ tpl.add "\{"
+ tpl.add "\"error\": {error.to_json}, "
+ tpl.add "\"message\": {message.to_json}"
+ tpl.add "\}"
+ return tpl.join("")
+ end
+
+ redef fun to_s do return "{error}: {message}"
+end
+
+# Redef parser
+
+redef class Nvalue
+ private fun to_nit_object: nullable Jsonable is abstract
+end
+
+redef class Nvalue_number
+ redef fun to_nit_object
+ do
+ var text = n_number.text
+ if text.chars.has('.') or text.chars.has('e') or text.chars.has('E') then return text.to_f
+ return text.to_i
+ end
+end
+
+redef class Nvalue_string
+ redef fun to_nit_object do return n_string.to_nit_string
+end
+
+redef class Nvalue_true
+ redef fun to_nit_object do return true
+end
+
+redef class Nvalue_false
+ redef fun to_nit_object do return false
+end
+
+redef class Nvalue_null
+ redef fun to_nit_object do return null
+end
+
+redef class Nstring
+ # FIXME support \n, etc.
+ fun to_nit_string: String do
+ var res = new FlatBuffer
+ var skip = false
+ for i in [1..text.length-2] do
+ if skip then
+ skip = false
+ continue
+ end
+ var char = text[i]
+ if char == '\\' and i < text.length - 2 then
+ if text[i + 1] == '\\' then
+ res.add('\\')
+ skip = true
+ continue
+ end
+ if text[i + 1] == '\"' then
+ res.add('\"')
+ skip = true
+ continue
+ end
+ if text[i + 1] == '/' then
+ res.add('\/')
+ skip = true
+ continue
+ end
+ if text[i + 1] == 'n' then
+ res.add('\n')
+ skip = true
+ continue
+ end
+ if text[i + 1] == 'r' then
+ res.add('\r')
+ skip = true
+ continue
+ end
+ if text[i + 1] == 't' then
+ res.add('\t')
+ skip = true
+ continue
+ end
+ end
+ res.add char
+ end
+ return res.write_to_string
+ end
+end
+
+redef class Nvalue_object
+ redef fun to_nit_object
+ do
+ var obj = new JsonObject
+ var members = n_members
+ if members != null then
+ var pairs = members.pairs
+ for pair in pairs do obj[pair.name] = pair.value
+ end
+ return obj
+ end
+end
+
+redef class Nmembers
+ fun pairs: Array[Npair] is abstract
+end
+
+redef class Nmembers_tail
+ redef fun pairs
+ do
+ var arr = n_members.pairs
+ arr.add n_pair
+ return arr
+ end
+end
+
+redef class Nmembers_head
+ redef fun pairs do return [n_pair]
+end
+
+redef class Npair
+ fun name: String do return n_string.to_nit_string
+ fun value: nullable Jsonable do return n_value.to_nit_object
+end
+
+redef class Nvalue_array
+ redef fun to_nit_object
+ do
+ var arr = new JsonArray
+ var elements = n_elements
+ if elements != null then
+ var items = elements.items
+ for item in items do arr.add(item.to_nit_object)
+ end
+ return arr
+ end
+end
+
+redef class Nelements
+ fun items: Array[Nvalue] is abstract
+end
+
+redef class Nelements_tail
+ redef fun items
+ do
+ var items = n_elements.items
+ items.add(n_value)
+ return items
+ end
+end
+
+redef class Nelements_head
+ redef fun items do return [n_value]
+end
+
+redef class Text
+ # Parse a JSON String as Jsonable entities
+ #
+ # Example with `JsonObject`"
+ #
+ # var obj = "\{\"foo\": \{\"bar\": true, \"goo\": [1, 2, 3]\}\}".to_jsonable
+ # assert obj isa JsonObject
+ # assert obj["foo"] isa JsonObject
+ # assert obj["foo"].as(JsonObject)["bar"] == true
+ #
+ # Example with `JsonArray`
+ #
+ # var arr = "[1, 2, 3]".to_jsonable
+ # assert arr isa JsonArray
+ # assert arr.length == 3
+ # assert arr.first == 1
+ # assert arr.last == 3
+ #
+ # Example with `String`
+ #
+ # var str = "\"foo, bar, baz\"".to_jsonable
+ # assert str isa String
+ # assert str == "foo, bar, baz"
+ #
+ # Malformed JSON input returns a `JsonError` object
+ #
+ # var bad = "\{foo: \"bar\"\}".to_jsonable
+ # assert bad isa JsonError
+ # assert bad.error == "JsonLexerError"
+ fun to_jsonable: nullable Jsonable
+ do
+ var lexer = new Lexer_json(to_s)
+ var parser = new Parser_json
+ var tokens = lexer.lex
+ parser.tokens.add_all(tokens)
+ var root_node = parser.parse
+ if root_node isa NStart then
+ return root_node.n_0.to_nit_object
+ else if root_node isa NLexerError then
+ var pos = root_node.position
+ var msg = "{root_node.message} at {pos or else "<unknown>"} for {root_node}"
+ return new JsonError("JsonLexerError", msg)
+ else if root_node isa NParserError then
+ var pos = root_node.position
+ var msg = "{root_node.message} at {pos or else "<unknown>"} for {root_node}"
+ return new JsonError("JsonParsingError", msg)
+ else abort
+ end
+end
+
--- /dev/null
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Neo4j connector through its JSON REST API using curl.
+#
+# For ease of use and testing this module provide a wrapper to the `neo4j` command:
+#
+# # Start the Neo4j server
+# var srv = new Neo4jServer
+# assert srv.start_quiet
+#
+# In order to connect to Neo4j you need a connector:
+#
+# # Create new Neo4j client
+# var client = new Neo4jClient("http://localhost:7474")
+# assert client.is_ok
+#
+# The fundamental units that form a graph are nodes and relationships.
+#
+# Nodes are used to represent entities stored in base:
+#
+# # Create a disconnected node
+# var andres = new NeoNode
+# andres["name"] = "Andres"
+# # Connect the node to Neo4j
+# client.save_node(andres)
+# assert andres.is_linked
+# #
+# # Create a second node
+# var kate = new NeoNode
+# kate["name"] = "Kate"
+# client.save_node(kate)
+# assert kate.is_linked
+#
+# Relationships between nodes are a key part of a graph database.
+# They allow for finding related data. Just like nodes, relationships can have properties.
+#
+# # Create a relationship
+# var loves = new NeoEdge(andres, "LOVES", kate)
+# client.save_edge(loves)
+# assert loves.is_linked
+#
+# Nodes can also be loaded fron Neo4j:
+#
+# # Get a node from DB and explore edges
+# var url = andres.url.to_s
+# var from = client.load_node(url)
+# assert from["name"].to_s == "Andres"
+# var to = from.out_nodes("LOVES").first # follow the first LOVES relationship
+# assert to["name"].to_s == "Kate"
+#
+# For more details, see http://docs.neo4j.org/chunked/milestone/rest-api.html
+module neo4j
+
+import curl_json
+
+# Handles Neo4j server start and stop command
+#
+# `neo4j` binary must be in `PATH` in order to work
+class Neo4jServer
+
+ # Start the local Neo4j server instance
+ fun start: Bool do
+ sys.system("neo4j start console")
+ return true
+ end
+
+ # Like `start` but redirect the console output to `/dev/null`
+ fun start_quiet: Bool do
+ sys.system("neo4j start console > /dev/null")
+ return true
+ end
+
+ # Stop the local Neo4j server instance
+ fun stop: Bool do
+ sys.system("neo4j stop")
+ return true
+ end
+
+ # Like `stop` but redirect the console output to `/dev/null`
+ fun stop_quiet: Bool do
+ sys.system("neo4j stop > /dev/null")
+ return true
+ end
+end
+
+# `Neo4jClient` is needed to communicate through the REST API
+#
+# var client = new Neo4jClient("http://localhost:7474")
+# assert client.is_ok
+class Neo4jClient
+
+ # Neo4j REST services baseurl
+ var base_url: String
+ # REST service to get node data
+ private var node_url: String
+ # REST service to batch
+ private var batch_url: String
+ # REST service to send cypher requests
+ private var cypher_url: String
+
+ private var curl = new Curl
+
+ init(base_url: String) do
+ self.base_url = base_url
+ var root = service_root
+ if not root isa JsonObject then
+ print "Neo4jClientError: cannot connect to server at {base_url}"
+ abort
+ end
+ self.node_url = root["node"].to_s
+ self.batch_url = root["batch"].to_s
+ self.cypher_url = root["cypher"].to_s
+ end
+
+ fun service_root: Jsonable do return get("{base_url}/db/data")
+
+ # Is the connection with the Neo4j server ok?
+ fun is_ok: Bool do return service_root isa JsonObject
+
+ # Empty the graph
+ fun clear_graph do
+ cypher(new CypherQuery.from_string("MATCH (n) OPTIONAL MATCH n-[r]-() DELETE r, n"))
+ end
+
+ # Last errors
+ var errors = new Array[String]
+
+ # Nodes view stored locally
+ private var local_nodes = new HashMap[String, nullable NeoNode]
+
+ # Save the node in base
+ #
+ # var client = new Neo4jClient("http://localhost:7474")
+ # #
+ # # Create a node
+ # var andres = new NeoNode
+ # andres["name"] = "Andres"
+ # client.save_node(andres)
+ # assert andres.is_linked
+ #
+ # Once linked, nodes cannot be created twice:
+ #
+ # var oldurl = andres.url
+ # client.save_node(andres) # do nothing
+ # assert andres.url == oldurl
+ fun save_node(node: NeoNode): Bool do
+ if node.is_linked then return true
+ node.neo = self
+ var batch = new NeoBatch(self)
+ batch.save_node(node)
+ # batch.create_edges(node.out_edges)
+ var errors = batch.execute
+ if not errors.is_empty then
+ errors.add_all errors
+ return false
+ end
+ local_nodes[node.url.to_s] = node
+ return true
+ end
+
+ # Load a node from base
+ # Data, labels and edges will be loaded lazily.
+ fun load_node(url: String): NeoNode do
+ if local_nodes.has_key(url) then
+ var node = local_nodes[url]
+ if node != null then return node
+ end
+ var node = new NeoNode.from_neo(self, url)
+ local_nodes[url] = node
+ return node
+ end
+
+ # Remove the entity from base
+ fun delete_node(node: NeoNode): Bool do
+ if not node.is_linked then return false
+ var url = node.url.to_s
+ delete(url)
+ local_nodes[url] = null
+ node.url = null
+ return true
+ end
+
+ # Edges view stored locally
+ private var local_edges = new HashMap[String, nullable NeoEdge]
+
+ # Save the edge in base
+ # From and to nodes will be created.
+ #
+ # var client = new Neo4jClient("http://localhost:7474")
+ # #
+ # var andres = new NeoNode
+ # var kate = new NeoNode
+ # var edge = new NeoEdge(andres, "LOVES", kate)
+ # client.save_edge(edge)
+ # assert andres.is_linked
+ # assert kate.is_linked
+ # assert edge.is_linked
+ fun save_edge(edge: NeoEdge): Bool do
+ if edge.is_linked then return true
+ edge.neo = self
+ edge.from.out_edges.add edge
+ edge.to.in_edges.add edge
+ var batch = new NeoBatch(self)
+ batch.save_edge(edge)
+ var errors = batch.execute
+ if not errors.is_empty then
+ errors.add_all errors
+ return false
+ end
+ local_edges[edge.url.to_s] = edge
+ return true
+ end
+
+ # Load a edge from base
+ # Data will be loaded lazily.
+ fun load_edge(url: String): NeoEdge do
+ if local_edges.has_key(url) then
+ var node = local_edges[url]
+ if node != null then return node
+ end
+ var edge = new NeoEdge.from_neo(self, url)
+ local_edges[url] = edge
+ return edge
+ end
+
+ # Remove the edge from base
+ fun delete_edge(edge: NeoEdge): Bool do
+ if not edge.is_linked then return false
+ var url = edge.url.to_s
+ delete(url)
+ local_edges[url] = null
+ edge.url = null
+ return true
+ end
+
+ # Retrieve all nodes with specified `lbl`
+ fun nodes_with_label(lbl: String): Array[NeoNode] do
+ var res = get("{base_url}/db/data/label/{lbl}/nodes")
+ var nodes = new Array[NeoNode]
+ var batch = new NeoBatch(self)
+ for obj in res.as(JsonArray) do
+ var node = new NeoNode.from_json(self, obj.as(JsonObject))
+ batch.load_node(node)
+ nodes.add node
+ end
+ batch.execute
+ return nodes
+ end
+
+ # Perform a `CypherQuery`
+ # see: CypherQuery
+ fun cypher(query: CypherQuery): Jsonable do
+ return post("{cypher_url}", query.to_json)
+ end
+
+ # GET JSON data from `url`
+ fun get(url: String): Jsonable do
+ var request = new JsonGET(url, curl)
+ var response = request.execute
+ return parse_response(response)
+ end
+
+ # POST `params` to `url`
+ fun post(url: String, params: Jsonable): Jsonable do
+ var request = new JsonPOST(url, curl)
+ request.data = params
+ var response = request.execute
+ return parse_response(response)
+ end
+
+ # PUT `params` at `url`
+ fun put(url: String, params: Jsonable): Jsonable do
+ var request = new JsonPUT(url, curl)
+ request.data = params
+ var response = request.execute
+ return parse_response(response)
+ end
+
+ # DELETE `url`
+ fun delete(url: String): Jsonable do
+ var request = new JsonDELETE(url, curl)
+ var response = request.execute
+ return parse_response(response)
+ end
+
+ # Parse the cURL `response` as a JSON string
+ private fun parse_response(response: CurlResponse): Jsonable do
+ if response isa CurlResponseSuccess then
+ if response.body_str.is_empty then
+ return new JsonObject
+ else
+ var str = response.body_str
+ var res = str.to_jsonable
+ if res == null then
+ # empty response wrap it in empty object
+ return new JsonObject
+ else if res isa JsonObject and res.has_key("exception") then
+ var error = "Neo4jError::{res["exception"] or else "null"}"
+ var msg = ""
+ if res.has_key("message") then
+ msg = res["message"].to_s
+ end
+ return new JsonError(error, msg.to_json)
+ else
+ return res
+ end
+ end
+ else if response isa CurlResponseFailed then
+ return new JsonError("Curl error", "{response.error_msg} ({response.error_code})")
+ else
+ return new JsonError("Curl error", "Unexpected response '{response}'")
+ end
+ end
+end
+
+# A Cypher query for Neo4j REST API
+#
+# The Neo4j REST API allows querying with Cypher.
+# The results are returned as a list of string headers (columns), and a data part,
+# consisting of a list of all rows, every row consisting of a list of REST representations
+# of the field value - Node, Relationship, Path or any simple value like String.
+#
+# Example:
+#
+# var client = new Neo4jClient("http://localhost:7474")
+# var query = new CypherQuery
+# query.nmatch("(n)-[r:LOVES]->(m)")
+# query.nwhere("n.name=\"Andres\"")
+# query.nreturn("m.name")
+# var res = client.cypher(query).as(JsonObject)
+# assert res["data"].as(JsonArray).first.as(JsonArray).first == "Kate"
+#
+# For more details, see: http://docs.neo4j.org/chunked/milestone/rest-api-cypher.html
+class CypherQuery
+ # Query string to perform
+ private var query: String
+
+ # `params` to embed in the query like in prepared statements
+ var params = new JsonObject
+
+ init do
+ self.query = ""
+ end
+
+ # init the query from a query string
+ init from_string(query: String) do
+ self.query = query
+ end
+
+ # init the query with parameters
+ init with_params(params: JsonObject) do
+ self.params = params
+ end
+
+ # Add a `CREATE` statement to the query
+ fun ncreate(query: String): CypherQuery do
+ self.query = "{self.query}CREATE {query} "
+ return self
+ end
+
+ # Add a `START` statement to the query
+ fun nstart(query: String): CypherQuery do
+ self.query = "{self.query}START {query} "
+ return self
+ end
+
+ # Add a `MATCH` statement to the query
+ fun nmatch(query: String): CypherQuery do
+ self.query = "{self.query}MATCH {query} "
+ return self
+ end
+
+ # Add a `WHERE` statement to the query
+ fun nwhere(query: String): CypherQuery do
+ self.query = "{self.query}WHERE {query} "
+ return self
+ end
+
+ # Add a `AND` statement to the query
+ fun nand(query: String): CypherQuery do
+ self.query = "{self.query}AND {query} "
+ return self
+ end
+
+ # Add a `RETURN` statement to the query
+ fun nreturn(query: String): CypherQuery do
+ self.query = "{self.query}RETURN {query} "
+ return self
+ end
+
+ # Translate the query to JSON
+ fun to_json: JsonObject do
+ var obj = new JsonObject
+ obj["query"] = query
+ if not params.is_empty then
+ obj["params"] = params
+ end
+ return obj
+ end
+
+ redef fun to_s do return to_json.to_s
+end
+
+# The fundamental units that form a graph are nodes and relationships.
+#
+# Entities can have two states:
+#
+# * linked: the NeoEntity references an existing node or edge in Neo4j
+# * unlinked: the NeoEntity is not yet created in Neo4j
+#
+# If the entity is initialized unlinked from neo4j:
+#
+# # Create a disconnected node
+# var andres = new NeoNode
+# andres["name"] = "Andres"
+# # At this point, the node is not linked
+# assert not andres.is_linked
+#
+# Then we can link the entity to the base:
+#
+# # Init client
+# var client = new Neo4jClient("http://localhost:7474")
+# client.save_node(andres)
+# # The node is now linked
+# assert andres.is_linked
+#
+# Entities can also be loaded from Neo4j:
+#
+# # Get a node from Neo4j
+# var url = andres.url.to_s
+# var node = client.load_node(url)
+# assert node.is_linked
+#
+# When working in connected mode, all reading operations are executed lazily on the base:
+#
+# # Get the node `name` property
+# assert node["name"] == "Andres" # loaded lazily from base
+abstract class NeoEntity
+ # Neo4j client connector
+ private var neo: Neo4jClient
+
+ # Entity unique URL in Neo4j REST API
+ var url: nullable String
+
+ # Temp id used in batch mode to update the entity
+ private var batch_id: nullable Int = null
+
+ # Load the entity from base
+ private init from_neo(neo: Neo4jClient, url: String) do
+ self.neo = neo
+ self.url = url
+ end
+
+ # Init entity from JSON representation
+ private init from_json(neo: Neo4jClient, obj: JsonObject) do
+ self.neo = neo
+ self.url = obj["self"].to_s
+ self.internal_properties = obj["data"].as(JsonObject)
+ end
+
+ # Create a empty (and not-connected) entity
+ init do
+ self.internal_properties = new JsonObject
+ end
+
+ # Is the entity linked to a Neo4j database?
+ fun is_linked: Bool do return url != null
+
+ # In Neo4j, both nodes and relationships can contain properties.
+ # Properties are key-value pairs where the key is a string.
+ # Property values are JSON formatted.
+ #
+ # Properties are loaded lazily
+ fun properties: JsonObject do return internal_properties or else load_properties
+
+ private var internal_properties: nullable JsonObject = null
+
+ private fun load_properties: JsonObject do
+ var obj = neo.get("{url.to_s}/properties").as(JsonObject)
+ internal_properties = obj
+ return obj
+ end
+
+ # Get the entity `id` if connected to base
+ fun id: nullable Int do
+ if url == null then return null
+ return url.split("/").last.to_i
+ end
+
+ # Get the entity property at `key`
+ fun [](key: String): nullable Jsonable do
+ if not properties.has_key(key) then return null
+ return properties[key]
+ end
+
+ # Set the entity property `value` at `key`
+ fun []=(key: String, value: nullable Jsonable) do properties[key] = value
+
+ # Is the property `key` set?
+ fun has_key(key: String): Bool do return properties.has_key(key)
+
+ # Translate `self` to JSON
+ fun to_json: JsonObject do return properties
+end
+
+# Nodes are used to represent entities stored in base.
+# Apart from properties and relationships (edges),
+# nodes can also be labeled with zero or more labels.
+#
+# A label is a `String` that is used to group nodes into sets.
+# All nodes labeled with the same label belongs to the same set.
+# A node may be labeled with any number of labels, including none,
+# making labels an optional addition to the graph.
+#
+# Creating new nodes:
+#
+# var client = new Neo4jClient("http://localhost:7474")
+# #
+# var andres = new NeoNode
+# andres.labels.add "Person"
+# andres["name"] = "Andres"
+# andres["age"] = 22
+# client.save_node(andres)
+# assert andres.is_linked
+#
+# Get nodes from Neo4j:
+#
+# var url = andres.url.to_s
+# var node = client.load_node(url)
+# assert node["name"] == "Andres"
+# assert node["age"].to_s.to_i == 22
+class NeoNode
+ super NeoEntity
+
+ private var internal_labels: nullable Array[String] = null
+ private var internal_in_edges: nullable List[NeoEdge] = null
+ private var internal_out_edges: nullable List[NeoEdge] = null
+
+ init do
+ super
+ self.internal_labels = new Array[String]
+ self.internal_in_edges = new List[NeoEdge]
+ self.internal_out_edges = new List[NeoEdge]
+ end
+
+ redef fun to_s do
+ var tpl = new FlatBuffer
+ tpl.append "\{"
+ tpl.append "labels: [{labels.join(", ")}],"
+ tpl.append "data: {to_json}"
+ tpl.append "\}"
+ return tpl.write_to_string
+ end
+
+ # A label is a `String` that is used to group nodes into sets.
+ # A node may be labeled with any number of labels, including none.
+ # All nodes labeled with the same label belongs to the same set.
+ #
+ # Many database queries can work with these sets instead of the whole graph,
+ # making queries easier to write and more efficient.
+ #
+ # Labels are loaded lazily
+ fun labels: Array[String] do return internal_labels or else load_labels
+
+ private fun load_labels: Array[String] do
+ var labels = new Array[String]
+ var res = neo.get("{url.to_s}/labels")
+ if res isa JsonArray then
+ for val in res do labels.add val.to_s
+ end
+ internal_labels = labels
+ return labels
+ end
+
+ # Get the list of `NeoEdge` pointing to `self`
+ #
+ # Edges are loaded lazily
+ fun in_edges: List[NeoEdge] do return internal_in_edges or else load_in_edges
+
+ private fun load_in_edges: List[NeoEdge] do
+ var edges = new List[NeoEdge]
+ var res = neo.get("{url.to_s}/relationships/in").as(JsonArray)
+ for obj in res do
+ edges.add(new NeoEdge.from_json(neo.as(not null), obj.as(JsonObject)))
+ end
+ internal_in_edges = edges
+ return edges
+ end
+
+ # Get the list of `NeoEdge` pointing from `self`
+ #
+ # Edges are loaded lazily
+ fun out_edges: List[NeoEdge] do return internal_out_edges or else load_out_edges
+
+ private fun load_out_edges: List[NeoEdge] do
+ var edges = new List[NeoEdge]
+ var res = neo.get("{url.to_s}/relationships/out")
+ for obj in res.as(JsonArray) do
+ edges.add(new NeoEdge.from_json(neo, obj.as(JsonObject)))
+ end
+ internal_out_edges = edges
+ return edges
+ end
+
+ # Get nodes pointed by `self` following a `rel_type` edge
+ fun out_nodes(rel_type: String): Array[NeoNode] do
+ var res = new Array[NeoNode]
+ for edge in out_edges do
+ if edge.rel_type == rel_type then res.add edge.to
+ end
+ return res
+ end
+
+ # Get nodes pointing to `self` following a `rel_type` edge
+ fun in_nodes(rel_type: String): Array[NeoNode] do
+ var res = new Array[NeoNode]
+ for edge in in_edges do
+ if edge.rel_type == rel_type then res.add edge.from
+ end
+ return res
+ end
+end
+
+# A relationship between two nodes.
+# Relationships between nodes are a key part of a graph database.
+# They allow for finding related data. Just like nodes, relationships can have properties.
+#
+# Create a relationship:
+#
+# var client = new Neo4jClient("http://localhost:7474")
+# # Create nodes
+# var andres = new NeoNode
+# andres["name"] = "Andres"
+# var kate = new NeoNode
+# kate["name"] = "Kate"
+# # Create a relationship of type `LOVES`
+# var loves = new NeoEdge(andres, "LOVES", kate)
+# client.save_edge(loves)
+# assert loves.is_linked
+#
+# Get an edge from DB:
+#
+# var url = loves.url.to_s
+# var edge = client.load_edge(url)
+# assert edge.from["name"].to_s == "Andres"
+# assert edge.to["name"].to_s == "Kate"
+class NeoEdge
+ super NeoEntity
+
+ private var internal_from: nullable NeoNode
+ private var internal_to: nullable NeoNode
+ private var internal_type: nullable String
+ private var internal_from_url: nullable String
+ private var internal_to_url: nullable String
+
+ init(from: NeoNode, rel_type: String, to: NeoNode) do
+ self.internal_from = from
+ self.internal_to = to
+ self.internal_type = rel_type
+ end
+
+ redef init from_neo(neo, url) do
+ super
+ var obj = neo.get(url.as(not null)).as(JsonObject)
+ self.internal_type = obj["type"].to_s
+ self.internal_from_url = obj["start"].to_s
+ self.internal_to_url = obj["end"].to_s
+ end
+
+ redef init from_json(neo, obj) do
+ super
+ self.internal_type = obj["type"].to_s
+ self.internal_from_url = obj["start"].to_s
+ self.internal_to_url = obj["end"].to_s
+ end
+
+ # Get `from` node
+ fun from: NeoNode do return internal_from or else load_from
+
+ private fun load_from: NeoNode do
+ var node = new NeoNode.from_neo(neo, internal_from_url.to_s)
+ internal_from = node
+ return node
+ end
+
+ # Get `to` node
+ fun to: NeoNode do return internal_to or else load_to
+
+ private fun load_to: NeoNode do
+ var node = new NeoNode.from_neo(neo, internal_to_url.to_s)
+ internal_to = node
+ return node
+ end
+
+ # Get edge type
+ fun rel_type: nullable String do return internal_type
+
+ redef fun to_json do
+ var obj = new JsonObject
+ if to.is_linked then
+ obj["to"] = to.url
+ else
+ obj["to"] = "\{{to.batch_id.to_s}\}"
+ end
+ obj["type"] = rel_type
+ obj["data"] = properties
+ return obj
+ end
+end
+
+# Batches are used to perform multiple operations on the REST API in one cURL request.
+# This can significantly improve performance for large insert and update operations.
+#
+# see: http://docs.neo4j.org/chunked/milestone/rest-api-batch-ops.html
+#
+# This service is transactional.
+# If any of the operations performed fails (returns a non-2xx HTTP status code),
+# the transaction will be rolled back and all changes will be undone.
+#
+# Example:
+#
+# var client = new Neo4jClient("http://localhost:7474")
+# #
+# var node1 = new NeoNode
+# var node2 = new NeoNode
+# var edge = new NeoEdge(node1, "TO", node2)
+# #
+# var batch = new NeoBatch(client)
+# batch.save_node(node1)
+# batch.save_node(node2)
+# batch.save_edge(edge)
+# batch.execute
+# #
+# assert node1.is_linked
+# assert node2.is_linked
+# assert edge.is_linked
+class NeoBatch
+
+ # Neo4j client connector
+ var client: Neo4jClient
+
+ # Jobs to perform in this batch
+ #
+ # The batch service expects an array of job descriptions as input,
+ # each job description describing an action to be performed via the normal server API.
+ var jobs = new HashMap[Int, NeoJob]
+
+ # Append a new job to the batch in JSON Format
+ # see `NeoJob`
+ fun new_job(nentity: NeoEntity): NeoJob do
+ var id = jobs.length
+ var job = new NeoJob(id, nentity)
+ jobs[id] = job
+ return job
+ end
+
+ # Load a node in batch mode also load labels, data and edges
+ fun load_node(node: NeoNode) do
+ load_node_data(node)
+ load_node_labels(node)
+ load_node_out_edges(node)
+ end
+
+ # Load data into node
+ private fun load_node_data(node: NeoNode) do
+ var job = new_job(node)
+ job.action = load_node_data_action
+ job.method = "GET"
+ if node.id != null then
+ job.to = "/node/{node.id.to_s}"
+ else
+ job.to = "\{{node.batch_id.to_s}\}"
+ end
+ end
+
+ # Load labels into node
+ private fun load_node_labels(node: NeoNode) do
+ var job = new_job(node)
+ job.action = load_node_labels_action
+ job.method = "GET"
+ if node.id != null then
+ job.to = "/node/{node.id.to_s}/labels"
+ else
+ job.to = "\{{node.batch_id.to_s}\}/labels"
+ end
+ end
+
+ # Load out edges into node
+ private fun load_node_out_edges(node: NeoNode) do
+ var job = new_job(node)
+ job.action = load_node_out_edges_action
+ job.method = "GET"
+ if node.id != null then
+ job.to = "/node/{node.id.to_s}/relationships/out"
+ else
+ job.to = "\{{node.batch_id.to_s}\}/relationships/out"
+ end
+ end
+
+ # Create a node in batch mode also create labels and edges
+ fun save_node(node: NeoNode) do
+ if node.id != null or node.batch_id != null then return
+ # create node
+ var job = new_job(node)
+ node.batch_id = job.id
+ job.action = create_node_action
+ job.method = "POST"
+ job.to = "/node"
+ job.body = node.properties
+ # add labels
+ job = new_job(node)
+ job.method = "POST"
+ job.to = "\{{node.batch_id.to_s}\}/labels"
+ job.body = new JsonArray.from(node.labels)
+ # add edges
+ save_edges(node.out_edges)
+ end
+
+ # Create multiple nodes
+ # also create labels and edges
+ fun save_nodes(nodes: Collection[NeoNode]) do for node in nodes do save_node(node)
+
+ # Create an edge
+ # nodes `edge.from` and `edge.to` will be created if not in base
+ fun save_edge(edge: NeoEdge) do
+ if edge.id != null or edge.batch_id != null then return
+ # create nodes
+ save_node(edge.from)
+ save_node(edge.to)
+ # create edge
+ var job = new_job(edge)
+ edge.batch_id = job.id
+ job.action = create_edge_action
+ job.method = "POST"
+ if edge.from.id != null then
+ job.to = "/node/{edge.from.id.to_s}/relationships"
+ else
+ job.to = "\{{edge.from.batch_id.to_s}\}/relationships"
+ end
+ job.body = edge.to_json
+ end
+
+ # Create multiple edges
+ fun save_edges(edges: Collection[NeoEdge]) do for edge in edges do save_edge(edge)
+
+ # Execute the batch and update local nodes
+ fun execute: List[JsonError] do
+ var request = new JsonPOST(client.batch_url, client.curl)
+ # request.headers["X-Stream"] = "true"
+ var json_jobs = new JsonArray
+ for job in jobs.values do json_jobs.add job.to_json
+ request.data = json_jobs
+ var response = request.execute
+ var res = client.parse_response(response)
+ return finalize_batch(res)
+ end
+
+ # Associate data from response in original nodes and edges
+ private fun finalize_batch(response: Jsonable): List[JsonError] do
+ var errors = new List[JsonError]
+ if not response isa JsonArray then
+ errors.add(new JsonError("Neo4jError", "Unexpected batch response format"))
+ return errors
+ end
+ # print " {res.length} jobs executed"
+ for res in response do
+ if not res isa JsonObject then
+ errors.add(new JsonError("Neo4jError", "Unexpected job format in batch response"))
+ continue
+ end
+ var id = res["id"].as(Int)
+ var job = jobs[id]
+ if job.action == create_node_action then
+ var node = job.entity.as(NeoNode)
+ node.batch_id = null
+ node.url = res["location"].to_s
+ else if job.action == create_edge_action then
+ var edge = job.entity.as(NeoEdge)
+ edge.batch_id = null
+ edge.url = res["location"].to_s
+ else if job.action == load_node_data_action then
+ var node = job.entity.as(NeoNode)
+ node.internal_properties = res["body"].as(JsonObject)["data"].as(JsonObject)
+ else if job.action == load_node_labels_action then
+ var node = job.entity.as(NeoNode)
+ var labels = new Array[String]
+ for l in res["body"].as(JsonArray) do labels.add l.to_s
+ node.internal_labels = labels
+ else if job.action == load_node_out_edges_action then
+ var node = job.entity.as(NeoNode)
+ var edges = res["body"].as(JsonArray)
+ node.internal_out_edges = new List[NeoEdge]
+ for edge in edges do
+ node.internal_out_edges.add new NeoEdge.from_json(client, edge.as(JsonObject))
+ end
+ end
+ end
+ return errors
+ end
+
+ # JobActions
+ # TODO replace with enum
+
+ private fun create_node_action: Int do return 1
+ private fun create_edge_action: Int do return 2
+ private fun load_node_data_action: Int do return 3
+ private fun load_node_labels_action: Int do return 4
+ private fun load_node_out_edges_action: Int do return 5
+end
+
+# A job that can be executed in a `NeoBatch`
+# This is a representation of a neo job in JSON Format
+#
+# Each job description should contain a `to` attribute, with a value relative to the data API root
+# (so http://localhost:7474/db/data/node becomes just /node), and a `method` attribute containing
+# HTTP verb to use.
+#
+# Optionally you may provide a `body` attribute, and an `id` attribute to help you keep track
+# of responses, although responses are guaranteed to be returned in the same order the job
+# descriptions are received.
+class NeoJob
+ # The job uniq `id`
+ var id: Int
+ # Entity targeted by the job
+ var entity: NeoEntity
+
+ init(id: Int, entity: NeoEntity) do
+ self.id = id
+ self.entity = entity
+ end
+
+ # What kind of action do the job
+ # used to attach responses to original Neo objets
+ private var action: nullable Int = null
+
+ # Job HTTP method: `GET`, `POST`, `PUT`, `DELETE`...
+ var method: String
+ # Job service target: `/node`, `/labels` etc...
+ var to: String
+ # Body to send with the job service request
+ var body: nullable Jsonable = null
+
+ # JSON formated job
+ fun to_json: JsonObject do
+ var job = new JsonObject
+ job["id"] = id
+ job["method"] = method
+ job["to"] = to
+ if not body == null then
+ job["body"] = body
+ end
+ return job
+ end
+end
+