activecypher 0.3.0 → 0.6.0
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/session.rb +62 -50
- data/lib/active_cypher/bolt/transaction.rb +95 -90
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +28 -0
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +40 -32
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +27 -1
- data/lib/active_cypher/connection_adapters/registry.rb +94 -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/cypher_config.rb +2 -1
- 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/node_generator.rb +32 -3
- data/lib/active_cypher/generators/relationship_generator.rb +29 -2
- data/lib/active_cypher/generators/templates/application_graph_node.rb +1 -0
- data/lib/active_cypher/generators/templates/node.rb.erb +10 -6
- data/lib/active_cypher/generators/templates/relationship.rb.erb +7 -6
- data/lib/active_cypher/instrumentation.rb +186 -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 +41 -33
- data/lib/active_cypher/model/core.rb +4 -12
- data/lib/active_cypher/model/countable.rb +10 -3
- data/lib/active_cypher/model/destruction.rb +23 -18
- data/lib/active_cypher/model/labelling.rb +45 -0
- data/lib/active_cypher/model/persistence.rb +52 -26
- data/lib/active_cypher/model/querying.rb +49 -25
- data/lib/active_cypher/railtie.rb +40 -5
- data/lib/active_cypher/relation.rb +10 -2
- 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/clause/set.rb +20 -10
- data/lib/cyrel/expression/property_access.rb +2 -0
- data/lib/cyrel/functions.rb +3 -1
- data/lib/cyrel/logging.rb +43 -0
- data/lib/cyrel/plus.rb +11 -0
- data/lib/cyrel/query.rb +7 -1
- data/lib/cyrel.rb +77 -18
- metadata +13 -2
- data/lib/active_cypher/connection_factory.rb +0 -130
@@ -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
|
@@ -16,7 +42,7 @@ module ActiveCypher
|
|
16
42
|
end
|
17
43
|
|
18
44
|
# Explicit TX helpers — optional but handy.
|
19
|
-
def begin_transaction = (@tx = @connection.session.begin_transaction)
|
45
|
+
def begin_transaction(**) = (@tx = @connection.session.begin_transaction(**))
|
20
46
|
def commit_transaction(_) = @tx&.commit
|
21
47
|
def rollback_transaction(_) = @tx&.rollback
|
22
48
|
|
@@ -0,0 +1,94 @@
|
|
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
|
+
)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
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)
|
@@ -23,7 +23,8 @@ module ActiveCypher
|
|
23
23
|
return nil if ENV['ACTIVE_CYPHER_SILENT_MISSING'] == 'true'
|
24
24
|
|
25
25
|
# Otherwise, raise a descriptive error
|
26
|
-
raise "Could not load ActiveCypher configuration. No such file - #{file}.
|
26
|
+
raise "Could not load ActiveCypher configuration. No such file - #{file}. " \
|
27
|
+
"Please run 'rails generate active_cypher:install' to create the configuration file."
|
27
28
|
end
|
28
29
|
|
29
30
|
## ------------------------------------------------------------
|
@@ -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
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module Fixtures
|
5
|
+
# Parses a fixture profile file using instance_eval in a DSL context.
|
6
|
+
class Parser
|
7
|
+
attr_reader :file, :dsl_context
|
8
|
+
|
9
|
+
def initialize(file)
|
10
|
+
@file = file
|
11
|
+
@dsl_context = ActiveCypher::Fixtures::DSLContext.new
|
12
|
+
end
|
13
|
+
|
14
|
+
# Evaluates the profile file in the DSL context.
|
15
|
+
# Returns the DSLContext instance (which accumulates node/rel declarations).
|
16
|
+
def parse
|
17
|
+
code = File.read(file)
|
18
|
+
dsl_context.instance_eval(code, file)
|
19
|
+
dsl_context
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module Fixtures
|
5
|
+
# Singleton registry for logical ref => model instance mapping
|
6
|
+
class Registry
|
7
|
+
@store = {}
|
8
|
+
|
9
|
+
class << self
|
10
|
+
# Store loaded instance
|
11
|
+
# @param ref [Symbol, String]
|
12
|
+
# @param obj [Object]
|
13
|
+
def add(ref, obj)
|
14
|
+
raise ArgumentError, "Duplicate fixture ref: #{ref.inspect}" if @store.key?(ref.to_sym)
|
15
|
+
|
16
|
+
@store[ref.to_sym] = obj
|
17
|
+
end
|
18
|
+
|
19
|
+
# Fetch in tests (`[]` delegate)
|
20
|
+
# @param ref [Symbol, String]
|
21
|
+
# @return [Object, nil]
|
22
|
+
def get(ref)
|
23
|
+
@store[ref.to_sym]
|
24
|
+
end
|
25
|
+
|
26
|
+
# Purge registry between loads
|
27
|
+
def reset!
|
28
|
+
@store.clear
|
29
|
+
end
|
30
|
+
|
31
|
+
# Allow bracket access: Registry[:foo]
|
32
|
+
def [](ref)
|
33
|
+
get(ref)
|
34
|
+
end
|
35
|
+
|
36
|
+
# For debugging or introspection
|
37
|
+
def all
|
38
|
+
@store.dup
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module Fixtures
|
5
|
+
class RelBuilder
|
6
|
+
# Builds a relationship between two nodes, enforcing cross-DB safety.
|
7
|
+
#
|
8
|
+
# @param _ref [Symbol, String] Logical reference for this relationship (not used for DB, but for registry/uniqueness)
|
9
|
+
# @param from_ref [Symbol, String] Logical ref of the start node
|
10
|
+
# @param type [Symbol, String] Relationship type (e.g., :LIKES)
|
11
|
+
# @param to_ref [Symbol, String] Logical ref of the end node
|
12
|
+
# @param props [Hash] Optional properties for the relationship
|
13
|
+
# @raise [ActiveCypher::FixtureError] if cross-DB relationship is attempted
|
14
|
+
# @return [void]
|
15
|
+
def build(_ref, from_ref, type, to_ref, props = {})
|
16
|
+
from = Registry.get(from_ref)
|
17
|
+
to = Registry.get(to_ref)
|
18
|
+
|
19
|
+
raise FixtureError, "Missing from node: #{from_ref}" unless from
|
20
|
+
raise FixtureError, "Missing to node: #{to_ref}" unless to
|
21
|
+
|
22
|
+
from_conn = from.class.connection
|
23
|
+
to_conn = to.class.connection
|
24
|
+
|
25
|
+
raise FixtureError, 'Cross-database relationship? Sorry, your data has commitment issues.' if from_conn != to_conn
|
26
|
+
|
27
|
+
from_label = from.class.labels.first
|
28
|
+
to_label = to.class.labels.first
|
29
|
+
|
30
|
+
cypher = <<~CYPHER
|
31
|
+
MATCH (a:#{from_label} {name: $from_name}), (b:#{to_label} {name: $to_name})
|
32
|
+
CREATE (a)-[r:#{type} $props]->(b)
|
33
|
+
RETURN r
|
34
|
+
CYPHER
|
35
|
+
|
36
|
+
from_conn.execute_cypher(
|
37
|
+
cypher,
|
38
|
+
from_name: from.name,
|
39
|
+
to_name: to.name,
|
40
|
+
props: props
|
41
|
+
)
|
42
|
+
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
|
46
|
+
# Bulk create relationships for performance using UNWIND batching
|
47
|
+
# @param rels [Array<Hash>] relationship definitions (from DSLContext)
|
48
|
+
# @param batch_size [Integer] batch size for UNWIND
|
49
|
+
def self.bulk_build(rels, batch_size: 200)
|
50
|
+
# Check all relationships for cross-DB violations first
|
51
|
+
rels.each do |rel|
|
52
|
+
from = Registry.get(rel[:from_ref])
|
53
|
+
to = Registry.get(rel[:to_ref])
|
54
|
+
raise FixtureError, "Both endpoints must exist: #{rel[:from_ref]}, #{rel[:to_ref]}" unless from && to
|
55
|
+
|
56
|
+
from_conn = from.class.connection
|
57
|
+
to_conn = to.class.connection
|
58
|
+
raise FixtureError, 'Cross-database relationship? Sorry, your data has commitment issues.' if from_conn != to_conn
|
59
|
+
end
|
60
|
+
|
61
|
+
# Group by connection
|
62
|
+
grouped = rels.group_by do |rel|
|
63
|
+
from = Registry.get(rel[:from_ref])
|
64
|
+
from.class.connection
|
65
|
+
end
|
66
|
+
|
67
|
+
grouped.each do |conn, group|
|
68
|
+
group.each_slice(batch_size) do |batch|
|
69
|
+
unwind_batch = batch.map do |rel|
|
70
|
+
from = Registry.get(rel[:from_ref])
|
71
|
+
to = Registry.get(rel[:to_ref])
|
72
|
+
|
73
|
+
{
|
74
|
+
from_name: from.name,
|
75
|
+
from_label: from.class.labels.first,
|
76
|
+
to_name: to.name,
|
77
|
+
to_label: to.class.labels.first,
|
78
|
+
props: rel[:props] || {},
|
79
|
+
type: rel[:type].to_s
|
80
|
+
}
|
81
|
+
end
|
82
|
+
|
83
|
+
cypher = <<~CYPHER
|
84
|
+
UNWIND $rows AS row
|
85
|
+
MATCH (a:#{unwind_batch.first[:from_label]} {name: row.from_name})
|
86
|
+
MATCH (b:#{unwind_batch.first[:to_label]} {name: row.to_name})
|
87
|
+
CREATE (a)-[r:#{unwind_batch.first[:type]} {props: row.props}]->(b)
|
88
|
+
CYPHER
|
89
|
+
|
90
|
+
conn.execute_cypher(cypher, rows: unwind_batch)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|