lib: introduce neo4j connector
authorAlexandre Terrasa <alexandre@moz-code.org>
Thu, 17 Jul 2014 16:15:51 +0000 (12:15 -0400)
committerAlexandre Terrasa <alexandre@moz-code.org>
Thu, 17 Jul 2014 16:15:51 +0000 (12:15 -0400)
Signed-off-by: Alexandre Terrasa <alexandre@moz-code.org>

lib/neo4j/curl_json.nit [new file with mode: 0644]
lib/neo4j/jsonable.nit [new file with mode: 0644]
lib/neo4j/neo4j.nit [new file with mode: 0644]
tests/sav/test_neo4j.res [new file with mode: 0644]
tests/sav/test_neo4j_batch.res [new file with mode: 0644]
tests/test_neo4j.nit [new file with mode: 0644]
tests/test_neo4j_batch.nit [new file with mode: 0644]

diff --git a/lib/neo4j/curl_json.nit b/lib/neo4j/curl_json.nit
new file mode 100644 (file)
index 0000000..77d3650
--- /dev/null
@@ -0,0 +1,182 @@
+# 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.
+
+# cURL requests compatible with the JSON REST APIs.
+module curl_json
+
+import jsonable
+intrude import curl
+
+# An abstract request that defines most of the standard options for Neo4j REST API
+abstract class JsonCurlRequest
+       super CurlRequest
+       super CCurlCallbacks
+       super CurlCallbacksRegisterIntern
+
+       # REST API service URL
+       var url: String
+
+       init (url: String, curl: nullable Curl) do
+               self.url = url
+               self.curl = curl
+
+               init_headers
+       end
+
+       # OAuth token
+       var auth: nullable String writable
+
+       # User agent (is used by github to contact devs in case of problems)
+       # Eg. "Awesome-Octocat-App"
+       var user_agent: nullable String writable
+
+       # HTTP headers to send
+       var headers: nullable HeaderMap writable = null
+
+
+       # init HTTP headers for Neo4j REST API
+       protected fun init_headers do
+               headers = new HeaderMap
+               headers["Accept"] = "application/json; charset=UTF-8"
+               headers["Transfer-Encoding"] = "chunked"
+               if auth != null then
+                       headers["Authorization"] = "token {auth.to_s}"
+               end
+               if user_agent != null then
+                       headers["User-Agent"] = user_agent.to_s
+               end
+       end
+
+       redef fun execute do
+               init_headers
+               if not self.curl.is_ok then
+                       return answer_failure(0, "Curl instance is not correctly initialized")
+               end
+
+               var success_response = new CurlResponseSuccess
+               var callback_receiver: CurlCallbacks = success_response
+               if self.delegate != null then callback_receiver = self.delegate.as(not null)
+
+               var err
+
+               err = self.curl.prim_curl.easy_setopt(new CURLOption.follow_location, 1)
+               if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+
+               err = self.curl.prim_curl.easy_setopt(new CURLOption.http_version, 1)
+               if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+
+
+               err = self.curl.prim_curl.easy_setopt(new CURLOption.url, url)
+               if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+
+               err = self.curl.prim_curl.register_callback(callback_receiver, new CURLCallbackType.header)
+               if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+
+               err = self.curl.prim_curl.register_callback(callback_receiver, new CURLCallbackType.body)
+               if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+
+               # HTTP Header
+               if self.headers != null then
+                       var headers_joined = self.headers.join_pairs(": ")
+                       err = self.curl.prim_curl.easy_setopt(
+                               new CURLOption.httpheader, headers_joined.to_curlslist)
+                       if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+               end
+
+               var err_hook = execute_hook
+           if err_hook != null then return err_hook
+
+               var err_resp = perform
+               if err_resp != null then return err_resp
+
+               var st_code = self.curl.prim_curl.easy_getinfo_long(new CURLInfoLong.response_code)
+               if not st_code == null then success_response.status_code = st_code.response
+
+               return success_response
+       end
+
+       # Hook to implement in concrete requests
+       protected fun execute_hook: nullable CurlResponse do return null
+end
+
+# HTTP GET command
+class JsonGET
+       super JsonCurlRequest
+
+       redef fun execute_hook do
+               var err = self.curl.prim_curl.easy_setopt(new CURLOption.get, true)
+               if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+               return null
+       end
+end
+
+# HTTP POST command that sends JSON data
+class JsonPOST
+       super JsonCurlRequest
+
+       var data: nullable Jsonable writable = null
+
+       redef fun init_headers do
+               super
+               headers["Content-Type"] = "application/json"
+       end
+
+       redef fun execute_hook do
+               var err = self.curl.prim_curl.easy_setopt(new CURLOption.post, true)
+               if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+
+               if self.data != null then
+                       var postdatas = self.data.to_json
+                       err = self.curl.prim_curl.easy_setopt(new CURLOption.postfields, postdatas)
+                       if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+               end
+               return null
+       end
+end
+
+# HTTP DELETE command
+class JsonDELETE
+       super JsonCurlRequest
+
+       redef fun execute_hook do
+               var err = self.curl.prim_curl.easy_setopt(new CURLOption.custom_request, "DELETE")
+               if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+               return null
+       end
+end
+
+# HTTP PUT command that sends JSON data
+class JsonPUT
+       super JsonCurlRequest
+
+       var data: nullable Jsonable writable = null
+
+       redef fun init_headers do
+               super
+               headers["Content-Type"] = "application/json"
+       end
+
+       redef fun execute_hook do
+               var err = self.curl.prim_curl.easy_setopt(new CURLOption.custom_request, "PUT")
+               if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+
+               if self.data != null then
+                       var postdatas = self.data.to_json
+                       err = self.curl.prim_curl.easy_setopt(new CURLOption.postfields, postdatas)
+                       if not err.is_ok then return answer_failure(err.to_i, err.to_s)
+               end
+               return null
+       end
+end
+
diff --git a/lib/neo4j/jsonable.nit b/lib/neo4j/jsonable.nit
new file mode 100644 (file)
index 0000000..630a992
--- /dev/null
@@ -0,0 +1,425 @@
+# 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
+
diff --git a/lib/neo4j/neo4j.nit b/lib/neo4j/neo4j.nit
new file mode 100644 (file)
index 0000000..7d36d56
--- /dev/null
@@ -0,0 +1,967 @@
+# 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
+
diff --git a/tests/sav/test_neo4j.res b/tests/sav/test_neo4j.res
new file mode 100644 (file)
index 0000000..7ef1fa8
--- /dev/null
@@ -0,0 +1,27 @@
+# Test local
+
+Andres
+24
+true
+[1,2,3]
+PERSON MALE
+LOVES
+1999
+Andres LOVES Kate
+Kate IS LOVED BY Andres
+
+# Test lazy
+
+Andres
+24
+true
+[1,2,3]
+PERSON MALE
+Kate
+25
+false
+PERSON FEMALE
+LOVES
+1999
+Andres LOVES Kate
+Kate IS LOVED BY Andres
diff --git a/tests/sav/test_neo4j_batch.res b/tests/sav/test_neo4j_batch.res
new file mode 100644 (file)
index 0000000..80bb707
--- /dev/null
@@ -0,0 +1,15 @@
+# Save batch
+
+Andres
+24
+true
+[1,2,3]
+PERSON MALE
+Kate
+25
+false
+PERSON FEMALE
+LOVES
+1999
+Andres LOVES Kate
+Kate IS LOVED BY Andres
diff --git a/tests/test_neo4j.nit b/tests/test_neo4j.nit
new file mode 100644 (file)
index 0000000..f8cbd4b
--- /dev/null
@@ -0,0 +1,126 @@
+# 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.
+
+import neo4j
+
+var key = get_time
+
+var srv = new Neo4jServer
+srv.start_quiet
+
+print "# Test local\n"
+
+var client = new Neo4jClient("http://localhost:7474")
+assert client.is_ok
+
+var andres = new NeoNode
+andres.labels.add_all(["PERSON", "MALE"])
+andres["name"] = "Andres"
+andres["age"] = 24
+andres["status"] = true
+andres["groups"] = new JsonArray.from([1, 2, 3])
+andres["key"] = key
+
+# Create node
+client.save_node(andres)
+assert andres.is_linked
+var andres_url = andres.url.to_s
+
+# Read Node
+var res1 = client.load_node(andres_url)
+assert res1.is_linked
+print res1["name"].to_s
+print res1["age"].to_s
+print res1["status"].to_s
+print res1["groups"].to_s
+print res1.labels.join(" ")
+assert res1.out_edges.is_empty
+
+# Create a second node
+var kate = new NeoNode
+kate.labels.add_all(["PERSON", "FEMALE"])
+kate["name"] = "Kate"
+kate["age"] = 25
+kate["status"] = false
+client.save_node(kate)
+assert kate.is_linked
+var kate_url = kate.url.to_s
+var res2 = client.load_node(kate_url)
+
+# Create an edge
+var loves = new NeoEdge(andres, "LOVES", kate)
+loves["since"] = 1999
+client.save_edge(loves)
+assert loves.is_linked
+var loves_url = loves.url.to_s
+
+# Check edge
+assert loves.from == andres
+assert loves.from == res1
+assert loves.to == kate
+assert loves.to == res2
+
+# Read edge
+var res3 = client.load_edge(loves_url)
+assert res3.is_linked
+print res3.rel_type.to_s
+print res3["since"].to_s
+
+# Follow edge
+print "{andres["name"].to_s} LOVES {andres.out_nodes("LOVES").first["name"].to_s}"
+print "{kate["name"].to_s} IS LOVED BY {kate.in_nodes("LOVES").first["name"].to_s}"
+
+print "\n# Test lazy\n"
+
+client = new Neo4jClient("http://localhost:7474")
+assert client.is_ok
+
+# Read Andres
+var res4 = client.load_node(andres_url)
+assert res4.is_linked
+print res4["name"].to_s
+print res4["age"].to_s
+print res4["status"].to_s
+print res4["groups"].to_s
+print res4.labels.join(" ")
+assert res4.in_edges.is_empty
+assert not res4.out_edges.is_empty
+
+# Read Kate
+var res5 = client.load_node(kate_url)
+assert res5.is_linked
+print res5["name"].to_s
+print res5["age"].to_s
+print res5["status"].to_s
+print res5.labels.join(" ")
+assert not res5.in_edges.is_empty
+assert res5.out_edges.is_empty
+
+# Read LOVES
+var res6 = client.load_edge(loves_url)
+assert res6.is_linked
+print res6.rel_type.to_s
+print res6["since"].to_s
+print "{res4["name"].to_s} LOVES {res4.out_nodes("LOVES").first["name"].to_s}"
+print "{res5["name"].to_s} IS LOVED BY {res5.in_nodes("LOVES").first["name"].to_s}"
+
+# Test Cypher
+var query = (new CypherQuery).
+       nmatch("(n: MALE)-[r: LOVES]->(m)").
+       nwhere("n.name = 'Andres'").
+       nand("n.key = {key}").
+       nreturn("n, r, m")
+var res7 = client.cypher(query)
+assert not res7.as(JsonObject)["data"].as(JsonArray).is_empty
+
diff --git a/tests/test_neo4j_batch.nit b/tests/test_neo4j_batch.nit
new file mode 100644 (file)
index 0000000..e6ac890
--- /dev/null
@@ -0,0 +1,99 @@
+# 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.
+
+import neo4j
+
+var srv = new Neo4jServer
+srv.start_quiet
+
+var key = get_time
+
+var andres = new NeoNode
+andres.labels.add_all(["PERSON", "MALE"])
+andres["name"] = "Andres"
+andres["age"] = 24
+andres["status"] = true
+andres["groups"] = new JsonArray.from([1, 2, 3])
+andres["key"] = key
+
+var kate = new NeoNode
+kate.labels.add_all(["PERSON", "FEMALE"])
+kate["name"] = "Kate"
+kate["age"] = 25
+kate["status"] = false
+
+var loves = new NeoEdge(andres, "LOVES", kate)
+loves["since"] = 1999
+
+var client = new Neo4jClient("http://localhost:7474")
+assert client.is_ok
+
+print "# Save batch\n"
+
+var batch = new NeoBatch(client)
+batch.save_node(andres)
+batch.save_node(kate)
+batch.save_edge(loves)
+var errors = batch.execute
+
+assert errors.is_empty
+assert andres.is_linked
+assert kate.is_linked
+assert loves.is_linked
+
+var andres_url = andres.url.to_s
+var kate_url = kate.url.to_s
+var loves_url = loves.url.to_s
+
+client = new Neo4jClient("http://localhost:7474")
+assert client.is_ok
+
+# Read Andres
+var res4 = client.load_node(andres_url)
+assert res4.is_linked
+print res4["name"].to_s
+print res4["age"].to_s
+print res4["status"].to_s
+print res4["groups"].to_s
+print res4.labels.join(" ")
+assert res4.in_edges.is_empty
+assert not res4.out_edges.is_empty
+
+# Read Kate
+var res5 = client.load_node(kate_url)
+assert res5.is_linked
+print res5["name"].to_s
+print res5["age"].to_s
+print res5["status"].to_s
+print res5.labels.join(" ")
+assert not res5.in_edges.is_empty
+assert res5.out_edges.is_empty
+
+# Read LOVES
+var res6 = client.load_edge(loves_url)
+assert res6.is_linked
+print res6.rel_type.to_s
+print res6["since"].to_s
+print "{res4["name"].to_s} LOVES {res4.out_nodes("LOVES").first["name"].to_s}"
+print "{res5["name"].to_s} IS LOVED BY {res5.in_nodes("LOVES").first["name"].to_s}"
+
+# Test Cypher
+var query = (new CypherQuery).
+       nmatch("(n: MALE)-[r: LOVES]->(m)").
+       nwhere("n.name = 'Andres'").
+       nand("n.key = {key}").
+       nreturn("n, r, m")
+var res7 = client.cypher(query)
+assert res7.as(JsonObject)["data"].as(JsonArray).length == 1
+