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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/base.rb +28 -8
  3. data/lib/active_cypher/bolt/driver.rb +6 -16
  4. data/lib/active_cypher/bolt/session.rb +62 -50
  5. data/lib/active_cypher/bolt/transaction.rb +95 -90
  6. data/lib/active_cypher/connection_adapters/abstract_adapter.rb +28 -0
  7. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +40 -32
  8. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
  9. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +27 -1
  10. data/lib/active_cypher/connection_adapters/registry.rb +94 -0
  11. data/lib/active_cypher/connection_handler.rb +18 -3
  12. data/lib/active_cypher/connection_pool.rb +5 -23
  13. data/lib/active_cypher/connection_url_resolver.rb +14 -3
  14. data/lib/active_cypher/cypher_config.rb +2 -1
  15. data/lib/active_cypher/fixtures/dsl_context.rb +41 -0
  16. data/lib/active_cypher/fixtures/evaluator.rb +37 -0
  17. data/lib/active_cypher/fixtures/node_builder.rb +53 -0
  18. data/lib/active_cypher/fixtures/parser.rb +23 -0
  19. data/lib/active_cypher/fixtures/registry.rb +43 -0
  20. data/lib/active_cypher/fixtures/rel_builder.rb +96 -0
  21. data/lib/active_cypher/fixtures.rb +177 -0
  22. data/lib/active_cypher/generators/node_generator.rb +32 -3
  23. data/lib/active_cypher/generators/relationship_generator.rb +29 -2
  24. data/lib/active_cypher/generators/templates/application_graph_node.rb +1 -0
  25. data/lib/active_cypher/generators/templates/node.rb.erb +10 -6
  26. data/lib/active_cypher/generators/templates/relationship.rb.erb +7 -6
  27. data/lib/active_cypher/instrumentation.rb +186 -0
  28. data/lib/active_cypher/model/callbacks.rb +5 -13
  29. data/lib/active_cypher/model/connection_handling.rb +37 -52
  30. data/lib/active_cypher/model/connection_owner.rb +41 -33
  31. data/lib/active_cypher/model/core.rb +4 -12
  32. data/lib/active_cypher/model/countable.rb +10 -3
  33. data/lib/active_cypher/model/destruction.rb +23 -18
  34. data/lib/active_cypher/model/labelling.rb +45 -0
  35. data/lib/active_cypher/model/persistence.rb +52 -26
  36. data/lib/active_cypher/model/querying.rb +49 -25
  37. data/lib/active_cypher/railtie.rb +40 -5
  38. data/lib/active_cypher/relation.rb +10 -2
  39. data/lib/active_cypher/relationship.rb +77 -17
  40. data/lib/active_cypher/version.rb +1 -1
  41. data/lib/activecypher.rb +4 -1
  42. data/lib/cyrel/clause/set.rb +20 -10
  43. data/lib/cyrel/expression/property_access.rb +2 -0
  44. data/lib/cyrel/functions.rb +3 -1
  45. data/lib/cyrel/logging.rb +43 -0
  46. data/lib/cyrel/plus.rb +11 -0
  47. data/lib/cyrel/query.rb +7 -1
  48. data/lib/cyrel.rb +77 -18
  49. metadata +13 -2
  50. 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 = @role_shard_map = Hash.new { |h, k| h[k] = {} }
6
- def set(role, shard, pool) = (@role_shard_map[role][shard] = pool)
7
- def pool(role, shard) = @role_shard_map.dig(role, shard)
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
- # Slow path —create a new connection with retry logic
48
- retries = 0
49
- max_retries = @spec[:max_retries]
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: nil, # or optional
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
- database = uri.path.empty? ? nil : uri.path.sub(%r{^/}, '')
87
- database = nil if database&.empty?
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}. Please run 'rails generate active_cypher:install' to create the configuration 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