├── test ├── fixtures │ ├── error_response.json │ └── tx_response.json ├── neo4j_sips_connection_test.exs ├── neo4j_sips_response_test.exs ├── test_helper.exs ├── neo4j_sips_server_test.exs ├── neo4j_sips_transaction_test.exs └── neo4j_sips_query_test.exs ├── lib ├── neo4j_sips │ ├── error.ex │ ├── application.ex │ ├── http.ex │ ├── response.ex │ ├── query.ex │ ├── utils.ex │ ├── connection.ex │ ├── transaction.ex │ └── server.ex └── neo4j_sips.ex ├── .travis.yml ├── config ├── test.exs ├── dev.exs └── config.exs ├── LICENSE ├── mix.exs ├── CHANGELOG.md ├── mix.lock ├── .gitignore └── README.md /test/fixtures/error_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "results" : [ ], 3 | "errors" : [ { 4 | "code" : "Neo.ClientError.Statement.InvalidSyntax", 5 | "message" : "Invalid input 'T': expected (line 1, column 1 (offset: 0))\n\"This is not a valid Cypher Statement.\"\n ^" 6 | } ] 7 | } 8 | -------------------------------------------------------------------------------- /test/neo4j_sips_connection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Connection.Test do 2 | use ExUnit.Case, async: true 3 | 4 | alias Neo4j.Sips, as: Neo4j 5 | 6 | test "there the server version availability" do 7 | assert Neo4j.server_version =~ ~r{^\d+\.\d+\.\d+$} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/neo4j_sips/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Error do 2 | @moduledoc """ 3 | This module defines a `Neo4j.Sips.Error` simple structure containing two fields: 4 | 5 | * `code` - the error code 6 | * `message` - the error details 7 | """ 8 | 9 | defexception [:code, :message] 10 | end 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | services: docker 3 | language: elixir 4 | 5 | elixir: 6 | - 1.4 7 | - 1.3 8 | 9 | before_install: 10 | - docker run --name neo4j -d -p 7373:7474 -e 'NEO4J_AUTH=none' neo4j:3.0.6 11 | - docker logs -f neo4j | sed /Bolt\ enabled/q 12 | 13 | script: 14 | - mix test 15 | -------------------------------------------------------------------------------- /test/fixtures/tx_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "commit" : "http://localhost:7474/db/data/transaction/7/commit", 3 | "results" : [{ 4 | "columns" : [ "n" ], 5 | "data" : [{"row" : [ {"name" : "My Node"} ]}] 6 | }], 7 | "transaction" : { 8 | "expires" : "Sun, 9 Aug 2015 14:33:42 +0000" 9 | }, 10 | "errors" : [ ] 11 | } 12 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :neo4j_sips, Neo4j, 4 | url: "http://localhost:7373", 5 | pool_size: 5, 6 | max_overflow: 2 7 | 8 | 9 | level = if System.get_env("DEBUG") do 10 | :debug 11 | else 12 | :info 13 | end 14 | 15 | config :logger, :console, 16 | level: level, 17 | format: "$date $time [$level] $metadata$message\n" 18 | -------------------------------------------------------------------------------- /lib/neo4j_sips/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | children = [] 10 | 11 | opts = [strategy: :one_for_one, name: Neo4j.Supervisor] 12 | Supervisor.start_link(children, opts) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # config :neo4j_sips, Neo4j, 4 | # url: "http://localhost:7474", 5 | # pool_size: 5, 6 | # max_overflow: 2, 7 | # #timeout: :infinity, 8 | # timeout: 3000 9 | # 10 | # level = if System.get_env("DEBUG") do 11 | # :debug 12 | # else 13 | # :info 14 | # end 15 | # 16 | # config :logger, :console, 17 | # level: level, 18 | # format: "$date $time [$level] $metadata$message\n" 19 | 20 | -------------------------------------------------------------------------------- /lib/neo4j_sips/http.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Http do 2 | @moduledoc """ 3 | 4 | module responsible with prepping the headers and delegating any requests to 5 | HTTPoison 6 | """ 7 | use HTTPoison.Base 8 | 9 | @doc false 10 | def headers do 11 | ConCache.get(:neo4j_sips_cache, :http_headers) 12 | end 13 | 14 | @doc false 15 | @spec process_request_headers(map) :: map 16 | def process_request_headers(header) do 17 | headers() ++ header 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /test/neo4j_sips_response_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Response.Test do 2 | use ExUnit.Case, async: true 3 | 4 | alias Neo4j.Sips.Response 5 | alias Neo4j.Sips.TestHelper 6 | 7 | test "loading a supported error response structure" do 8 | path = "./test/fixtures/error_response.json" 9 | sip = Poison.decode!(TestHelper.read_whole_file(path), as: Response) 10 | {:error, reason} = Response.to_rows(sip) 11 | assert length(reason) > 0 and List.first(reason)["code"] == "Neo.ClientError.Statement.InvalidSyntax" 12 | end 13 | 14 | test "loading a transaction response structure" do 15 | path = "./test/fixtures/tx_response.json" 16 | sip = Poison.decode!(TestHelper.read_whole_file(path), as: Response) 17 | {:ok, rows} = Response.to_rows(sip) 18 | 19 | assert [%{"n" => %{"name" => "My Node"}}] = rows 20 | assert %{"expires" => "Sun, 9 Aug 2015 14:33:42 +0000"} = sip["transaction"] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2017 Florin T.PATRASCU 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sub-license, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Logger.configure(level: :info) 2 | ExUnit.start() 3 | 4 | defmodule Neo4j.Sips.TestHelper do 5 | 6 | @doc """ 7 | Read an entire file into a string. 8 | Return a tuple of success and data. 9 | """ 10 | def read_whole_file(path) do 11 | case File.read(path) do 12 | {:ok, file} -> file 13 | {:error, reason} -> {:error, "Could not open #{path} #{file_error_description(reason)}" } 14 | end 15 | end 16 | 17 | @doc """ 18 | Open a file stream, and join the lines into a string. 19 | """ 20 | def stream_file_join(filename) do 21 | stream = File.stream!(filename) 22 | Enum.join stream 23 | end 24 | 25 | defp file_error_description(:enoent), do: "because the file does not exist." 26 | defp file_error_description(reason), do: "due to #{reason}." 27 | end 28 | 29 | {:ok, _pid} = Neo4j.Sips.start_link(Application.get_env(:neo4j_sips, Neo4j)) 30 | 31 | # I am using the test db for debugging and the line below will clear *everything* 32 | # Neo4j.Sips.query(Neo4j.Sips.conn, "MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n,r") 33 | # 34 | # todo: The tests should the data they create. 35 | 36 | Process.flag(:trap_exit, true) 37 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Neo4jSips.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.2.18" 5 | 6 | def project do 7 | [app: :neo4j_sips, 8 | version: @version, 9 | elixir: "~> 1.3", 10 | deps: deps(), 11 | package: package(), 12 | description: "A very simple and versatile Neo4J Elixir driver", 13 | name: "Neo4j.Sips", 14 | 15 | build_embedded: Mix.env == :prod, 16 | start_permanent: Mix.env == :prod, 17 | 18 | docs: [extras: ["README.md", "CHANGELOG.md"], 19 | source_ref: "v#{@version}", 20 | source_url: "https://github.com/florinpatrascu/neo4j_sips"]] 21 | end 22 | 23 | # Configuration for the OTP application 24 | # 25 | # Type `mix help compile.app` for more information 26 | def application do 27 | [applications: [:logger, :httpoison, :poison, :con_cache, :poolboy], 28 | mod: {Neo4j.Sips.Application, []}] 29 | end 30 | 31 | defp deps do 32 | [ 33 | {:httpoison, "~> 0.11"}, 34 | {:poison, "~> 2.0 or ~> 3.0"}, 35 | {:con_cache, "0.11.1"}, 36 | {:poolboy, "~> 1.5"}, 37 | {:mix_test_watch, "~> 0.2", only: [:dev, :test]}, 38 | {:credo, "~> 0.5", only: [:dev, :test]}, 39 | {:ex_doc, "~> 0.14.3", only: [:dev]} 40 | ] 41 | end 42 | 43 | defp package do 44 | %{licenses: ["MIT"], 45 | maintainers: ["Florin T. Patrascu"], 46 | links: %{"GitHub" => "https://github.com/florinpatrascu/neo4j_sips"}} 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | 26 | # config :neo4j_sips, Neo4j, 27 | # url: "http://localhost:7474", 28 | # pool_size: 5, 29 | # max_overflow: 2, 30 | # #timeout: :infinity, 31 | # timeout: 3000 32 | # # token_auth: "bmVvNGo6dGVzdA==" 33 | # # basic_auth: [username: "neo4j", password: "test"] 34 | 35 | 36 | if Mix.env == :test || Mix.env == :dev, do: import_config "#{Mix.env}.exs" 37 | -------------------------------------------------------------------------------- /lib/neo4j_sips/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Response do 2 | @moduledoc ~S""" 3 | Defines the structure for a raw REST response received from a Neo4j server. 4 | """ 5 | 6 | # @derive Access 7 | defstruct [:results, :transaction, :commit, :errors] 8 | 9 | def to_rows(sip) do 10 | get_results(sip, "row") 11 | end 12 | 13 | def to_graph(sip) do 14 | get_results(sip, "graph") 15 | end 16 | 17 | def to_options(sip, options) do 18 | errors = sip["errors"] 19 | if (length(errors) > 0) do 20 | {:error, errors} 21 | else 22 | # 1. IO.inspect(options |> Enum.map &({String.to_atom(&1), format_response(sip["results"], &1)})) 23 | # 2. IO.inspect(Enum.map(options, fn opt -> {String.to_atom(opt), format_response(sip["results"], opt)} end)) 24 | {:ok, options |> Enum.map(&{String.to_atom(&1), get_row_or_graph(sip, &1)})} 25 | end 26 | end 27 | 28 | defp get_results(sip, row_or_graph) do 29 | errors = sip["errors"] 30 | if (length(errors) > 0) do 31 | {:error, errors} 32 | else 33 | {:ok, sip["results"] |> Enum.map(&format_response(&1, row_or_graph)) |> List.first} 34 | end 35 | end 36 | 37 | # todo: refactor me, see (1) and (2) above 38 | defp get_row_or_graph(sip, row_or_graph) do 39 | errors = sip["errors"] 40 | 41 | if (length(errors) > 0) do 42 | {:error, errors} 43 | else 44 | sip["results"] 45 | |> Enum.map(&format_response(&1, row_or_graph)) 46 | |> List.first 47 | 48 | end 49 | end 50 | 51 | 52 | defp format_response(response, row_or_graph) do 53 | columns = response["columns"] 54 | 55 | case row_or_graph do 56 | "row" -> 57 | response["data"] 58 | |> Enum.map(fn data -> Map.get(data, row_or_graph) end) 59 | |> Enum.map(fn data -> Enum.zip(columns, data) end) 60 | |> Enum.map(fn data -> Enum.into(data, %{}) end) 61 | 62 | "graph" -> 63 | response["data"] 64 | |> Enum.map(fn data -> Map.get(data, row_or_graph) end) 65 | 66 | end 67 | end 68 | 69 | end 70 | -------------------------------------------------------------------------------- /test/neo4j_sips_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Server.Test do 2 | use ExUnit.Case, async: true 3 | 4 | # alias Neo4j.Sips.Server 5 | # @db_url Neo4j.Sips.Utils.default_config()[:url] 6 | 7 | # test "server initialization" do 8 | # case Server.init(url: @db_url, timeout: 60) do 9 | # {:ok, pid} -> {:ok, %{pid: pid}} 10 | # {:error, message} -> Mix.raise message 11 | # end 12 | # end 13 | 14 | # test "invalid server configuration" do 15 | # assert {:error, _} = Server.init [] 16 | # assert {:error, _} = Server.init [foo: "bar"] 17 | # assert {:error, _} = Server.init [url: "htt://nothin'"] 18 | # end 19 | 20 | # test "headers without authentication" do 21 | # Server.init(url: @db_url) 22 | # assert Keyword.get(Server.headers, :Authorization) == nil 23 | # end 24 | 25 | # test "headers containing the authentication token" do 26 | # token = "bmVvNGo6dGVzdA=" 27 | # Server.init(url: @db_url, token_auth: token) 28 | # assert Keyword.get(Server.headers, :Authorization) == "Basic #{token}" 29 | # end 30 | 31 | # test "headers containing a proper token for basic_auth" do 32 | # Server.init(url: @db_url, basic_auth: [username: "neo4j", password: "neo4j"]) 33 | # assert Keyword.get(Server.headers, :Authorization) == "Basic bmVvNGo6bmVvNGo=" 34 | # end 35 | 36 | # test "headers containing a proper token for URL with baisc authentication included" do 37 | # "http://" <> rest = @db_url 38 | # url = "http://neo4j:neo4j@#{rest}" 39 | # Server.init(url: url) 40 | # assert Keyword.get(Server.headers, :Authorization) == "Basic bmVvNGo6bmVvNGo=" 41 | # end 42 | 43 | # test "authentication in URL has lower precedence than basic_auth or token_auth" do 44 | # "http://" <> rest = @db_url 45 | # url = "http://neo4j123:neo4j123@#{rest}" 46 | # Server.init(url: url, basic_auth: [username: "neo4j", password: "neo4j"]) 47 | # assert Keyword.get(Server.headers, :Authorization) == "Basic bmVvNGo6bmVvNGo=" 48 | 49 | # token = "bmVvNGo6dGVzdA=" 50 | # Server.init(url: url, token_auth: token) 51 | # assert Keyword.get(Server.headers, :Authorization) == "Basic #{token}" 52 | # end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /lib/neo4j_sips/query.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Query do 2 | @moduledoc ~S""" 3 | Provides the Query DSL. 4 | """ 5 | alias Neo4j.Sips.Connection 6 | alias Neo4j.Sips.Response 7 | alias Neo4j.Sips.Utils 8 | 9 | @commit "/commit" 10 | 11 | def query(conn, statement) do 12 | {:ok, response} = query_commit(conn, statement) 13 | do_query(response, conn) 14 | end 15 | def query(conn, statement, params) when is_map(params) do 16 | {:ok, response} = query_commit(conn, statement, params) 17 | do_query(response, conn) 18 | end 19 | 20 | defp do_query(response, conn) do 21 | options = 22 | if conn.options && Map.has_key?(conn.options, :resultDataContents) do 23 | conn.options[:resultDataContents] 24 | end || nil 25 | 26 | query_response(response, options) 27 | end 28 | 29 | 30 | def query!(conn, statement), do: query(conn,statement) |> do_query! 31 | def query!(conn, statement, params) when is_map(params) do 32 | query(conn, statement, params) |> do_query! 33 | end 34 | defp do_query!(result) do 35 | case result do 36 | {:ok, response} -> response 37 | {:error, reason} -> raise Neo4j.Sips.Error, code: reason.code, message: reason.message 38 | end 39 | end 40 | 41 | defp query_response(response, options) do 42 | if options do 43 | Response.to_options(response, options) 44 | else 45 | case Response.to_rows(response) do 46 | {:error, reason} -> {:error, reason} 47 | {:ok, rows} -> {:ok, rows} 48 | end 49 | end 50 | end 51 | 52 | 53 | @doc """ 54 | This is different than Transaction's same function, since we only commit if there are no open transactions 55 | """ 56 | def commit_url(conn) do 57 | if( String.length(conn.commit_url) > 0, do: conn.commit_url, else: conn.transaction_url <> @commit) 58 | end 59 | 60 | defp query_commit(conn, statements) when is_list(statements) do 61 | Connection.send(:post, commit_url(conn), Utils.neo4j_statements(statements, conn.options)) 62 | end 63 | 64 | defp query_commit(conn, statement, params \\ %{}) do 65 | Connection.send(:post, commit_url(conn), Utils.neo4j_statements([{statement, params}], conn.options)) 66 | end 67 | 68 | end 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.2.18 4 | 5 | (via https://github.com/florinpatrascu/neo4j_sips/pull/21, thank you: https://github.com/suddenrushofsushi) Currently setting :timeout in Neo4j.Sips config sets the timeout for :poolboy.transaction but neither HTTP calls, nor the :gen_server call wrapping them have the timeout applied. 6 | 7 | This version adds: 8 | 9 | - HTTP connection and receive timeouts to HTTPoison calls 10 | - Timeout to the gen_server call which wraps the HTTP calls above. 11 | 12 | ## v0.2.17 (2017-01-09) 13 | - relax the `:poison` library version requirements; in relation to Phoenix 1.2.x 14 | 15 | ## v0.2.16 16 | - Travis test builds with Elixir 1.3/1.4 17 | - tame some of the warnings and mild code refactoring 18 | - updated README 19 | 20 | ## v0.2.15 (2017-01-05) 21 | - add Travis CI support 22 | - temporarily suspending the `Neo4j.Sips.Server.Test` suite; requires more thinking, after the url refactoring 23 | - Elixir 1.3 24 | - return an error if the driver authentication fails 25 | 26 | ## v0.2.14 (2016-12-26) 27 | - Neo4j 3.1 is now returning the address of the `Bolt` protocol address, during the initial handshake, with the remote http API. At this time, I am expecting a set of keys I convert later to atoms, for efficiency. However, the story with the atoms in Erlang is well-known: `Atoms are not garbage-collected. Once an atom is created, it will never be removed.` This is why I also had to make sure I am allocating all the keys I need **before** this initial handshake. And the `:bolt` atom was not one of them, as I didn't expect to have it, breaking this way the Poison validations. Fixed now. 28 | 29 | ## v0.2.12 (2016-11-07) 30 | - minor changes: dependencies update 31 | 32 | ## v0.2.11 (2016-09-29) 33 | - fix access error bug. PR provided by @tpunt. Thank you. 34 | 35 | ## v0.2.10 (2016-07-24) 36 | - Enhancements 37 | * ready for Elixir 1.3 38 | * Neo4j.Sips is paving the path towards an easier integration with third party frameworks i.e. where you may want to use Neo4j.Sips as an Ecto-like Repo, Adapters, etc. 39 | * you can enable the logger now and see the requests we send to the Neo4j server. Please see `config/test.exs`, for an example of logger configuration. For now the logged info is very simple simple, yet useful for debugging 40 | * added more tests 41 | * code cleanup and various code optimizations 42 | - Breaking changes 43 | * you must start the `Neo4j.Sips` server process. This is easily done via: `Neo4j.Sips.start_link/1`. For example: `Neo4j.Sips.start_link(url: "http://localhost:7474")` 44 | - Bug fixes 45 | * the driver configuration is properly reloaded 46 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"bunt": {:hex, :bunt, "0.1.6", "5d95a6882f73f3b9969fdfd1953798046664e6f77ec4e486e6fafc7caad97c6f", [:mix], []}, 2 | "certifi": {:hex, :certifi, "0.7.0", "861a57f3808f7eb0c2d1802afeaae0fa5de813b0df0979153cbafcd853ababaf", [:rebar3], []}, 3 | "con_cache": {:hex, :con_cache, "0.11.1", "acbe5a1d10c47faba30d9629c9121e1ef9bca0aa5eddbb86f4e777bd9f9f9b6f", [:mix], [{:exactor, "~> 2.2.0", [hex: :exactor, optional: false]}]}, 4 | "credo": {:hex, :credo, "0.5.3", "0c405b36e7651245a8ed63c09e2d52c2e2b89b6d02b1570c4d611e0fcbecf4a2", [:mix], [{:bunt, "~> 0.1.6", [hex: :bunt, optional: false]}]}, 5 | "earmark": {:hex, :earmark, "1.0.3", "89bdbaf2aca8bbb5c97d8b3b55c5dd0cff517ecc78d417e87f1d0982e514557b", [:mix], []}, 6 | "ex_doc": {:hex, :ex_doc, "0.14.5", "c0433c8117e948404d93ca69411dd575ec6be39b47802e81ca8d91017a0cf83c", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, 7 | "exactor": {:hex, :exactor, "2.2.3", "a6972f43bb6160afeb73e1d8ab45ba604cd0ac8b5244c557093f6e92ce582786", [:mix], []}, 8 | "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []}, 9 | "hackney": {:hex, :hackney, "1.6.5", "8c025ee397ac94a184b0743c73b33b96465e85f90a02e210e86df6cbafaa5065", [:rebar3], [{:certifi, "0.7.0", [hex: :certifi, optional: false]}, {:idna, "1.2.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, optional: false]}]}, 10 | "httpoison": {:hex, :httpoison, "0.11.0", "b9240a9c44fc46fcd8618d17898859ba09a3c1b47210b74316c0ffef10735e76", [:mix], [{:hackney, "~> 1.6.3", [hex: :hackney, optional: false]}]}, 11 | "idna": {:hex, :idna, "1.2.0", "ac62ee99da068f43c50dc69acf700e03a62a348360126260e87f2b54eced86b2", [:rebar3], []}, 12 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, 13 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, 14 | "mix_test_watch": {:hex, :mix_test_watch, "0.2.6", "9fcc2b1b89d1594c4a8300959c19d50da2f0ff13642c8f681692a6e507f92cab", [:mix], [{:fs, "~> 0.9.1", [hex: :fs, optional: false]}]}, 15 | "poison": {:hex, :poison, "3.0.0", "625ebd64d33ae2e65201c2c14d6c85c27cc8b68f2d0dd37828fde9c6920dd131", [:mix], []}, 16 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 17 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], []}} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Elixir template 2 | /_build 3 | /deps 4 | /doc 5 | /bench/snapshots 6 | /bench/graphs 7 | /cover 8 | 9 | erl_crash.dump 10 | *.ez 11 | 12 | ### Vim template 13 | [._]*.s[a-w][a-z] 14 | [._]s[a-w][a-z] 15 | *.un~ 16 | Session.vim 17 | .netrwhist 18 | *~ 19 | 20 | ### SublimeText template 21 | # cache files for sublime text 22 | *.tmlanguage.cache 23 | *.tmPreferences.cache 24 | *.stTheme.cache 25 | 26 | # workspace files are user-specific 27 | *.sublime-workspace 28 | *.sublime-project 29 | 30 | # project files should be checked into the repository, unless a significant 31 | # proportion of contributors will probably not be using SublimeText 32 | # *.sublime-project 33 | 34 | # sftp configuration file 35 | sftp-config.json 36 | 37 | ### OSX template 38 | .DS_Store 39 | .AppleDouble 40 | .LSOverride 41 | 42 | # Icon must end with two \r 43 | Icon 44 | 45 | # Thumbnails 46 | ._* 47 | 48 | # Files that might appear in the root of a volume 49 | .DocumentRevisions-V100 50 | .fseventsd 51 | .Spotlight-V100 52 | .TemporaryItems 53 | .Trashes 54 | .VolumeIcon.icns 55 | 56 | # Directories potentially created on remote AFP share 57 | .AppleDB 58 | .AppleDesktop 59 | Network Trash Folder 60 | Temporary Items 61 | .apdisk 62 | 63 | ### Tags template 64 | # Ignore tags created by etags, ctags, gtags (GNU global) and cscope 65 | TAGS 66 | !TAGS/ 67 | tags 68 | !tags/ 69 | gtags.files 70 | GTAGS 71 | GRTAGS 72 | GPATH 73 | cscope.files 74 | cscope.out 75 | cscope.in.out 76 | cscope.po.out 77 | /tmp 78 | ### JetBrains template 79 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 80 | 81 | *.iml 82 | 83 | ## Directory-based project format: 84 | .idea/ 85 | # if you remove the above rule, at least ignore the following: 86 | 87 | # User-specific stuff: 88 | # .idea/workspace.xml 89 | # .idea/tasks.xml 90 | # .idea/dictionaries 91 | 92 | # Sensitive or high-churn files: 93 | # .idea/dataSources.ids 94 | # .idea/dataSources.xml 95 | # .idea/sqlDataSources.xml 96 | # .idea/dynamic.xml 97 | # .idea/uiDesigner.xml 98 | 99 | # Gradle: 100 | # .idea/gradle.xml 101 | # .idea/libraries 102 | 103 | # Mongo Explorer plugin: 104 | # .idea/mongoSettings.xml 105 | 106 | ## File-based project format: 107 | *.ipr 108 | *.iws 109 | 110 | ## Plugin-specific files: 111 | 112 | # IntelliJ 113 | /out/ 114 | 115 | # mpeltonen/sbt-idea plugin 116 | .idea_modules/ 117 | 118 | # JIRA plugin 119 | atlassian-ide-plugin.xml 120 | 121 | # Crashlytics plugin (for Android Studio and IntelliJ) 122 | com_crashlytics_export_strings.xml 123 | crashlytics.properties 124 | crashlytics-build.properties 125 | 126 | .tags 127 | .tags_sorted_by_file 128 | 129 | ### Erlang template 130 | .eunit 131 | *.o 132 | *.plt 133 | ebin 134 | rel/example_project 135 | .concrete/DEV_MODE 136 | -------------------------------------------------------------------------------- /lib/neo4j_sips/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Utils do 2 | @moduledoc "Common utilities" 3 | 4 | @doc """ 5 | Generate a random string. 6 | """ 7 | def random_id, do: :rand.uniform |> Float.to_string |> String.slice(2..10) 8 | 9 | @doc """ 10 | Fills in the given `opts` with default options. 11 | """ 12 | @spec default_config(Keyword.t) :: Keyword.t 13 | def default_config(config \\ Application.get_env(:neo4j_sips, Neo4j)) do 14 | config 15 | |> Keyword.put_new(:url, System.get_env("NEO4J_URL") || "http://localhost:7474") 16 | |> Keyword.put_new(:pool_size, 5) 17 | |> Keyword.put_new(:max_overflow, 2) 18 | |> Keyword.put_new(:timeout, 5000) 19 | |> Enum.reject(fn {_k, v} -> is_nil(v) end) 20 | end 21 | 22 | @doc """ 23 | Given a list of queries i.e. `[{"cypher statement ..."}, %{parameters...}]`, this 24 | method will return a JSON that may look like this: 25 | 26 | ```` 27 | { 28 | "statements" : [ { 29 | "statement" : "CREATE (n {props}) RETURN n", 30 | "parameters" : { 31 | "props" : { 32 | "name" : "My Node" 33 | } 34 | } 35 | } ] 36 | } 37 | ```` 38 | 39 | """ 40 | def neo4j_statements(queries, options \\ nil) when is_list(queries) do 41 | make_neo4j_statements(queries, [], options) 42 | end 43 | 44 | @doc """ 45 | use a collection for finding and extracting elements with a given name 46 | """ 47 | def get_element(c, name) do 48 | List.first(Enum.map(c, &(Map.get(&1, name)))) 49 | end 50 | 51 | # private stuff 52 | 53 | defp make_neo4j_statements([], acc, _options) when acc == [nil], do: "" 54 | 55 | defp make_neo4j_statements([], acc, _options) do 56 | to_json(%{statements: Enum.reverse(acc)}) 57 | end 58 | 59 | # some of the methods here are a customized variant from a similar project: 60 | # - https://github.com/raw1z/ex_neo4j 61 | 62 | defp make_neo4j_statements([query|tail], acc, options) when is_binary(query) do 63 | statement = neo4j_statement(query, %{}, options) 64 | make_neo4j_statements(tail, [statement|acc], options) 65 | end 66 | 67 | defp make_neo4j_statements([{query, params}|tail], acc, options) do 68 | statement = neo4j_statement(query, params, options) 69 | make_neo4j_statements(tail, [statement|acc], options) 70 | end 71 | 72 | defp neo4j_statement(query, params, options) do 73 | q = String.strip(query) 74 | if String.length(q) > 0 do 75 | %{statement: q} 76 | |> merge_params(params) 77 | |> merge_options(options) 78 | end 79 | end 80 | defp merge_params(statement, params) when map_size(params)> 0 do 81 | Map.merge(statement, %{parameters: params}) 82 | end 83 | defp merge_params(statement, _), do: statement 84 | defp merge_options(statement, opts) when is_nil(opts), do: statement 85 | defp merge_options(statement, opts), do: Map.merge(statement, opts) 86 | 87 | defp to_json(value, options \\ []) do 88 | IO.iodata_to_binary(Poison.encode!(value, options)) 89 | end 90 | 91 | end 92 | -------------------------------------------------------------------------------- /lib/neo4j_sips/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Connection do 2 | @moduledoc """ 3 | The Connection module. 4 | 5 | This module defines a `Neo4j.Sips.Connection` structure containing important 6 | server details. For efficiency, and because we need an initial dialog with the 7 | server for finding the REST API endpoints, the server details are cached and reused. 8 | """ 9 | 10 | defstruct [:server, :transaction_url, :server_version, :commit_url, :options] 11 | 12 | use GenServer 13 | 14 | import Kernel, except: [send: 2] 15 | 16 | alias Neo4j.Sips.Http, as: HTTP 17 | 18 | require Logger 19 | 20 | @post "POST - " 21 | @delete "DELETE - " 22 | @get "GET - " 23 | 24 | @doc """ 25 | Starts the connection process. Please check the config files for the connection 26 | options 27 | """ 28 | def start_link(server_endpoint) do 29 | GenServer.start_link(__MODULE__, server_endpoint, []) 30 | end 31 | 32 | @doc false 33 | def handle_call(data, _from, state) do 34 | result = case data do 35 | {:post, url, body} -> 36 | log(@post <> "#{url} - #{body}") 37 | decode_as_response(HTTP.post!(url, body, [], [timeout: Neo4j.Sips.config(:timeout), recv_timeout: Neo4j.Sips.config(:timeout)]).body) 38 | 39 | {:delete, url, _} -> 40 | log(@delete <> "#{url}") 41 | decode_as_response(HTTP.delete!(url).body) 42 | 43 | {:get, url, _} -> 44 | log(@get <> "#{url}") 45 | case HTTP.get(url) do 46 | {:ok, %HTTPoison.Response{body: body, headers: _headers, status_code: 200}} -> Poison.decode!(body) 47 | {:error, %HTTPoison.Error{id: _id, reason: reason}} -> {:error, reason} 48 | {:ok, _} -> [] 49 | end 50 | end 51 | {:reply, result, state} 52 | end 53 | 54 | @doc false 55 | def send(method, connection, body \\ "") do 56 | pool_server(method, connection, body) 57 | end 58 | 59 | defp pool_server(method, connection, body) do 60 | :poolboy.transaction( 61 | Neo4j.Sips.pool_name, &(:gen_server.call(&1, {method, connection, body}, Neo4j.Sips.config(:timeout))), 62 | Neo4j.Sips.config(:timeout) 63 | ) 64 | end 65 | 66 | @doc false 67 | def terminate(_reason, _state) do 68 | :ok 69 | end 70 | 71 | 72 | @doc """ 73 | returns a Connection containing the server details. You can 74 | specify some optional parameters i.e. graph_result. 75 | 76 | graph_result is nil, by default, and can have the following values: 77 | 78 | graph_result: ["row"] 79 | graph_result: ["graph"] 80 | or both: 81 | 82 | graph_result: [ "row", "graph" ] 83 | 84 | """ 85 | def conn(options) do 86 | Map.put(ConCache.get(:neo4j_sips_cache, :conn), :options, options) 87 | end 88 | 89 | @doc """ 90 | returns a Neo4j.Sips.Connection 91 | """ 92 | def conn() do 93 | ConCache.get(:neo4j_sips_cache, :conn) 94 | end 95 | 96 | @doc """ 97 | returns the version of the Neo4j server you're connected to 98 | """ 99 | def server_version() do 100 | conn().server_version 101 | end 102 | 103 | @doc """ 104 | Logs the given message in debug mode. 105 | 106 | The logger call will be removed at compile time if `compile_time_purge_level` 107 | is set to higher than :debug 108 | """ 109 | def log(message) when is_binary(message) do 110 | Logger.debug(message) 111 | end 112 | 113 | defp decode_as_response(resp) do 114 | case Poison.decode(resp, as: Neo4j.Sips.Response) do 115 | {:ok, sip} -> {:ok, sip} 116 | error -> {:error, error} 117 | end 118 | end 119 | 120 | end 121 | -------------------------------------------------------------------------------- /test/neo4j_sips_transaction_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Transaction.Test do 2 | use ExUnit.Case, async: true 3 | 4 | alias Neo4j.Sips, as: Neo4j 5 | 6 | test "there is a valid commit link at the beginning of a transaction" do 7 | conn = Neo4j.conn 8 | assert String.length(conn.commit_url) == 0, 9 | "this is not a new connection. Its: commit_url, must be empty" 10 | 11 | new_conn = Neo4j.tx_begin(conn) 12 | assert String.length(new_conn.commit_url) != 0, "invalid commit_url" 13 | 14 | pattern = Regex.compile!("#{new_conn.transaction_url}/\\d+") 15 | assert Regex.match?(pattern, new_conn.commit_url), 16 | "invalid commit url: #{new_conn.commit_url}, received from server" 17 | 18 | end 19 | 20 | ### 21 | ### NOTE: 22 | ### 23 | ### The labels used in these examples MUST be unique across all tests! 24 | ### These tests depend on being able to expect that a node either exists 25 | ### or does not, and asynchronous testing with the same names will cause 26 | ### random cases where the underlying state changes. 27 | ### 28 | 29 | test "rollback statements in an open transaction" do 30 | try do 31 | # In case there's already a copy in our DB, count them... 32 | {:ok, result} = Neo4j.query(Neo4j.conn, "MATCH (x:XactRollback) RETURN count(x)") 33 | original_count = hd(result)["count(x)"] 34 | 35 | conn = Neo4j.tx_begin(Neo4j.conn) 36 | books = Neo4j.query(conn, "CREATE (x:XactRollback {title:\"The Game Of Trolls\"}) return x") 37 | assert {:ok, rows} = books 38 | assert List.first(rows)["x"]["title"] == "The Game Of Trolls" 39 | assert String.length(conn.commit_url) > 0, 40 | "this is not an existing connection. Its commit_url, must not be empty" 41 | 42 | # Original connection (outside the transaction) should not see this node. 43 | {:ok, result} = Neo4j.query(Neo4j.conn, "MATCH (x:XactRollback) RETURN count(x)") 44 | assert hd(result)["count(x)"] == original_count, 45 | "Main connection should not be able to see transactional change" 46 | 47 | assert {:ok, conn} = Neo4j.tx_rollback(conn) 48 | assert String.length(conn.commit_url) == 0 49 | 50 | # Original connection should still not see this node committed. 51 | {:ok, result} = Neo4j.query(Neo4j.conn, "MATCH (x:XactRollback) RETURN count(x)") 52 | assert hd(result)["count(x)"] == original_count 53 | after 54 | # Delete all XactRollback nodes in case the tx_rollback() didn't work! 55 | Neo4j.query(Neo4j.conn, "MATCH (x:XactRollback) DETACH DELETE x") 56 | end 57 | end 58 | 59 | test "commit statements in an open transaction" do 60 | try do 61 | conn = Neo4j.tx_begin(Neo4j.conn) 62 | books = Neo4j.query(conn, "CREATE (x:XactCommit {foo: 'bar'}) return x") 63 | assert {:ok, rows} = books 64 | assert List.first(rows)["x"]["foo"] == "bar" 65 | assert String.length(conn.commit_url) > 0, 66 | "this is not an existing connection. Its commit_url, must not be empty" 67 | 68 | # Main connection should not see this new node. 69 | {:ok, results} = Neo4j.query(Neo4j.conn, "MATCH (x:XactCommit) RETURN x") 70 | assert is_list(results) 71 | assert Enum.count(results) == 0, 72 | "Main connection should not be able to see transactional change" 73 | 74 | # Now, commit... 75 | assert {:ok, _} = Neo4j.tx_commit(conn) 76 | 77 | # And we should see it now with the main connection. 78 | {:ok, results} = Neo4j.query(Neo4j.conn, "MATCH (x:XactCommit) RETURN x") 79 | assert is_list(results) 80 | assert Enum.count(results) == 1 81 | after 82 | # Delete any XactCommit nodes that were succesfully committed! 83 | Neo4j.query(Neo4j.conn, "MATCH (x:XactCommit) DETACH DELETE x") 84 | end 85 | end 86 | 87 | end 88 | -------------------------------------------------------------------------------- /lib/neo4j_sips/transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Transaction do 2 | @moduledoc """ 3 | This module is the main implementation for running Cypher commands using 4 | transactions. It is using the transactional HTTP endpoint for Cypher and has 5 | the ability to let you use the same transaction across multiple HTTP requests. 6 | 7 | Every Cypher operation is executed in a transaction. 8 | 9 | Example: 10 | 11 | test "execute statements in an open transaction" do 12 | conn = Neo4j.tx_begin(Neo4j.conn) 13 | books = Neo4j.query(conn, "CREATE (b:Book {title:\"The Game Of Trolls\"}) return b") 14 | assert {:ok, rows} = books 15 | assert List.first(rows)["b"]["title"] == "The Game Of Trolls" 16 | assert {:ok, conn} = Neo4j.tx_rollback(conn) 17 | assert String.length(conn.commit_url) == 0 18 | end 19 | 20 | To do: 21 | - let the user override the default TX timeout; the default timeout is 60 seconds. 22 | - improve the errors handling 23 | - Reset transaction timeout of an open transaction 24 | - add support for returning results in graph format 25 | """ 26 | 27 | alias Neo4j.Sips.Connection 28 | alias Neo4j.Sips.Utils 29 | 30 | require Logger 31 | 32 | # URL suffix used for composing Neo4j transactional endpoints 33 | @commit "/commit" 34 | 35 | @doc """ 36 | begin a new transaction. If there is no need to keep a 37 | transaction open across multiple HTTP requests, you can begin a transaction, 38 | execute statements, and commit with just a single HTTP request. 39 | """ 40 | @spec tx_begin(Neo4j.Sips.Connection) :: Neo4j.Sips.Connection 41 | def tx_begin(conn) do 42 | case Connection.send(:post, conn.transaction_url) do 43 | {:ok, response} -> 44 | Map.put(conn, :commit_url, String.replace(response["commit"], ~r{/commit}, "")) 45 | {:error, reason} -> {:error, List.first(reason)} 46 | end 47 | end 48 | 49 | @spec tx_rollback(Neo4j.Sips.Connection) :: Neo4j.Sips.Connection 50 | def tx_rollback(conn) do 51 | case Connection.send(:delete, conn.commit_url) do 52 | {:ok, _response} -> {:ok, Map.put(conn, :commit_url, "")} 53 | {:error, reason} -> 54 | case reason do 55 | {:error, :invalid} -> {:error, "invalid url: #{conn.commit_url}"} 56 | _ -> {:error, List.first(reason)} 57 | end 58 | end 59 | end 60 | 61 | @doc """ 62 | commit an open transaction 63 | """ 64 | @spec tx_commit(Neo4j.Sips.Connection) :: Neo4j.Sips.Response 65 | def tx_commit(conn) do 66 | tx_commit(conn, "") 67 | end 68 | 69 | @doc """ 70 | send a list of cypher commands to the server. Each command will have this form: 71 | {query, params}, where the query is a valid Cypher command and the params are a 72 | map of optional parameters. 73 | """ 74 | @spec tx_commit(Neo4j.Sips.Connection, String.t) :: Neo4j.Sips.Response 75 | def tx_commit(conn, statements) when is_list(statements) do 76 | Connection.send(:post, commit_url(conn), Utils.neo4j_statements(statements, conn.options)) 77 | end 78 | 79 | @doc """ 80 | send a single cypher command to the server, and an optional map of parameters 81 | """ 82 | @spec tx_commit(Neo4j.Sips.Connection, String.t, Map.t) :: Neo4j.Sips.Response 83 | def tx_commit(conn, statement, params \\ %{}) do 84 | Connection.send(:post, commit_url(conn), Utils.neo4j_statements([{statement, params}], conn.options)) 85 | end 86 | 87 | @doc """ 88 | same as #tx_commit but maybe raise an error 89 | """ 90 | @spec tx_commit!(Neo4j.Sips.Connection, String.t, Map.t) :: Neo4j.Sips.Response 91 | def tx_commit!(conn, query, params \\ %{}) do 92 | case tx_commit(conn, query, params) do 93 | {:error, reason} -> raise Neo4j.Sips.Error, code: reason["code"], 94 | message: reason["message"] 95 | {:ok, response} -> response 96 | end 97 | end 98 | 99 | @doc """ 100 | This is different than Query's same function, since we always commit, on tx_commit/... 101 | """ 102 | def commit_url(conn) do 103 | if( String.length(conn.commit_url) > 0, do: conn.commit_url, else: conn.transaction_url) <> @commit 104 | end 105 | 106 | end 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Neo4j.Sips 2 | 3 | A simple Elixir driver using the [Neo4j](http://neo4j.com/developer/get-started/) graph database REST API. Compatible with the following Neo4j servers: `2.x/3.0.x/3.1.x` 4 | 5 | ![Build Status](https://travis-ci.org/florinpatrascu/neo4j_sips.svg?branch=master) 6 | [![Deps Status](https://beta.hexfaktor.org/badge/all/github/florinpatrascu/neo4j_sips.svg)](https://beta.hexfaktor.org/github/florinpatrascu/neo4j_sips) 7 | [![Hex.pm](https://img.shields.io/hexpm/dt/neo4j_sips.svg?maxAge=2592000)](https://hex.pm/packages/neo4j_sips) 8 | [![Hexdocs.pm](https://img.shields.io/badge/api-hexdocs-brightgreen.svg)](https://hexdocs.pm/neo4j_sips) 9 | 10 | Documentation: [hexdocs.pm/neo4j_sips/](http://hexdocs.pm/neo4j_sips/) 11 | 12 | *You can also look at: [Bolt.Sips](https://github.com/florinpatrascu/bolt_sips) - Elixir driver using the Bolt protocol; Neo4j's newest network protocol, designed for high-performance.* 13 | 14 | ### Install 15 | 16 | [Available in Hex](https://hex.pm/packages/neo4j_sips). Edit the `mix.ex` file and add the `neo4j_sips` dependency to the `deps/1 `function: 17 | 18 | def deps do 19 | [{:neo4j_sips, "~> 0.2"}] 20 | end 21 | 22 | or from Github: 23 | 24 | def deps do 25 | [{:neo4j_sips, github: "florinpatrascu/neo4j_sips"}] 26 | end 27 | 28 | If you're using a local development copy: 29 | 30 | def deps do 31 | [{:neo4j_sips, path: "../neo4j_sips"}] 32 | end 33 | 34 | Then add the `neo4j_sips` dependency the applications list: 35 | 36 | def application do 37 | [applications: [:logger, :neo4j_sips], 38 | mod: {Neo4j.Sips.Application, []}] 39 | end 40 | 41 | 42 | Edit the `config/config.exs` and describe a Neo4j server endpoint, example: 43 | 44 | config :neo4j_sips, Neo4j, 45 | url: "http://localhost:7474", 46 | pool_size: 5, 47 | max_overflow: 2, 48 | timeout: 15_000 # milliseconds! 49 | 50 | Run `mix do deps.get, deps.compile` 51 | 52 | If your server requires basic authentication, add this to your config file: 53 | 54 | basic_auth: [username: "foo", password: "bar"] 55 | 56 | Or: 57 | 58 | token_auth: "bmVvNGo6dGVzdA==" # if using an authentication token?! 59 | 60 | You can also specify the authentication in the `url` config: 61 | 62 | url: "http://neo4j:neo4j@localhost:7474" 63 | 64 | ### Example 65 | 66 | With a minimalist setup configured as above, and a Neo4j server running, we can connect to the server and run some queries using Elixir’s interactive shell ([IEx](http://elixir-lang.org/docs/stable/iex/IEx.html)): 67 | 68 | $ cd 69 | $ iex -S mix 70 | Erlang/OTP 19 [erts-8.0.2] [source] [64-bit] ... 71 | 72 | Interactive Elixir (1.3.2) - press Ctrl+C to exit (type h() ENTER for help) 73 | iex(1)> alias Neo4j.Sips, as: Neo4j 74 | 75 | iex(2)> Neo4j.start_link(url: "http://localhost:7474") 76 | {:ok, #PID<0.204.0>} 77 | 78 | iex(3)> cypher = """ 79 | CREATE (n:Neo4jSips {title:'Elixir sipping from Neo4j', released:2015, 80 | license:'MIT', neo4j_sips_test: true}) 81 | """ 82 | 83 | iex(4)> Neo4j.query(Neo4j.conn, cypher) 84 | {:ok, []} 85 | 86 | iex(5)> n = Neo4j.query!(Neo4j.conn, "match (n:Neo4jSips {title:'Elixir sipping from Neo4j'}) where n.neo4j_sips_test return n") 87 | [%{"n" => %{"license" => "MIT", "neo4j_sips_test" => true, "released" => 2015, 88 | "title" => "Elixir sipping from Neo4j"}}] 89 | 90 | For more examples, see the test suites. 91 | 92 | ### Contributing 93 | 94 | - [Fork it](https://github.com/florinpatrascu/neo4j_sips/fork) 95 | - Create your feature branch (`git checkout -b my-new-feature`) 96 | - Test (`mix test`) 97 | - Commit your changes (`git commit -am 'Add some feature'`) 98 | - Push to the branch (`git push origin my-new-feature`) 99 | - Create new Pull Request 100 | 101 | ### Contributors 102 | 103 | As reported by Github: [contributions to master, excluding merge commits](https://github.com/florinpatrascu/neo4j_sips/graphs/contributors) 104 | 105 | ### Author 106 | Florin T.PATRASCU (@florinpatrascu, @florin on Twitter) 107 | 108 | ## License 109 | * Neo4j.Sips - MIT, check [LICENSE](LICENSE) file for more information. 110 | * Neo4j - Dual free software/commercial license, see http://neo4j.org/ 111 | -------------------------------------------------------------------------------- /lib/neo4j_sips/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips.Server do 2 | @moduledoc ~S""" 3 | Provide access to a structure containing all the HTTP endpoints 4 | exposed by a remote (or local) Neo4j server instance 5 | 6 | Example extracted from a standard Neo4j server; Community Edition 7 | 8 | { 9 | "extensions" : { }, 10 | "node" : "http://localhost:7474/db/data/node", 11 | "node_index" : "http://localhost:7474/db/data/index/node", 12 | "relationship_index" : "http://localhost:7474/db/data/index/relationship", 13 | "extensions_info" : "http://localhost:7474/db/data/ext", 14 | "relationship_types" : "http://localhost:7474/db/data/relationship/types", 15 | "batch" : "http://localhost:7474/db/data/batch", 16 | "cypher" : "http://localhost:7474/db/data/cypher", 17 | "indexes" : "http://localhost:7474/db/data/schema/index", 18 | "constraints" : "http://localhost:7474/db/data/schema/constraint", 19 | "transaction" : "http://localhost:7474/db/data/transaction", 20 | "node_labels" : "http://localhost:7474/db/data/labels", 21 | "neo4j_version" : "2.2.3" 22 | } 23 | """ 24 | 25 | defstruct [:server_url, :management_url, :data_url, :data, :timeout, :bolt] 26 | 27 | alias Neo4j.Sips.Http, as: HTTP 28 | 29 | require Logger 30 | 31 | defmodule ServerData do 32 | @moduledoc false 33 | defstruct [ 34 | :batch, 35 | :constraints, 36 | :cypher, 37 | :extensions, 38 | :extensions_info, 39 | :indexes, 40 | :neo4j_version, 41 | :node, 42 | :node_index, 43 | :node_labels, 44 | :relationship_index, 45 | :relationship_types, 46 | :transaction, 47 | :relationship 48 | ] 49 | end 50 | 51 | @headers [ 52 | "Accept": "application/json; charset=UTF-8", 53 | "Content-Type": "application/json; charset=UTF-8", 54 | "User-Agent": "Neo4j.Sips client", 55 | "X-Stream": "true" 56 | ] 57 | 58 | 59 | @doc """ 60 | collect the server REST endpoints from the remote host 61 | """ 62 | def init(opts \\ []) do 63 | {url, opts} = Keyword.pop(opts, :url, "") 64 | {timeout, _} = Keyword.pop(opts, :timeout, 5000) 65 | 66 | ConCache.put(:neo4j_sips_cache, :http_headers, @headers) 67 | 68 | {check_status, uri} = check_uri(url); 69 | 70 | token_auth = cond do 71 | opts[:token_auth] -> Macro.escape(opts[:token_auth]) 72 | basic_auth = opts[:basic_auth] -> 73 | username = basic_auth[:username] 74 | password = basic_auth[:password] 75 | Base.encode64("#{username}:#{password}") 76 | uri.userinfo -> Base.encode64(uri.userinfo) 77 | true -> nil 78 | end 79 | 80 | if token_auth != nil do 81 | ConCache.put(:neo4j_sips_cache, :http_headers, 82 | @headers ++ ["Authorization": "Basic #{token_auth}"]) 83 | end 84 | 85 | case check_status do 86 | :ok -> 87 | # "ping" the server, and check if we can connect 88 | case HTTP.get("#{url}/db/data/") do 89 | {:ok, %HTTPoison.Response{body: _body, headers: _headers, status_code: 400,}} -> 90 | {:error, "Cannot connect to the server at url: #{url}. Reason: Invalid Authorization"} 91 | 92 | {:error, %HTTPoison.Error{reason: reason}} -> 93 | {:error, "Cannot connect to the server at url: #{url}. Reason: #{reason}"} 94 | 95 | {:ok, response_db_data} -> 96 | response_db_root = HTTP.get!("#{url}") 97 | 98 | if response_db_root.status_code == 200 do 99 | %{data: data, management: management} = Poison.Parser.parse!(response_db_root.body, keys: :atoms!) 100 | server_data = Poison.decode!(response_db_data.body, as: ServerData, keys: :atoms) # returned by: /db/data/ 101 | %{node_labels: _node_labels, transaction: _transaction, neo4j_version: _neo4j_version} 102 | = Poison.Parser.parse!(response_db_data.body, keys: :atoms!) 103 | {:ok, %Neo4j.Sips.Server{ 104 | server_url: url, management_url: management, data_url: data, 105 | data: server_data, timeout: timeout}} 106 | else 107 | {:error, "Cannot connect to the server at url: #{url}. Reason: #{response_db_root.body}"} 108 | end 109 | end 110 | 111 | :error -> {:error, "invalid server url: #{url}"} 112 | end 113 | end 114 | 115 | def headers do 116 | ConCache.get(:neo4j_sips_cache, :http_headers) 117 | end 118 | 119 | defp check_uri(str) do 120 | uri = URI.parse(str) 121 | case uri do 122 | %URI{scheme: nil} -> {:error, uri} 123 | %URI{host: nil} -> {:error, uri} 124 | %URI{port: nil} -> {:error, uri} 125 | uri -> {:ok, uri} 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /test/neo4j_sips_query_test.exs: -------------------------------------------------------------------------------- 1 | # mix test test/neo4j_sips_query_test.exs 2 | defmodule Neo4j.Sips.Query.Test do 3 | use ExUnit.Case, async: true 4 | 5 | alias Neo4j.Sips.Utils 6 | alias Neo4j.Sips, as: Neo4j 7 | 8 | setup_all do 9 | batch_cypher = """ 10 | MATCH (n {neo4j_sips: TRUE}) OPTIONAL MATCH (n)-[r]-() DELETE n,r; 11 | 12 | CREATE (Neo4jSips:Neo4jSips {title:'Elixir sipping from Neo4j', released:2015, license:'MIT', neo4j_sips: true}) 13 | CREATE (TNOTW:Book {title:'The Name of the Wind', released:2007, genre:'fantasy', neo4j_sips: true}) 14 | CREATE (Patrick:Person {name:'Patrick Rothfuss', neo4j_sips: true}) 15 | CREATE (Kvothe:Person {name:'Kote', neo4j_sips: true}) 16 | CREATE (Denna:Person {name:'Denna', neo4j_sips: true}) 17 | CREATE (Chandrian:Deamon {name:'Chandrian', neo4j_sips: true}) 18 | 19 | CREATE 20 | (Kvothe)-[:ACTED_IN {roles:['sword fighter', 'magician', 'musician']}]->(TNOTW), 21 | (Denna)-[:ACTED_IN {roles:['many talents']}]->(TNOTW), 22 | (Chandrian)-[:ACTED_IN {roles:['killer']}]->(TNOTW), 23 | (Patrick)-[:WROTE]->(TNOTW) 24 | """ 25 | 26 | assert {:ok, _rows} = Neo4j.query(Neo4j.conn, String.split(batch_cypher, ";") # different command behavior in the same lot/batch 27 | |> Enum.map(&(String.strip(&1))) 28 | |> Enum.filter(&(String.length(&1) > 0))) 29 | 30 | :ok 31 | end 32 | 33 | test "a simple query that should work" do 34 | {:ok, row} = Neo4j.query(Neo4j.conn, "match (n:Person {neo4j_sips: true}) return n.name as Name ORDER BY Name DESC limit 5") 35 | assert List.first(row)["Name"] == "Patrick Rothfuss", 36 | "missing 'The Name of the Wind' database, or data incomplete" 37 | end 38 | 39 | test "executing a Cypher query, with parameters" do 40 | cypher = "match (n:Person {neo4j_sips: true}) where n.name = {name} return n.name as name" 41 | case Neo4j.query(Neo4j.conn, cypher, %{name: "Kote"}) do 42 | {:ok, row} -> 43 | refute length(row) == 0, "Did you initialize the 'The Name of the Wind' database?" 44 | refute length(row) > 1, "Kote?! There is only one!" 45 | assert List.first(row)["name"] == "Kote", "expecting to find Kote" 46 | {:error, reason} -> IO.puts "Error: #{reason["message"]}" 47 | end 48 | end 49 | 50 | test "executing a raw Cypher query with alias, and no parameters" do 51 | cypher = """ 52 | MATCH (p:Person {neo4j_sips: true}) 53 | RETURN p, p.name AS name, upper(p.name) as NAME, 54 | coalesce(p.nickname,"n/a") AS nickname, 55 | { name: p.name, label:head(labels(p))} AS person 56 | ORDER BY name DESC 57 | """ 58 | {:ok, r} = Neo4j.query(Neo4j.conn, cypher) 59 | 60 | assert length(r) == 3, "you're missing some characters from the 'The Name of the Wind' db" 61 | 62 | if row = List.first(r) do 63 | assert row["name"] == "Patrick Rothfuss", 64 | "missing 'The Name of the Wind' database, or data incomplete" 65 | assert row["NAME"] == "PATRICK ROTHFUSS" 66 | assert row["nickname"] == "n/a" 67 | assert is_map(row["p"]), "was expecting a map `p`" 68 | assert row["p"]["neo4j_sips"] == true 69 | assert row["person"]["label"] == "Person" 70 | else 71 | IO.puts "Did you initialize the 'The Name of the Wind' database?" 72 | end 73 | end 74 | 75 | test "if Patrick Rothfuss wrote The Name of the Wind" do 76 | cypher = "MATCH (p:Person)-[r:WROTE]->(b:Book {title: 'The Name of the Wind'}) RETURN p" 77 | rows = Neo4j.query!(Neo4j.conn, cypher) 78 | assert List.first(rows)["p"]["name"] == "Patrick Rothfuss" 79 | end 80 | 81 | test "it returns only known role names" do 82 | cypher = """ 83 | MATCH (p)-[r:ACTED_IN]->() where p.neo4j_sips RETURN r.roles as roles 84 | LIMIT 25 85 | """ 86 | rows = Neo4j.query!(Neo4j.conn, cypher) 87 | roles = ["killer", "sword fighter","magician","musician","many talents"] 88 | my_roles = Enum.map(rows, &(&1["roles"])) |> List.flatten 89 | assert my_roles -- roles == [], "found more roles in the db than expected" 90 | end 91 | 92 | 93 | test "results in graph format" do 94 | conn = Neo4j.conn( %{resultDataContents: [ "row", "graph" ]}) 95 | cypher = """ 96 | CREATE (bike:Bike {weight: 10, neo4j_sips: true}) 97 | CREATE (frontWheel:Wheel {spokes: 3, neo4j_sips: true}) 98 | CREATE (backWheel:Wheel {spokes: 32, neo4j_sips: true}) 99 | CREATE p1 = (bike)-[:HAS {position: 1} ]->(frontWheel) 100 | CREATE p2 = (bike)-[:HAS {position: 2} ]->(backWheel) 101 | RETURN bike, p1, p2 102 | """ 103 | 104 | {:ok, data} = Neo4j.query( conn, cypher) 105 | 106 | graph = data[:graph] 107 | assert length(Utils.get_element(graph, "nodes")) 108 | == 3, "invalid graph, missing nodes info" 109 | 110 | assert length(Utils.get_element(graph, "relationships")) 111 | == 2, "invalid graph, missing relationships info" 112 | end 113 | 114 | test "list all property keys ever used in the database" do 115 | my_keys = ~w{title roles license released genre neo4j_sips name} 116 | db_keys = Neo4j.property_keys 117 | 118 | refute length(db_keys) == nil, "You must use a valid Neo4j database" 119 | 120 | key_set = Enum.into(my_keys, MapSet.new) 121 | assert length(Enum.filter(db_keys, &(MapSet.member?(key_set, &1)))) > 0 122 | end 123 | 124 | end 125 | -------------------------------------------------------------------------------- /lib/neo4j_sips.ex: -------------------------------------------------------------------------------- 1 | defmodule Neo4j.Sips do 2 | @moduledoc """ 3 | Elixir driver for Neo4j 4 | 5 | A module that provides a simple Interface to communicate with a Neo4j server via http, 6 | using [Neo4j's own REST API](http://neo4j.com/docs/stable/rest-api.html). 7 | 8 | All functions take a pool to run the query on. 9 | """ 10 | use Supervisor 11 | 12 | alias Neo4j.Sips.Transaction 13 | alias Neo4j.Sips.Connection 14 | alias Neo4j.Sips.Server 15 | alias Neo4j.Sips.Query 16 | alias Neo4j.Sips.Utils 17 | 18 | @pool_name :neo4j_sips_pool 19 | 20 | @doc """ 21 | Example of valid configurations (i.e. defined in config/dev.exs): 22 | 23 | # Neo4j server not requiring authentication 24 | config :neo4j_sips, Neo4j, 25 | url: "http://localhost:7474" 26 | 27 | # Neo4j server with username and password authentication 28 | config :neo4j_sips, Neo4j, 29 | url: "http://localhost:7474", 30 | pool_size: 5, 31 | max_overflow: 2, 32 | timeout: 30, 33 | basic_auth: [username: "neo4j", password: "neo4j"] 34 | 35 | # or using a token 36 | config :neo4j_sips, Neo4j, 37 | url: "http://localhost:7474", 38 | pool_size: 10, 39 | max_overflow: 5, 40 | timeout: :infinity, 41 | token_auth: "bmVvNGo6dGVzdA==" 42 | """ 43 | def start_link(opts) do 44 | cnf = Utils.default_config(opts) 45 | 46 | ConCache.start_link([], name: :neo4j_sips_cache) 47 | ConCache.put(:neo4j_sips_cache, :config, cnf) 48 | 49 | poolboy_config = [ 50 | name: {:local, @pool_name}, 51 | worker_module: Neo4j.Sips.Connection, 52 | size: Keyword.get(cnf, :pool_size), 53 | max_overflow: Keyword.get(cnf, :max_overflow) 54 | ] 55 | 56 | case Server.init(cnf) do 57 | {:ok, server} -> 58 | ConCache.put(:neo4j_sips_cache, :conn, %Neo4j.Sips.Connection{ 59 | server: server, 60 | transaction_url: server.data.transaction, 61 | server_version: server.data.neo4j_version, 62 | commit_url: "", 63 | options: nil 64 | }) 65 | 66 | {:error, message} -> Mix.raise message 67 | end 68 | 69 | children = [:poolboy.child_spec(@pool_name, poolboy_config, cnf)] 70 | options = [strategy: :one_for_one, name: __MODULE__] 71 | 72 | Supervisor.start_link(children, options) 73 | end 74 | 75 | @doc false 76 | def child_spec(opts) do 77 | Supervisor.Spec.worker(__MODULE__, [opts]) 78 | end 79 | 80 | ## Connection 81 | 82 | @doc """ 83 | returns a Connection containing the server details. You can 84 | specify some optional parameters i.e. graph_result. 85 | 86 | graph_result is nil, by default, and can have the following values: 87 | 88 | graph_result: ["row"] 89 | graph_result: ["graph"] 90 | or both: 91 | 92 | graph_result: [ "row", "graph" ] 93 | 94 | """ 95 | defdelegate conn(options), to: Connection 96 | 97 | # until defdelegate allows optional args?! 98 | @doc """ 99 | returns a Neo4j.Sips.Connection 100 | """ 101 | defdelegate conn(), to: Connection 102 | 103 | @doc """ 104 | 105 | returns the server version 106 | """ 107 | @spec server_version() :: String.t 108 | defdelegate server_version(), to: Connection 109 | 110 | ## Query 111 | ######################## 112 | 113 | @doc """ 114 | sends the query (and its parameters) to the server and returns `{:ok, Neo4j.Sips.Response}` or 115 | `{:error, error}` otherwise 116 | """ 117 | @spec query(Neo4j.Sips.Connection, String.t) :: {:ok, Neo4j.Sips.Response} | {:error, Neo4j.Sips.Error} 118 | defdelegate query(conn, statement), to: Query 119 | 120 | @doc """ 121 | The same as query/2 but raises a Neo4j.Sips.Error if it fails. 122 | Returns the server response otherwise. 123 | """ 124 | @spec query!(Neo4j.Sips.Connection, String.t) :: Neo4j.Sips.Response | Neo4j.Sips.Error 125 | defdelegate query!(conn, statement), to: Query 126 | 127 | @doc """ 128 | send a query and an associated map of parameters. Returns the server response or an error 129 | """ 130 | @spec query(Neo4j.Sips.Connection, String.t, Map.t) :: {:ok, Neo4j.Sips.Response} | {:error, Neo4j.Sips.Error} 131 | defdelegate query(conn, statement, params), to: Query 132 | 133 | @doc """ 134 | The same as query/3 but raises a Neo4j.Sips.Error if it fails. 135 | """ 136 | @spec query!(Neo4j.Sips.Connection, String.t, Map.t) :: Neo4j.Sips.Response | Neo4j.Sips.Error 137 | defdelegate query!(conn, statement, params), to: Query 138 | 139 | 140 | ## Transaction 141 | ######################## 142 | 143 | @doc """ 144 | begin a new transaction. 145 | """ 146 | @spec tx_begin(Neo4j.Sips.Connection) :: Neo4j.Sips.Connection 147 | defdelegate tx_begin(conn), to: Transaction 148 | 149 | @doc """ 150 | execute a Cypher statement in a new or an existing transaction 151 | begin a new transaction. If there is no need to keep a 152 | transaction open across multiple HTTP requests, you can begin a transaction, 153 | execute statements, and commit with just a single HTTP request. 154 | """ 155 | @spec tx_commit(Neo4j.Sips.Connection, String.t) :: Neo4j.Sips.Response 156 | defdelegate tx_commit(conn, statements), to: Transaction 157 | 158 | @doc """ 159 | given you have an open transaction, you can use this to send a commit request 160 | """ 161 | @spec tx_commit(Neo4j.Sips.Connection) :: Neo4j.Sips.Response 162 | defdelegate tx_commit(conn), to: Transaction 163 | 164 | @doc """ 165 | execute a Cypher statement with a map containing associated parameters 166 | """ 167 | @spec tx_commit(Neo4j.Sips.Connection, String.t, Map.t) :: Neo4j.Sips.Response 168 | defdelegate tx_commit(conn, statement, params), to: Transaction 169 | 170 | @spec tx_commit!(Neo4j.Sips.Connection, String.t) :: Neo4j.Sips.Response 171 | defdelegate tx_commit!(conn, statements), to: Transaction 172 | 173 | @spec tx_commit!(Neo4j.Sips.Connection, String.t, Map.t) :: Neo4j.Sips.Response 174 | defdelegate tx_commit!(conn, statement, params), to: Transaction 175 | 176 | @doc """ 177 | given that you have an open transaction, you can send a rollback request. 178 | The server will rollback the transaction. Any further statements trying to run 179 | in this transaction will fail immediately. 180 | """ 181 | @spec tx_rollback(Neo4j.Sips.Connection) :: Neo4j.Sips.Connection 182 | defdelegate tx_rollback(conn), to: Transaction 183 | 184 | @doc """ 185 | list all property keys ever used in the database. This also includes any property 186 | keys you have used, but deleted. There is currently no way to tell which ones 187 | are in use and which ones are not, short of walking the entire set of properties 188 | in the database. 189 | """ 190 | @spec property_keys() :: List.t | [] 191 | def property_keys do 192 | property_keys_url = Neo4j.Sips.conn.server.data_url <> "propertykeys" 193 | Connection.send(:get, property_keys_url) 194 | end 195 | 196 | @doc """ 197 | returns an environment specific Neo4j.Sips configuration. 198 | """ 199 | def config, do: ConCache.get(:neo4j_sips_cache, :config) 200 | 201 | @doc false 202 | def config(key), do: Keyword.get(config(), key) 203 | 204 | @doc false 205 | def config(key, default), do: Keyword.get(config(), key, default) 206 | 207 | @doc false 208 | def pool_name, do: @pool_name 209 | 210 | @doc false 211 | def init(args) do 212 | {:ok, args} 213 | end 214 | end 215 | --------------------------------------------------------------------------------