activecypher 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/active_cypher/base.rb +28 -8
- data/lib/active_cypher/bolt/driver.rb +6 -16
- data/lib/active_cypher/bolt/transaction.rb +9 -9
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +28 -0
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +1 -1
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +26 -0
- data/lib/active_cypher/connection_adapters/registry.rb +96 -0
- data/lib/active_cypher/connection_handler.rb +18 -3
- data/lib/active_cypher/connection_pool.rb +5 -23
- data/lib/active_cypher/connection_url_resolver.rb +14 -3
- data/lib/active_cypher/fixtures/dsl_context.rb +41 -0
- data/lib/active_cypher/fixtures/evaluator.rb +37 -0
- data/lib/active_cypher/fixtures/node_builder.rb +53 -0
- data/lib/active_cypher/fixtures/parser.rb +23 -0
- data/lib/active_cypher/fixtures/registry.rb +43 -0
- data/lib/active_cypher/fixtures/rel_builder.rb +96 -0
- data/lib/active_cypher/fixtures.rb +177 -0
- data/lib/active_cypher/generators/templates/application_graph_node.rb +1 -0
- data/lib/active_cypher/model/callbacks.rb +5 -13
- data/lib/active_cypher/model/connection_handling.rb +37 -52
- data/lib/active_cypher/model/connection_owner.rb +31 -38
- data/lib/active_cypher/model/core.rb +1 -63
- data/lib/active_cypher/model/destruction.rb +16 -18
- data/lib/active_cypher/model/labelling.rb +45 -0
- data/lib/active_cypher/model/persistence.rb +46 -40
- data/lib/active_cypher/model/querying.rb +47 -27
- data/lib/active_cypher/railtie.rb +40 -5
- data/lib/active_cypher/relationship.rb +77 -17
- data/lib/active_cypher/version.rb +1 -1
- data/lib/activecypher.rb +4 -1
- data/lib/cyrel/functions.rb +3 -1
- data/lib/cyrel/logging.rb +43 -0
- data/lib/cyrel/query.rb +6 -1
- metadata +11 -2
- data/lib/active_cypher/connection_factory.rb +0 -130
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3901e0713d002b9c8061ffe4f3e7e8b85cceb4faf3216df23c91cab136654356
|
4
|
+
data.tar.gz: ba8088d2981a28e99eca7e171553f94696c0ed940a51c4ba53fa03210dce3be8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1e0923e31a2d8a3bfea68047870de373ab012ecc90d6164bacd43f8c7a3af1f88ce4c9051b4e4d53ca541d738cefeb988da3e508995557d2c51277870a39da60
|
7
|
+
data.tar.gz: 63126e6704aa13ce3094d6aafc5590eb3d9786a58d065590fb68d98b5234ed5f7d125147663928cc7baadab3da623b5aafc9432b8324a4957e11b131632e8bf8
|
data/lib/active_cypher/base.rb
CHANGED
@@ -1,5 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'active_model'
|
4
|
+
require 'active_model/validations'
|
5
|
+
require 'active_model/callbacks'
|
6
|
+
require 'active_model/attributes'
|
7
|
+
require 'active_model/dirty'
|
8
|
+
|
3
9
|
module ActiveCypher
|
4
10
|
# @!parse
|
5
11
|
# # The One True Base Class. All node models must kneel before it.
|
@@ -8,18 +14,25 @@ module ActiveCypher
|
|
8
14
|
# @!attribute [rw] connects_to_mappings
|
9
15
|
# @return [Hash] Because every base class needs a mapping it will never use directly.
|
10
16
|
class_attribute :connects_to_mappings, default: {}
|
17
|
+
|
18
|
+
# Rails/ActiveModel foundations
|
11
19
|
include Logging
|
20
|
+
include ActiveModel::Model
|
21
|
+
include ActiveModel::Validations
|
22
|
+
include ActiveModel::Callbacks
|
23
|
+
include ActiveModel::Attributes
|
24
|
+
include ActiveModel::Dirty
|
12
25
|
|
13
26
|
# Let's just include every concern we can find, because why not.
|
14
|
-
include Model::Querying # Must be before Core so Core can override its methods
|
15
27
|
include Model::Core
|
28
|
+
include Model::Callbacks
|
29
|
+
include Model::Labelling
|
30
|
+
include Model::Querying
|
31
|
+
include Model::Abstract
|
16
32
|
include Model::Attributes
|
17
33
|
include Model::ConnectionOwner
|
18
|
-
include Model::Callbacks
|
19
34
|
include Model::Persistence
|
20
|
-
include Model::ConnectionHandling
|
21
35
|
include Model::Destruction
|
22
|
-
include Model::Abstract
|
23
36
|
include Model::Countable
|
24
37
|
include Model::Inspectable
|
25
38
|
|
@@ -29,15 +42,22 @@ module ActiveCypher
|
|
29
42
|
# If you still don't have a connection, you get an error. It's the circle of life.
|
30
43
|
# @return [Object] The connection instance
|
31
44
|
def connection
|
32
|
-
|
33
|
-
|
45
|
+
# Determine the current role (e.g., :writing, :reading)
|
46
|
+
# ActiveCypher::RuntimeRegistry.current_role defaults to :writing
|
47
|
+
# Only use db_key for pool lookup
|
48
|
+
if respond_to?(:connects_to_mappings) && connects_to_mappings.is_a?(Hash)
|
49
|
+
db_key = connects_to_mappings[:writing] # or whichever role is appropriate
|
50
|
+
if db_key && (pool = connection_handler.pool(db_key))
|
51
|
+
return pool.connection
|
52
|
+
end
|
34
53
|
end
|
35
54
|
|
36
|
-
# fall back to a per‑model connection created by establish_connection
|
37
55
|
return @connection if defined?(@connection) && @connection&.active?
|
38
56
|
|
39
57
|
raise ActiveCypher::ConnectionNotEstablished,
|
40
|
-
"No pool for
|
58
|
+
"No connection pool found for graph #{name}, db_key=#{db_key.inspect}. " \
|
59
|
+
'Ensure `connects_to` is configured for this model or its ancestors, ' \
|
60
|
+
'and `cypher_databases.yml` has the corresponding entries.'
|
41
61
|
end
|
42
62
|
end
|
43
63
|
|
@@ -12,31 +12,21 @@ module ActiveCypher
|
|
12
12
|
class Driver
|
13
13
|
DEFAULT_POOL_SIZE = ENV.fetch('BOLT_POOL_SIZE', 10).to_i
|
14
14
|
|
15
|
-
# Map URI schemes ➞ security/verification flags
|
16
|
-
# Because nothing says "enterprise" like six ways to spell 'bolt'.
|
17
|
-
SCHEMES = {
|
18
|
-
'bolt' => { secure: false, verify: true },
|
19
|
-
'bolt+s' => { secure: true, verify: true },
|
20
|
-
'bolt+ssc' => { secure: true, verify: false },
|
21
|
-
'neo4j' => { secure: false, verify: true },
|
22
|
-
'neo4j+s' => { secure: true, verify: true },
|
23
|
-
'neo4j+ssc' => { secure: true, verify: false }
|
24
|
-
}.freeze
|
25
|
-
|
26
15
|
# Initializes the driver, because you can't spell "abstraction" without "action".
|
27
16
|
#
|
28
17
|
# @param uri [String] The Bolt URI
|
29
18
|
# @param adapter [Object] The adapter instance
|
30
19
|
# @param auth_token [Hash] Authentication token
|
31
20
|
# @param pool_size [Integer] Connection pool size
|
32
|
-
|
33
|
-
|
34
|
-
|
21
|
+
# @param secure [Boolean] Use SSL (default: false)
|
22
|
+
# @param verify_cert [Boolean] Verify SSL certificate (default: true)
|
23
|
+
def initialize(uri:, adapter:, auth_token:, pool_size: DEFAULT_POOL_SIZE, secure: false, verify_cert: true)
|
24
|
+
@uri = URI(uri)
|
35
25
|
|
36
26
|
@adapter = adapter
|
37
27
|
@auth = auth_token
|
38
|
-
@secure =
|
39
|
-
@verify_cert =
|
28
|
+
@secure = secure
|
29
|
+
@verify_cert = verify_cert
|
40
30
|
|
41
31
|
# Create a connection pool with the specified size
|
42
32
|
# Because one connection is never enough for true disappointment.
|
@@ -5,7 +5,7 @@ module ActiveCypher
|
|
5
5
|
# Manages transaction state (BEGIN/COMMIT/ROLLBACK) and runs queries within a transaction.
|
6
6
|
class Transaction
|
7
7
|
include Instrumentation
|
8
|
-
attr_reader :bookmarks, :metadata
|
8
|
+
attr_reader :bookmarks, :metadata, :connection
|
9
9
|
|
10
10
|
# Initializes a new Transaction instance.
|
11
11
|
#
|
@@ -35,10 +35,10 @@ module ActiveCypher
|
|
35
35
|
# Send RUN message
|
36
36
|
run_metadata = {}
|
37
37
|
run_msg = Messaging::Run.new(query_str, parameters, run_metadata)
|
38
|
-
|
38
|
+
connection.write_message(run_msg)
|
39
39
|
|
40
40
|
# Read response to RUN
|
41
|
-
response =
|
41
|
+
response = connection.read_message
|
42
42
|
qid = -1
|
43
43
|
fields = []
|
44
44
|
|
@@ -53,7 +53,7 @@ module ActiveCypher
|
|
53
53
|
pull_metadata = { 'n' => -1 }
|
54
54
|
pull_metadata['qid'] = qid if qid != -1
|
55
55
|
pull_msg = Messaging::Pull.new(pull_metadata)
|
56
|
-
|
56
|
+
connection.write_message(pull_msg)
|
57
57
|
|
58
58
|
# Process PULL response(s)
|
59
59
|
records = []
|
@@ -61,7 +61,7 @@ module ActiveCypher
|
|
61
61
|
|
62
62
|
# Read messages until we get a SUCCESS (or FAILURE)
|
63
63
|
loop do
|
64
|
-
msg =
|
64
|
+
msg = connection.read_message
|
65
65
|
case msg
|
66
66
|
when Messaging::Record
|
67
67
|
# Store record with raw values - processing will happen in the adapter
|
@@ -108,10 +108,10 @@ module ActiveCypher
|
|
108
108
|
instrument_transaction(:commit, object_id) do
|
109
109
|
# Send COMMIT message
|
110
110
|
commit_msg = Messaging::Commit.new
|
111
|
-
|
111
|
+
connection.write_message(commit_msg)
|
112
112
|
|
113
113
|
# Read response to COMMIT
|
114
|
-
response =
|
114
|
+
response = connection.read_message
|
115
115
|
|
116
116
|
case response
|
117
117
|
when Messaging::Success
|
@@ -163,10 +163,10 @@ module ActiveCypher
|
|
163
163
|
instrument_transaction(:rollback, object_id) do
|
164
164
|
# Send ROLLBACK message
|
165
165
|
rollback_msg = Messaging::Rollback.new
|
166
|
-
|
166
|
+
connection.write_message(rollback_msg)
|
167
167
|
|
168
168
|
# Read response to ROLLBACK
|
169
|
-
response =
|
169
|
+
response = connection.read_message
|
170
170
|
|
171
171
|
case response
|
172
172
|
when Messaging::Success
|
@@ -49,6 +49,34 @@ module ActiveCypher
|
|
49
49
|
end
|
50
50
|
end
|
51
51
|
|
52
|
+
# Get current adapter type for ID handling
|
53
|
+
# Helper for generating ID-related Cypher functions that are database-specific
|
54
|
+
module CypherFunction
|
55
|
+
# Generate ID equality clause with the ID value embedded in the query for Memgraph
|
56
|
+
def self.id_equals(var, id_value, adapter)
|
57
|
+
func = id_function(adapter)
|
58
|
+
"#{func}(#{var}) = #{id_value}"
|
59
|
+
end
|
60
|
+
|
61
|
+
# Generate ID equality clause using a parameterized ID value for Neo4j
|
62
|
+
def self.id_equals_param(var, param_name, adapter)
|
63
|
+
func = id_function(adapter)
|
64
|
+
"#{func}(#{var}) = $#{param_name}"
|
65
|
+
end
|
66
|
+
|
67
|
+
# Generate a node variable with ID predicate
|
68
|
+
def self.node_with_id(node_var, id_value, adapter)
|
69
|
+
func = id_function(adapter)
|
70
|
+
"#{func}(#{node_var}) = #{id_value}"
|
71
|
+
end
|
72
|
+
|
73
|
+
# Return ID expression
|
74
|
+
def self.return_id(var, as_name, adapter)
|
75
|
+
func = id_function(adapter)
|
76
|
+
"#{func}(#{var}) AS #{as_name}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
52
80
|
# Turns rows into symbols, because Rubyists fear strings.
|
53
81
|
# @param rows [Array<Hash>] The rows to process
|
54
82
|
# @return [Array<Hash>] The processed rows
|
@@ -63,7 +63,7 @@ module ActiveCypher
|
|
63
63
|
|
64
64
|
instrument_query(cypher, params, context: context, metadata: { db: db, access_mode: access_mode }) do
|
65
65
|
session = Bolt::Session.new(connection, database: db)
|
66
|
-
result = session.run(cypher, prepare_params(params), access_mode
|
66
|
+
result = session.run(cypher, prepare_params(params), mode: access_mode)
|
67
67
|
rows = result.respond_to?(:to_a) ? result.to_a : result
|
68
68
|
session.close
|
69
69
|
rows
|
@@ -3,6 +3,38 @@
|
|
3
3
|
module ActiveCypher
|
4
4
|
module ConnectionAdapters
|
5
5
|
class MemgraphAdapter < AbstractBoltAdapter
|
6
|
+
# Register this adapter with the registry
|
7
|
+
Registry.register('memgraph', self)
|
8
|
+
|
9
|
+
# Use id() for Memgraph instead of elementId()
|
10
|
+
ID_FUNCTION = 'id'
|
11
|
+
|
12
|
+
# Helper methods for Cypher query generation with IDs
|
13
|
+
def self.with_direct_id(id)
|
14
|
+
"id(r) = #{id}"
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.with_param_id
|
18
|
+
'id(r) = $id'
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.with_direct_node_ids(a_id, b_id)
|
22
|
+
"id(p) = #{a_id} AND id(h) = #{b_id}"
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.with_param_node_ids
|
26
|
+
'id(p) = $from_id AND id(h) = $to_id'
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.return_id
|
30
|
+
'id(r) AS rid'
|
31
|
+
end
|
32
|
+
|
33
|
+
# Return self as id_handler for compatibility with tests
|
34
|
+
def id_handler
|
35
|
+
self.class
|
36
|
+
end
|
37
|
+
|
6
38
|
# Memgraph defaults to **implicit auto‑commit** transactions :contentReference[oaicite:1]{index=1},
|
7
39
|
# so we simply run the Cypher and return the rows.
|
8
40
|
def execute_cypher(cypher, params = {}, ctx = 'Query')
|
@@ -34,6 +66,18 @@ module ActiveCypher
|
|
34
66
|
true
|
35
67
|
end
|
36
68
|
|
69
|
+
# Override prepare_params to handle arrays correctly for Memgraph
|
70
|
+
# Memgraph's UNWIND requires actual arrays/lists, not maps
|
71
|
+
def prepare_params(raw)
|
72
|
+
case raw
|
73
|
+
when Hash then raw.transform_keys(&:to_s).transform_values { |v| prepare_params(v) }
|
74
|
+
when Array then raw.map { |v| prepare_params(v) } # Keep arrays as arrays for Memgraph
|
75
|
+
when Time, Date, DateTime then raw.iso8601
|
76
|
+
when Symbol then raw.to_s
|
77
|
+
else raw # String/Integer/Float/Boolean/NilClass
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
37
81
|
class ProtocolHandler < AbstractProtocolHandler
|
38
82
|
def extract_version(agent)
|
39
83
|
agent[%r{Memgraph/([\d.]+)}, 1] || 'unknown'
|
@@ -3,6 +3,32 @@
|
|
3
3
|
module ActiveCypher
|
4
4
|
module ConnectionAdapters
|
5
5
|
class Neo4jAdapter < AbstractBoltAdapter
|
6
|
+
Registry.register('neo4j', self)
|
7
|
+
|
8
|
+
# Use elementId() for Neo4j
|
9
|
+
ID_FUNCTION = 'elementId'
|
10
|
+
|
11
|
+
# Helper methods for Cypher query generation with IDs
|
12
|
+
def self.with_direct_id(id)
|
13
|
+
"elementId(r) = #{id}"
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.with_param_id
|
17
|
+
'elementId(r) = $id'
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.with_direct_node_ids(a_id, b_id)
|
21
|
+
"elementId(p) = #{a_id} AND elementId(h) = #{b_id}"
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.with_param_node_ids
|
25
|
+
'elementId(p) = $from_id AND elementId(h) = $to_id'
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.return_id
|
29
|
+
'elementId(r) AS rid'
|
30
|
+
end
|
31
|
+
|
6
32
|
def execute_cypher(cypher, params = {}, ctx = 'Query')
|
7
33
|
connect
|
8
34
|
session = connection.session # thin wrapper around Bolt::Session
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module ConnectionAdapters
|
5
|
+
# Registry: Because every adapter wants to feel special, and every ORM needs a secret society.
|
6
|
+
# This class is the adapter speakeasy—register your adapter, get on the list, and maybe, just maybe,
|
7
|
+
# you'll get to connect to a database tonight. Under the hood, it's just a hash, a dash of Ruby mischief,
|
8
|
+
# and the occasional existential dread when you realize your adapter isn't registered.
|
9
|
+
class Registry
|
10
|
+
class << self
|
11
|
+
# The sacred scroll of adapters. Not inherited, not shared—just ours.
|
12
|
+
def adapters
|
13
|
+
@adapters ||= {}
|
14
|
+
end
|
15
|
+
|
16
|
+
# Register an adapter class for a specific database type.
|
17
|
+
# Because every adapter wants to be chosen, but only a few make the cut.
|
18
|
+
# @param adapter_type [String] The adapter type name (e.g., 'neo4j', 'memgraph')
|
19
|
+
# @param adapter_class [Class] The adapter class to register
|
20
|
+
def register(adapter_type, adapter_class)
|
21
|
+
adapters[adapter_type.to_s.downcase] = adapter_class
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get all registered adapters (for those who like to peek behind the curtain).
|
25
|
+
# @return [Hash] The hash of registered adapters
|
26
|
+
def adapters_dup
|
27
|
+
adapters.dup
|
28
|
+
end
|
29
|
+
|
30
|
+
# Summon an adapter from a connection URL.
|
31
|
+
# @param url [String] Connection URL (e.g., "neo4j://user:pass@localhost:7687")
|
32
|
+
# @param options [Hash] Additional options for the connection
|
33
|
+
# @return [AbstractAdapter] An instance of the appropriate adapter
|
34
|
+
def create_from_url(url, options = {})
|
35
|
+
resolver = ActiveCypher::ConnectionUrlResolver.new(url)
|
36
|
+
config = resolver.to_hash
|
37
|
+
return nil unless config
|
38
|
+
|
39
|
+
create_from_config(config, options)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Conjure an adapter from a configuration hash.
|
43
|
+
# @param config [Hash] Configuration hash with adapter, host, port, etc.
|
44
|
+
# @param options [Hash] Additional options for the connection
|
45
|
+
# @return [AbstractAdapter] An instance of the appropriate adapter, or a cryptic error if you angered the registry spirits.
|
46
|
+
def create_from_config(config, options = {})
|
47
|
+
adapter_type = config[:adapter].to_s.downcase
|
48
|
+
adapter_class = adapters[adapter_type]
|
49
|
+
unless adapter_class
|
50
|
+
# Try to require the adapter file dynamically
|
51
|
+
begin
|
52
|
+
require "active_cypher/connection_adapters/#{adapter_type}_adapter"
|
53
|
+
rescue LoadError
|
54
|
+
# Ignore, will raise below
|
55
|
+
end
|
56
|
+
adapter_class = adapters[adapter_type]
|
57
|
+
end
|
58
|
+
raise ActiveCypher::ConnectionError, "No adapter registered for '#{adapter_type}'. The registry is silent (and so is require)." unless adapter_class
|
59
|
+
|
60
|
+
full_config = config.merge(options)
|
61
|
+
adapter_class.new(full_config)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Creates a Bolt driver from a connection URL, because sometimes you want to skip the foreplay and go straight to disappointment.
|
65
|
+
# @param url [String] Connection URL
|
66
|
+
# @param pool_size [Integer] Connection pool size
|
67
|
+
# @param options [Hash] Additional options
|
68
|
+
# @return [Bolt::Driver] The configured driver, or a ticket to the debugging underworld.
|
69
|
+
def create_driver_from_url(url, pool_size: 5, options: {})
|
70
|
+
resolver = ActiveCypher::ConnectionUrlResolver.new(url)
|
71
|
+
config = resolver.to_hash
|
72
|
+
return nil unless config
|
73
|
+
|
74
|
+
adapter = create_from_config(config, options)
|
75
|
+
return nil unless adapter
|
76
|
+
|
77
|
+
# Always use 'bolt' scheme for driver creation, regardless of adapter
|
78
|
+
uri = "bolt://#{config[:host]}:#{config[:port]}"
|
79
|
+
auth_token = {
|
80
|
+
scheme: 'basic',
|
81
|
+
principal: config[:username],
|
82
|
+
credentials: config[:password]
|
83
|
+
}
|
84
|
+
ActiveCypher::Bolt::Driver.new(
|
85
|
+
uri: uri,
|
86
|
+
adapter: adapter,
|
87
|
+
auth_token: auth_token,
|
88
|
+
pool_size: pool_size,
|
89
|
+
secure: config[:ssl] ? true : false,
|
90
|
+
verify_cert: config[:ssc] ? false : true
|
91
|
+
)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -2,8 +2,23 @@
|
|
2
2
|
|
3
3
|
module ActiveCypher
|
4
4
|
class ConnectionHandler
|
5
|
-
def initialize
|
6
|
-
|
7
|
-
|
5
|
+
def initialize
|
6
|
+
# One-level hash: db_key -> pool
|
7
|
+
@db_key_map = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
# Set a connection pool
|
11
|
+
# @param db_key [Symbol] The database key (e.g., :primary, :neo4j)
|
12
|
+
# @param pool [ConnectionPool] The connection pool
|
13
|
+
def set(db_key, pool)
|
14
|
+
@db_key_map[db_key.to_sym] = pool
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get a connection pool
|
18
|
+
# @param db_key [Symbol] The database key (e.g., :primary, :neo4j)
|
19
|
+
# @return [ConnectionPool, nil] The connection pool, or nil if not found
|
20
|
+
def pool(db_key)
|
21
|
+
@db_key_map[db_key.to_sym]
|
22
|
+
end
|
8
23
|
end
|
9
24
|
end
|
@@ -5,7 +5,7 @@ require 'timeout'
|
|
5
5
|
|
6
6
|
module ActiveCypher
|
7
7
|
class ConnectionPool
|
8
|
-
attr_reader :spec
|
8
|
+
attr_reader :spec, :connection_key
|
9
9
|
|
10
10
|
def initialize(spec)
|
11
11
|
@spec = spec.symbolize_keys
|
@@ -44,28 +44,10 @@ module ActiveCypher
|
|
44
44
|
conn = @conn_ref.value
|
45
45
|
return conn if conn&.active?
|
46
46
|
|
47
|
-
#
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
begin
|
52
|
-
new_conn = build_connection
|
53
|
-
@conn_ref.set(new_conn)
|
54
|
-
@retry_count.set(0) # Reset retry count on success
|
55
|
-
return new_conn
|
56
|
-
rescue StandardError => e
|
57
|
-
retries += 1
|
58
|
-
if retries <= max_retries
|
59
|
-
# Exponential backoff
|
60
|
-
sleep_time = 0.1 * (2**(retries - 1))
|
61
|
-
sleep(sleep_time)
|
62
|
-
retry
|
63
|
-
else
|
64
|
-
# Track persistent failures
|
65
|
-
@retry_count.update { |count| count + 1 }
|
66
|
-
raise ConnectionError, "Failed to establish connection after #{max_retries} attempts: #{e.message}"
|
67
|
-
end
|
68
|
-
end
|
47
|
+
# Create a new connection
|
48
|
+
new_conn = build_connection
|
49
|
+
@conn_ref.set(new_conn)
|
50
|
+
return new_conn
|
69
51
|
end
|
70
52
|
end
|
71
53
|
alias checkout connection
|
@@ -15,6 +15,12 @@ module ActiveCypher
|
|
15
15
|
# - memgraph+ssl://
|
16
16
|
# - memgraph+ssc://
|
17
17
|
#
|
18
|
+
# Database resolution:
|
19
|
+
# - If specified in path: neo4j://localhost:7687/custom_db → db=custom_db
|
20
|
+
# - Otherwise defaults based on adapter:
|
21
|
+
# - neo4j://localhost:7687 → db=neo4j
|
22
|
+
# - memgraph://localhost:7687 → db=memgraph
|
23
|
+
#
|
18
24
|
# The output of to_hash follows a consistent pattern:
|
19
25
|
# {
|
20
26
|
# adapter: "neo4j", # or "memgraph"
|
@@ -22,7 +28,7 @@ module ActiveCypher
|
|
22
28
|
# password: "pass",
|
23
29
|
# host: "localhost",
|
24
30
|
# port: 7687,
|
25
|
-
# database:
|
31
|
+
# database: "neo4j", # or "memgraph" or custom path value
|
26
32
|
# ssl: true,
|
27
33
|
# ssc: false,
|
28
34
|
# options: {} # future-proof for params like '?timeout=30'
|
@@ -83,8 +89,13 @@ module ActiveCypher
|
|
83
89
|
options = extract_query_params(uri.query)
|
84
90
|
|
85
91
|
# Extract database from path, if present
|
86
|
-
|
87
|
-
|
92
|
+
path_database = uri.path.empty? ? nil : uri.path.sub(%r{^/}, '')
|
93
|
+
path_database = nil if path_database&.empty?
|
94
|
+
|
95
|
+
# Determine database using factory pattern:
|
96
|
+
# 1. Use path database if specified
|
97
|
+
# 2. Otherwise fall back to adapter name as default
|
98
|
+
database = path_database || adapter
|
88
99
|
|
89
100
|
# The to_s conversion handles nil values
|
90
101
|
username = uri.user.to_s.empty? ? nil : CGI.unescape(uri.user)
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module Fixtures
|
5
|
+
# Context for evaluating fixture profile DSL files.
|
6
|
+
# Provides node and relationship methods for use in profiles.
|
7
|
+
class DSLContext
|
8
|
+
attr_reader :nodes, :relationships
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@nodes = []
|
12
|
+
@relationships = []
|
13
|
+
@refs = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
# DSL: node :ref, ModelClass, props
|
17
|
+
def node(ref, model_class, **props)
|
18
|
+
raise ArgumentError, "Duplicate node ref: #{ref.inspect}" if @refs.key?(ref)
|
19
|
+
|
20
|
+
@refs[ref] = :node
|
21
|
+
@nodes << { ref: ref, model_class: model_class, props: props }
|
22
|
+
end
|
23
|
+
|
24
|
+
# DSL: relationship :ref, :from_ref, :TYPE, :to_ref, props
|
25
|
+
def relationship(ref, from_ref, type, to_ref, **props)
|
26
|
+
raise ArgumentError, "Duplicate relationship ref: #{ref.inspect}" if @refs.key?(ref)
|
27
|
+
raise ArgumentError, "Unknown from_ref: #{from_ref.inspect}" unless @refs.key?(from_ref)
|
28
|
+
raise ArgumentError, "Unknown to_ref: #{to_ref.inspect}" unless @refs.key?(to_ref)
|
29
|
+
|
30
|
+
@refs[ref] = :relationship
|
31
|
+
@relationships << {
|
32
|
+
ref: ref,
|
33
|
+
from_ref: from_ref,
|
34
|
+
type: type,
|
35
|
+
to_ref: to_ref,
|
36
|
+
props: props
|
37
|
+
}
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module Fixtures
|
5
|
+
# Evaluator orchestrates node and relationship creation for a fixture profile.
|
6
|
+
class Evaluator
|
7
|
+
def initialize(registry: Registry, node_builder: NodeBuilder.new, rel_builder: RelBuilder.new)
|
8
|
+
@registry = registry
|
9
|
+
@node_builder = node_builder
|
10
|
+
@rel_builder = rel_builder
|
11
|
+
end
|
12
|
+
|
13
|
+
# Evaluate a sequence of DSL instructions (AST or direct calls).
|
14
|
+
# Each instruction is a hash: { type: :node/:relationship, args: [...] }
|
15
|
+
def evaluate(instructions)
|
16
|
+
instructions.each do |inst|
|
17
|
+
case inst[:type]
|
18
|
+
when :node
|
19
|
+
ref = inst[:ref]
|
20
|
+
model_class = inst[:model_class]
|
21
|
+
props = inst[:props]
|
22
|
+
@node_builder.build(ref, model_class, props)
|
23
|
+
when :relationship
|
24
|
+
ref = inst[:ref]
|
25
|
+
from_ref = inst[:from_ref]
|
26
|
+
type = inst[:rel_type]
|
27
|
+
to_ref = inst[:to_ref]
|
28
|
+
props = inst[:props]
|
29
|
+
@rel_builder.build(ref, from_ref, type, to_ref, props)
|
30
|
+
else
|
31
|
+
raise ArgumentError, "Unknown instruction type: #{inst[:type]}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module Fixtures
|
5
|
+
class NodeBuilder
|
6
|
+
# Builds a node and registers it like a bouncer at a VIP graph party.
|
7
|
+
#
|
8
|
+
# @param ref [Symbol, String] Logical ref name (e.g., :john, :spaceship_42)
|
9
|
+
# @param model_class [Class] The model class (must know how to connect and label itself)
|
10
|
+
# @param props [Hash] Properties to assign to the node
|
11
|
+
# @return [Object] Instantiated model object with DB-assigned internal_id
|
12
|
+
def self.build(ref, model_class, props)
|
13
|
+
conn = model_class.connection
|
14
|
+
labels = model_class.labels
|
15
|
+
|
16
|
+
# Because even Cypher likes a well-dressed node.
|
17
|
+
label_clause = labels.map { |label| "`#{label}`" }.join(':')
|
18
|
+
|
19
|
+
# Build and fire the CREATE query.
|
20
|
+
# We use id(n) because elementId(n) lies to us with strings.
|
21
|
+
cypher = <<~CYPHER
|
22
|
+
CREATE (n:#{label_clause} $props)
|
23
|
+
RETURN n, id(n) AS internal_id, properties(n) AS props
|
24
|
+
CYPHER
|
25
|
+
|
26
|
+
result = conn.execute_cypher(cypher, props: props)
|
27
|
+
record = result.first
|
28
|
+
|
29
|
+
# Extract properties returned by the DB
|
30
|
+
node_props = record[:props] || record['props'] || {}
|
31
|
+
node_props['internal_id'] = record[:internal_id] || record['internal_id']
|
32
|
+
|
33
|
+
# Instantiate and tag it like we own it
|
34
|
+
instance = model_class.instantiate(node_props)
|
35
|
+
Registry.add(ref, instance)
|
36
|
+
instance
|
37
|
+
end
|
38
|
+
|
39
|
+
# Bulk create nodes. Still uses single `CREATE` per node,
|
40
|
+
# just slices the list to avoid melting your graph engine.
|
41
|
+
#
|
42
|
+
# @param nodes [Array<Hash>] List of { ref:, model_class:, props: }
|
43
|
+
# @param batch_size [Integer] How many to process per slice
|
44
|
+
def self.bulk_build(nodes, batch_size: 200)
|
45
|
+
nodes.each_slice(batch_size) do |batch|
|
46
|
+
batch.each do |node|
|
47
|
+
build(node[:ref], node[:model_class], node[:props])
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|