Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bls spike apache age #18

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ source "https://rubygems.org"
# Specify your gem's dependencies in neo4j-http.gemspec
gemspec

gem "pg"
gem "rake", "~> 12.0"
gem "rspec", "~> 3.0"
gem "standard", group: [:development, :test]
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ PATH
faraday (< 2)
faraday-retry
faraday_middleware
pg
pry

GEM
Expand Down Expand Up @@ -53,6 +54,7 @@ GEM
parallel (1.22.1)
parser (3.1.1.0)
ast (~> 2.4.1)
pg (1.5.7)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
Expand Down Expand Up @@ -101,6 +103,7 @@ PLATFORMS

DEPENDENCIES
neo4j-http!
pg
rake (~> 12.0)
rspec (~> 3.0)
standard
Expand Down
75 changes: 57 additions & 18 deletions lib/neo4j/http/cypher_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "faraday"
require "faraday/retry"
require "faraday_middleware"
require "pg"

module Neo4j
module Http
Expand All @@ -24,20 +25,56 @@ def execute_cypher(cypher, parameters = {})
# for improved routing performance on read only queries
access_mode = parameters.delete(:access_mode) || @configuration.access_mode

request_body = {
statements: [
{
statement: cypher,
parameters: parameters.as_json
}
]
}

@connection = @injected_connection || connection(access_mode)
response = @connection.post(transaction_path, request_body)
results = check_errors!(cypher, response, parameters)

Neo4j::Http::Results.parse(results&.first || {})
cypher = expand_parameters_for_set(cypher, parameters)

prepared_statement = <<~SQL
LOAD 'age';
SET search_path = ag_catalog, "$user", public;

PREPARE cypher_stored_procedure(agtype) AS
SELECT *
FROM cypher('#{@configuration.database_name}', $$
#{ cypher }
$$, $1)
AS (#{return_syntax(cypher, [])});

EXECUTE cypher_stored_procedure('#{parameters.to_json}');
SQL

response = @connection.exec(prepared_statement)
Neo4j::Http::Results.parse(response || [])
rescue => e
raise_error(e.message, cypher, parameters)
end

def expand_parameters_for_set(cypher, parameters)
if cypher.match(/SET node \+\= \$attributes/)
new_set_syntax = "SET " + parameters[:attributes].map { |k,v| "node.#{k} = '#{v}'"}.join(", ")
cypher.sub(/SET node \+\= \$attributes/, new_set_syntax)

elsif cypher.match(/SET relationship \+\= \$relationship_attributes/)
new_set_syntax = "SET " + parameters[:relationship_attributes].map { |k,v| "relationship.#{k} = '#{v}'"}.join(", ")
cypher.sub(/SET relationship \+\= \$relationship_attributes/, new_set_syntax)

else
cypher
end
end

# https://age.apache.org/age-manual/master/clauses/return.html#return-all-elements
# AGE is different in that we have to separately declare each return as an agtype
def return_syntax(cypher, returns)
# Will attempt a crude parsing to extract RETURN variables
# https://rubular.com/r/0TX7F3uTTfbUvW
groups = cypher.match(/RETURN ((?:\w|,\s?)*)/i)

if groups && groups[1]
groups[1].split(",").map { |r| "\"#{r.delete(" ")}\" agtype"}.join(", ")
else
"v agtype"
end
end

def connection(access_mode)
Expand Down Expand Up @@ -76,13 +113,15 @@ def find_error_class(code)
end

def build_connection(access_mode)
PG::Connection.new(@configuration.uri)

# https://neo4j.com/docs/http-api/current/actions/transaction-configuration/
headers = build_http_headers.merge({"access-mode" => access_mode})
Faraday.new(url: @configuration.uri, headers: headers, request: build_request_options) do |f|
f.request :json # encode req bodies as JSON
f.request :retry # retry transient failures
f.response :json # decode response bodies as JSON
end
# headers = build_http_headers.merge({"access-mode" => access_mode})
# Faraday.new(url: @configuration.uri, headers: headers, request: build_request_options) do |f|
# f.request :json # encode req bodies as JSON
# f.request :retry # retry transient failures
# f.response :json # decode response bodies as JSON
# end
end

def build_request_options
Expand Down
3 changes: 1 addition & 2 deletions lib/neo4j/http/node_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ def upsert_node(node)

cypher = <<-CYPHER
MERGE (node:#{node.label} {#{node.key_name}: $key_value})
ON CREATE SET node += $attributes
ON MATCH SET node += $attributes
SET node += $attributes
return node
CYPHER

Expand Down
70 changes: 46 additions & 24 deletions lib/neo4j/http/relationship_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,48 @@ def initialize(cypher_client)
end

def upsert_relationship(relationship:, from:, to:, create_nodes: false)
match_or_merge = create_nodes ? "MERGE" : "MATCH"
if create_nodes
NodeClient.new(@cypher_client).upsert_node(from)
NodeClient.new(@cypher_client).upsert_node(to)
end

from_selector = build_match_selector(:from, from)
to_selector = build_match_selector(:to, to)
relationship_selector = build_match_selector(:relationship, relationship)

on_match = ""
relationship_selector = build_relationship_match_selector(relationship)

# cypher = +<<-CYPHER
# MATCH (#{from_selector})-[#{relationship_selector}]-(#{to_selector})
# RETURN from, to, relationship
# CYPHER

# results = @cypher_client.execute_cypher(
# cypher,
# from: from,
# to: to,
# relationship: relationship,
# relationship_attributes: relationship.attributes
# )

# This is necessary because AGE ignores MERGE + SET on relationship attributes
# Because of this we cannot both MATCH on properties then SET different properties
# The relationship has to be deleted, then subsequently recreated
if relationship.attributes.present?
on_match = <<-CYPHER
ON CREATE SET relationship += $relationship_attributes
ON MATCH SET relationship += $relationship_attributes
delete_relationship(relationship:, from:, to:)
attributes = relationship.attributes.reduce([]) {|sum, (k,v)| sum << "#{k}: '#{v}'"}.join(", ")
cypher = <<-CYPHER
MATCH (#{from_selector}), (#{to_selector})
MERGE (from)-[relationship:#{relationship.label} { #{attributes} }]-(to)
RETURN from, to, relationship
CYPHER
end

cypher = +<<-CYPHER
#{match_or_merge} (#{from_selector})
#{match_or_merge} (#{to_selector})
MERGE (from) - [#{relationship_selector}] - (to)
#{on_match}
RETURN from, to, relationship
CYPHER

results = @cypher_client.execute_cypher(
cypher,
from: from,
to: to,
relationship: relationship,
relationship_attributes: relationship.attributes
)
results = @cypher_client.execute_cypher(
cypher,
from: from,
to: to,
relationship: relationship,
relationship_attributes: relationship.attributes
)
end
results&.first
end

Expand Down Expand Up @@ -72,10 +86,18 @@ def build_match_selector(name, data)
selector
end

def build_relationship_match_selector(data)
if data.key_value.present?
"relationship:#{data.label} { #{data.key_name}: '#{data.key_value}' }"
else
"relationship:#{data.label} { uuid: '#{data.uuid}' }"
end
end

def delete_relationship(relationship:, from:, to:)
from_selector = build_match_selector(:from, from)
to_selector = build_match_selector(:to, to)
relationship_selector = build_match_selector(:relationship, relationship)
relationship_selector = build_relationship_match_selector(relationship)

cypher = <<-CYPHER
MATCH (#{from_selector}) - [#{relationship_selector}] - (#{to_selector})
Expand Down
45 changes: 29 additions & 16 deletions lib/neo4j/http/results.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,39 @@
module Neo4j
module Http
class Results
# Example result set:
# [{"columns"=>["n"],
# "data"=>
# [{"row"=>[{"name"=>"Foo", "uuid"=>"8c7dcfda-d848-4937-a91a-2e6debad2dd6"}],
# "meta"=>[{"id"=>242, "type"=>"node", "deleted"=>false}]}]}]
#
# [{"n"=>"{\"id\": 844424930131972, \"label\": \"User\", \"properties\": {\"name\": \"ben\"}}::vertex"}]
def self.parse(results)
columns = results["columns"]
data = results["data"]
x = results.map do |result|

data.map do |result|
row = result["row"] || []
meta = result["meta"] || []
compacted_data = row.each_with_index.map do |attributes, index|
row_meta = meta[index] || {}
attributes["_neo4j_meta_data"] = row_meta if attributes.is_a?(Hash)
attributes
response = result.dup
result.each_pair do |key, value|
value.slice!("::vertex")
value.slice!("::edge")
response[key] = JSON.parse(value)
end
response
end

hoist_properties(x)
end

columns.zip(compacted_data).to_h.with_indifferent_access
def self.hoist_properties(results)
if results.is_a?(Array)
# Recuse on arrays
return results.map { |a| self.hoist_properties(a) }
elsif results.is_a?(Hash)
# Recurse on hashes
# Hoist ~properties key
new_hash = {}
results = results.merge(results["properties"]) if results.key?("properties")

results.each_pair do |k,v|
new_hash[k] = self.hoist_properties(v)
end
return new_hash
else
# Primative value
return results
end
end
end
Expand Down
1 change: 1 addition & 0 deletions neo4j-http.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ Gem::Specification.new do |spec|
spec.add_runtime_dependency "faraday_middleware"
spec.add_runtime_dependency "faraday-retry"
spec.add_runtime_dependency "pry"
spec.add_runtime_dependency "pg"
end
6 changes: 3 additions & 3 deletions spec/neo4j/http/cypher_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
RSpec.describe Neo4j::Http::CypherClient, type: :uses_neo4j do
subject(:client) { described_class.default }

describe "connection" do
xdescribe "connection" do
it "uses the request timeout option when provided" do
config = Neo4j::Http::Configuration.new
config.request_timeout_in_seconds = 42
Expand Down Expand Up @@ -45,7 +45,7 @@

results = client.execute_cypher("MATCH (node:Test {uuid: 'Uuid1'}) DETACH DELETE node return node")
expect(results.length).to eq(1)
expect(results.first["node"].keys).to eq(["_neo4j_meta_data"])
# expect(results.first["node"].keys).to eq(["_neo4j_meta_data"])

results = client.execute_cypher("MATCH (node:Test {uuid: 'Uuid1'}) return node")
expect(results.length).to eq(0)
Expand All @@ -61,7 +61,7 @@
end
let(:client) { described_class.new(Neo4j::Http.config, injected_connection) }

it "raises a ReadOnlyError when access control is set to read" do
xit "raises a ReadOnlyError when access control is set to read" do
stubs.post("/db/data/transaction/commit") do
[
200,
Expand Down
8 changes: 1 addition & 7 deletions spec/neo4j/http/node_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@
node = client.upsert_node(node_in)
expect(node["uuid"]).to eq(uuid)
expect(node["name"]).to eq("Foo")
expect(node["_neo4j_meta_data"]).not_to be_nil
expect(node["_neo4j_meta_data"]["id"]).not_to be_nil

results = cypher_client.execute_cypher("MATCH (node:Test {uuid: $uuid}) RETURN node", uuid: uuid)
results = cypher_client.execute_cypher("MATCH (node:Test {uuid: $uuid}) RETURN node", { uuid: uuid })
expect(results.length).to eq(1)
node = results&.first&.fetch("node", nil)
expect(node).not_to be_nil
Expand All @@ -29,16 +27,12 @@
node1 = client.upsert_node(node_in)
expect(node1["uuid"]).to eq(uuid)
expect(node1["name"]).to eq("Foo")
expect(node1["_neo4j_meta_data"]).not_to be_nil
expect(node1["_neo4j_meta_data"]["id"]).not_to be_nil

node_in = Neo4j::Http::Node.new(label: "Test", uuid: uuid, name: "Bar")
node2 = client.upsert_node(node_in)
expect(node2["uuid"]).to eq(uuid)
expect(node2["name"]).to eq("Bar")
expect(node2["_db_id"]).to eq(node1["_db_id"])
expect(node2["_neo4j_meta_data"]).not_to be_nil
expect(node2["_neo4j_meta_data"]["id"]).to eq(node1["_neo4j_meta_data"]["id"])

results = cypher_client.execute_cypher("MATCH (node:Test {uuid: $uuid}) RETURN node", uuid: uuid)
expect(results.length).to eq(1)
Expand Down
Loading