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.
Files changed (37) 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/transaction.rb +9 -9
  5. data/lib/active_cypher/connection_adapters/abstract_adapter.rb +28 -0
  6. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +1 -1
  7. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
  8. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +26 -0
  9. data/lib/active_cypher/connection_adapters/registry.rb +96 -0
  10. data/lib/active_cypher/connection_handler.rb +18 -3
  11. data/lib/active_cypher/connection_pool.rb +5 -23
  12. data/lib/active_cypher/connection_url_resolver.rb +14 -3
  13. data/lib/active_cypher/fixtures/dsl_context.rb +41 -0
  14. data/lib/active_cypher/fixtures/evaluator.rb +37 -0
  15. data/lib/active_cypher/fixtures/node_builder.rb +53 -0
  16. data/lib/active_cypher/fixtures/parser.rb +23 -0
  17. data/lib/active_cypher/fixtures/registry.rb +43 -0
  18. data/lib/active_cypher/fixtures/rel_builder.rb +96 -0
  19. data/lib/active_cypher/fixtures.rb +177 -0
  20. data/lib/active_cypher/generators/templates/application_graph_node.rb +1 -0
  21. data/lib/active_cypher/model/callbacks.rb +5 -13
  22. data/lib/active_cypher/model/connection_handling.rb +37 -52
  23. data/lib/active_cypher/model/connection_owner.rb +31 -38
  24. data/lib/active_cypher/model/core.rb +1 -63
  25. data/lib/active_cypher/model/destruction.rb +16 -18
  26. data/lib/active_cypher/model/labelling.rb +45 -0
  27. data/lib/active_cypher/model/persistence.rb +46 -40
  28. data/lib/active_cypher/model/querying.rb +47 -27
  29. data/lib/active_cypher/railtie.rb +40 -5
  30. data/lib/active_cypher/relationship.rb +77 -17
  31. data/lib/active_cypher/version.rb +1 -1
  32. data/lib/activecypher.rb +4 -1
  33. data/lib/cyrel/functions.rb +3 -1
  34. data/lib/cyrel/logging.rb +43 -0
  35. data/lib/cyrel/query.rb +6 -1
  36. metadata +11 -2
  37. data/lib/active_cypher/connection_factory.rb +0 -130
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d529adab6eb5478a435e3061d317f742373617d8460b14f545d2b66b03cd37d
4
- data.tar.gz: 35726eeafd44cc915e3731b3c4557e63f6ace655b87c28761e1ebc426d989af1
3
+ metadata.gz: 3901e0713d002b9c8061ffe4f3e7e8b85cceb4faf3216df23c91cab136654356
4
+ data.tar.gz: ba8088d2981a28e99eca7e171553f94696c0ed940a51c4ba53fa03210dce3be8
5
5
  SHA512:
6
- metadata.gz: 0cdb35c5ebeb4d5774a2ff203b6322ef26e1f1abcdaac97b93cea64125322454fdd95b6a91117ced49ea5391815b96e84f274f96fd57725f0de65b1916aef7cf
7
- data.tar.gz: 258b065dc249ac957e7eed9025663dbbb12e7f0927dd7555793365bbbbbad36ffff4458aba48f99baedfecc02c214a490ba5e2cf68a9244d27a284291951f79f
6
+ metadata.gz: 1e0923e31a2d8a3bfea68047870de373ab012ecc90d6164bacd43f8c7a3af1f88ce4c9051b4e4d53ca541d738cefeb988da3e508995557d2c51277870a39da60
7
+ data.tar.gz: 63126e6704aa13ce3094d6aafc5590eb3d9786a58d065590fb68d98b5234ed5f7d125147663928cc7baadab3da623b5aafc9432b8324a4957e11b131632e8bf8
@@ -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
- if (pool = connection_handler.pool(current_role, current_shard))
33
- return pool.connection
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 role=#{current_role.inspect} shard=#{current_shard.inspect}"
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
- def initialize(uri:, adapter:, auth_token:, pool_size: DEFAULT_POOL_SIZE)
33
- @uri = URI(uri)
34
- scheme = SCHEMES.fetch(@uri.scheme) { raise ArgumentError, "Unsupported Bolt scheme: #{@uri.scheme}" }
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 = scheme[:secure]
39
- @verify_cert = scheme[:verify]
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
- @connection.write_message(run_msg)
38
+ connection.write_message(run_msg)
39
39
 
40
40
  # Read response to RUN
41
- response = @connection.read_message
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
- @connection.write_message(pull_msg)
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 = @connection.read_message
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
- @connection.write_message(commit_msg)
111
+ connection.write_message(commit_msg)
112
112
 
113
113
  # Read response to COMMIT
114
- response = @connection.read_message
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
- @connection.write_message(rollback_msg)
166
+ connection.write_message(rollback_msg)
167
167
 
168
168
  # Read response to ROLLBACK
169
- response = @connection.read_message
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 = @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)
@@ -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