Skip to content

Commit

Permalink
Introduces a batch client
Browse files Browse the repository at this point in the history
This client inherits from the default client but overrides the actions
associated with node and relationship manipulation

To run commands in batch you can now instantiate a transaction like:

Neo4j::Http::Client.in_batch do |tx|
  [
    tx.upsert_node(node),
    tx.upsert_node(node2),
    tx.upsert_relationship(relationship: relationship, from: from, to:
    to)
  ]
end

The array of statements will be passed into a batch client that will
prepare the statements and the parameters and issue a single
request to the Neo4j HTTP API. Note that the size of the batch is
determined by the caller's array length.
  • Loading branch information
bsimpson committed Sep 16, 2022
1 parent cb0ea12 commit 324813d
Show file tree
Hide file tree
Showing 16 changed files with 562 additions and 116 deletions.
37 changes: 23 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,6 @@ Neo4j::Http::Client.find_relationship(relationship: relationship, from: user1, t
Neo4j::Http::Client.delete_relationship(relationship: relationship, from: user1, to: user2)
```

### Executing multiple statements
```ruby
Neo4j::Http::Client.execute_batch_cypher([
{
"statement": "RETURN 1",
"parameters": {}
},
{
"statement": "RETURN 2",
"parameters": {}
}
])
```

Each of the methods exposed on `Neo4j::Http::Client` above are provided by instances of each of the following adapters:
* `Neo4j::Http::CypherClient` - provides an `execute_cypher` method which sends raw cypher commands to neo4j
* `Neo4j::Http::NodeClient` - provides a higher level API for upserting and deleting Nodes
Expand All @@ -138,6 +124,29 @@ cypher_client = Neo4j::Http::CypherClient.new(config)
node_client = Neo4j::Http::NodeClient.new(cypher_client)
```

## Batch operations

The `Neo4j::Http::Client.in_batch` will yield a batch client. It can be used like:

```ruby
Neo4j::Http::Client.in_batch do |tx|
[
tx.upsert_node(node),
tx.upsert_node(node2),
tx.upsert_relationship(relationship: relationship, from: from, to: to)
]
end
```

All of the commands need to chain off of the variable exposed by the block in order to
prepare the operations for the batch. These are not immediately invoked like their
single operation counterparts. The syntax and arguments are identical.

The array of statements will be passed into a batch client that will
prepare the statements and the parameters and issue a single
request to the Neo4j HTTP API. Note that the size of the batch is
determined by the caller's array length.

## Versioning

This project follows [semantic versioning](https://semver.org).
Expand Down
6 changes: 5 additions & 1 deletion lib/neo4j/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@

require "neo4j/http/auth_token"
require "neo4j/http/client"
require "neo4j/http/batch_client"
require "neo4j/http/configuration"
require "neo4j/http/cypher_client"
require "neo4j/http/batch_cypher_client"
require "neo4j/http/object_wrapper"
require "neo4j/http/node"
require "neo4j/http/node_client"
require "neo4j/http/batch_node_client"
require "neo4j/http/relationship"
require "neo4j/http/relationship_client"
require "neo4j/http/batch_relationship_client"
require "neo4j/http/results"

require "neo4j/http/errors"
Expand All @@ -24,7 +28,7 @@ module Http
extend self

def config
@congiguration ||= Configuration.new
@configuration ||= Configuration.new
end

def configure
Expand Down
28 changes: 28 additions & 0 deletions lib/neo4j/http/batch_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module Neo4j
module Http
class BatchClient < Client
class << self
def default
cypher_client = Http::BatchCypherClient.new(Neo4j::Http.config)
node_client = Http::BatchNodeClient.new(cypher_client)
relationship_client = Http::BatchRelationshipClient.new(cypher_client)
@default ||= new(cypher_client, node_client, relationship_client)
end
end

attr_accessor :cypher_client, :node_client, :relationship_client

def initialize(cypher_client, node_client, relationship_client)
@cypher_client = cypher_client
@node_client = node_client
@relationship_client = relationship_client
end

delegate(*CYPHER_CLIENT_METHODS, to: :cypher_client)
delegate(*NODE_CLIENT_METHODS, to: :node_client)
delegate(*RELATIONSHIP_CLIENT_METHODS, to: :relationship_client)
end
end
end
39 changes: 39 additions & 0 deletions lib/neo4j/http/batch_cypher_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

require "forwardable"
require "faraday"
require "faraday/retry"
require "faraday_middleware"

module Neo4j
module Http
class BatchCypherClient < CypherClient
# Each statement should be Hash with statement and parameters keys e.g.
# {
# statement: "MATCH (n:User { name: $name }) RETURN n",
# parameters: { name: "Ben" }
# }
# https://neo4j.com/docs/http-api/current/actions/execute-multiple-statements/
def execute_cypher(statements = [])
statements = [statements] if statements.is_a?(Hash) # equivalent to Array.wrap

request_body = {
statements: statements.map do |statement|
{
statement: statement[:statement],
parameters: statement[:parameters].as_json
}
end
}

@connection = @injected_connection || connection("WRITE")
response = @connection.post(transaction_path, request_body)
results = check_errors!(statements, response)

results.map do |result|
Neo4j::Http::Results.parse(result || {})
end
end
end
end
end
37 changes: 37 additions & 0 deletions lib/neo4j/http/batch_node_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

module Neo4j
module Http
class BatchNodeClient < NodeClient
def upsert_node(node)
raise "#{node.key_name} value cannot be blank - (node keys: #{node.to_h.keys})" if node.key_value.blank?

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

{
statement: cypher,
parameters: { key_value: node.key_value, attributes: node.attributes }
}
end

def delete_node(node)
cypher = <<-CYPHER
MATCH (node:#{node.label} {#{node.key_name}: $key_value})
WITH node
DETACH DELETE node
RETURN node
CYPHER

{
statement: cypher,
parameters: { key_value: node.key_value }
}
end
end
end
end
82 changes: 82 additions & 0 deletions lib/neo4j/http/batch_relationship_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

module Neo4j
module Http
class BatchRelationshipClient < RelationshipClient
def upsert_relationship(relationship:, from:, to:, create_nodes: false)
match_or_merge = create_nodes ? "MERGE" : "MATCH"
from_selector = build_match_selector(:from, from)
to_selector = build_match_selector(:to, to)
relationship_selector = build_match_selector(:relationship, relationship)

on_match = ""
if relationship.attributes.present?
on_match = <<-CYPHER
ON CREATE SET relationship += $relationship_attributes
ON MATCH SET relationship += $relationship_attributes
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

{
statement: cypher,
parameters: {
from: from,
to: to,
relationship: relationship,
relationship_attributes: relationship.attributes
}
}
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)

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

{
statement: cypher,
parameters: {
from: from,
to: to
}
}
end

def delete_relationship_on_primary_key(relationship:)
# protection against mass deletion of relationships
return if relationship.key_name.nil?

relationship_selector = build_match_selector(:relationship, relationship)

cypher = <<-CYPHER
MATCH () - [#{relationship_selector}] - ()
WITH relationship
DELETE relationship
RETURN relationship
CYPHER

{
statement: cypher,
parameters: {
relationship: relationship
}
}
end
end
end
end
7 changes: 6 additions & 1 deletion lib/neo4j/http/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module Neo4j
module Http
class Client
CYPHER_CLIENT_METHODS = %i[execute_cypher execute_batch_cypher].freeze
CYPHER_CLIENT_METHODS = %i[execute_cypher].freeze
NODE_CLIENT_METHODS = %i[delete_node find_node_by find_nodes_by upsert_node].freeze
RELATIONSHIP_CLIENT_METHODS = %i[delete_relationship upsert_relationship delete_relationship_on_primary_key].freeze
CLIENT_METHODS = (CYPHER_CLIENT_METHODS + NODE_CLIENT_METHODS + RELATIONSHIP_CLIENT_METHODS).freeze
Expand All @@ -17,6 +17,11 @@ def default
relationship_client = Http::RelationshipClient.new(cypher_client)
@default ||= new(cypher_client, node_client, relationship_client)
end

def in_batch &block
batch_client = Neo4j::Http::BatchClient
batch_client.execute_cypher yield(batch_client)
end
end

attr_accessor :cypher_client, :node_client, :relationship_client
Expand Down
25 changes: 0 additions & 25 deletions lib/neo4j/http/cypher_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,31 +40,6 @@ def execute_cypher(cypher, parameters = {})
Neo4j::Http::Results.parse(results&.first || {})
end

# Each statement should be Hash with statement and parameters keys e.g.
# {
# statement: "MATCH (n:User { name: $name }) RETURN n",
# parameters: { name: "Ben" }
# }
# https://neo4j.com/docs/http-api/current/actions/execute-multiple-statements/
def execute_batch_cypher(statements = [])
request_body = {
statements: statements.map do |statement|
{
statement: statement[:statement],
parameters: statement[:parameters].as_json
}
end
}

@connection = @injected_connection || connection("WRITE")
response = @connection.post(transaction_path, request_body)
results = check_errors!(statements, response)

results.map do |result|
Neo4j::Http::Results.parse(result || {})
end
end

def connection(access_mode)
build_connection(access_mode)
end
Expand Down
14 changes: 2 additions & 12 deletions lib/neo4j/http/node_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def initialize(cypher_client)
@cypher_client = cypher_client
end

def upsert_node(node, defer: false)
def upsert_node(node)
raise "#{node.key_name} value cannot be blank - (node keys: #{node.to_h.keys})" if node.key_value.blank?

cypher = <<-CYPHER
Expand All @@ -21,29 +21,19 @@ def upsert_node(node, defer: false)
return node
CYPHER

return {
statement: cypher,
parameters: { key_value: node.key_value, attributes: node.attributes }
} if defer

results = @cypher_client.execute_cypher(cypher, key_value: node.key_value, attributes: node.attributes)

results.first&.fetch("node")
end

def delete_node(node, defer: false)
def delete_node(node)
cypher = <<-CYPHER
MATCH (node:#{node.label} {#{node.key_name}: $key_value})
WITH node
DETACH DELETE node
RETURN node
CYPHER

return {
statement: cypher,
parameters: { key_value: node.key_value }
} if defer

results = @cypher_client.execute_cypher(cypher, key_value: node.key_value)
results.first&.fetch("node")
end
Expand Down
Loading

0 comments on commit 324813d

Please sign in to comment.