activecypher 0.11.2 → 0.12.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd727afea785772beaeb00a2d67953177535dfb60b6d64690e238b1df8d809ce
4
- data.tar.gz: '08838575c512ef8d3bafd2b7118f3c274bc8a4ba0d071e790d879294d4b05f5c'
3
+ metadata.gz: 29fa2252b85c3c67d4f8a8e72acc69ad1021088561ce02c8e1d205bef28b4069
4
+ data.tar.gz: 0665ef0b5e7a68264f50b846ba78036dc5d3c805e874bc475abb12a3bb699069
5
5
  SHA512:
6
- metadata.gz: 3789c6b35b8704ae06a7b497fa0a45db948fd69dc76326805a56cc16b396fa6c753a5ff78a5d96a28357fe6d1eb900ed26787e844e83a43a56f77bb5a20df6b5
7
- data.tar.gz: f059e3273a82ea69d12a6c16a6892a53840c0b2179f05c572c4aa6530861950273d9f03152943340e88c90361eb6a602e162c8295021f1f1b7177d5846193bd4
6
+ metadata.gz: 13b575ead30e4ea3b3476e97993c33e8eebc2dfb788d10e7ad9aa8e02014f389581d3e619f50629611340ea33af2bae967b026cd07110de57586887919ff89f3
7
+ data.tar.gz: a9dd762af2674169b558739ca5652e21cfcb60adb8a491c5d0bdf22b64a19b45c1087858db972a71f26af9113ee146e67ef57188e6f273adef959ca369a407d3
@@ -44,11 +44,15 @@ module ActiveCypher
44
44
  # Determine the current role (e.g., :writing, :reading)
45
45
  # ActiveCypher::RuntimeRegistry.current_role defaults to :writing
46
46
  # Only use db_key for pool lookup
47
- if respond_to?(:connects_to_mappings) && connects_to_mappings.is_a?(Hash)
48
- db_key = connects_to_mappings[:writing] # or whichever role is appropriate
49
- if db_key && (pool = connection_handler.pool(db_key))
50
- return pool.connection
51
- end
47
+ mapping = connects_to_mappings if respond_to?(:connects_to_mappings)
48
+ role = ActiveCypher::RuntimeRegistry.current_role || :writing
49
+ # Debug guardrails removed in release code; rely on role/shard registry.
50
+
51
+ db_key = ActiveCypher::Model::ConnectionOwner.db_key_for(mapping, role)
52
+ db_key = db_key.to_sym if db_key.respond_to?(:to_sym)
53
+
54
+ if db_key && (pool = connection_handler.pool(db_key))
55
+ return pool.connection
52
56
  end
53
57
 
54
58
  return @connection if defined?(@connection) && @connection&.active?
@@ -15,7 +15,8 @@ module ActiveCypher
15
15
  include VersionEncoding
16
16
 
17
17
  attr_reader :host, :port, :timeout_seconds, :socket,
18
- :protocol_version, :server_agent, :connection_id, :adapter
18
+ :protocol_version, :server_agent, :connection_id, :adapter,
19
+ :count
19
20
 
20
21
  # Override inspect to redact sensitive information
21
22
  def inspect
@@ -68,6 +69,7 @@ module ActiveCypher
68
69
  @connection_id = nil
69
70
  @reconnect_attempts = 0
70
71
  @max_reconnect_attempts = 3
72
+ @count = 0
71
73
  end
72
74
 
73
75
  # ───────────────────────── connection lifecycle ────────────── #
@@ -84,7 +86,7 @@ module ActiveCypher
84
86
  error = nil
85
87
 
86
88
  begin
87
- Async do |task|
89
+ Sync do |task|
88
90
  task.with_timeout(@timeout_seconds) do
89
91
  @socket = open_socket
90
92
  perform_handshake
@@ -102,7 +104,7 @@ module ActiveCypher
102
104
  close
103
105
  # Store the error instead of raising
104
106
  error = ConnectionError.new("Error during connection: #{e.message}")
105
- end.wait
107
+ end
106
108
  rescue Async::TimeoutError => e
107
109
  error = ConnectionError.new("Connection timed out to #{host}:#{port} - #{e.message}")
108
110
  rescue StandardError => e
@@ -223,6 +225,9 @@ module ActiveCypher
223
225
  # @return [Boolean]
224
226
  def reusable? = connected?
225
227
 
228
+ # Increment the usage counter for compatibility with Async::Pool diagnostics.
229
+ def mark_used! = @count += 1
230
+
226
231
  # This method is required by Async::Pool to check if the connection is viable for reuse
227
232
  #
228
233
  # @return [Boolean]
@@ -480,15 +485,6 @@ module ActiveCypher
480
485
  session(database: db).write_transaction(db: db, timeout: timeout, metadata: metadata, &)
481
486
  end
482
487
 
483
- # Asynchronously execute a read transaction.
484
- def async_read_transaction(db: nil, timeout: nil, metadata: nil, &)
485
- session(database: db).async_read_transaction(db: db, timeout: timeout, metadata: metadata, &)
486
- end
487
-
488
- # Asynchronously execute a write transaction.
489
- def async_write_transaction(db: nil, timeout: nil, metadata: nil, &)
490
- session(database: db).async_write_transaction(db: db, timeout: timeout, metadata: metadata, &)
491
- end
492
488
 
493
489
  # ────────────────────────────────────────────────────────────────────
494
490
  # HEALTH AND VERSION DETECTION METHODS
@@ -521,16 +517,16 @@ module ActiveCypher
521
517
  result = nil
522
518
 
523
519
  begin
524
- Async do
525
- result = case database_type
526
- when :neo4j
527
- perform_neo4j_health_check
528
- when :memgraph
529
- perform_memgraph_health_check
530
- else
531
- perform_generic_health_check
532
- end
533
- end.wait
520
+ result = Sync do
521
+ case database_type
522
+ when :neo4j
523
+ perform_neo4j_health_check
524
+ when :memgraph
525
+ perform_memgraph_health_check
526
+ else
527
+ perform_generic_health_check
528
+ end
529
+ end
534
530
 
535
531
  result
536
532
  rescue ConnectionError, ProtocolError => e
@@ -40,33 +40,9 @@ module ActiveCypher
40
40
  #
41
41
  # @yieldparam session [Bolt::Session] The session to use
42
42
  # @return [Object] The result of the block
43
- def with_session(**kw)
44
- if Async::Task.current?
45
- # We're already in an Async context, use the pool directly
46
- @pool.acquire do |conn|
47
- # Check if connection is viable before using it
48
- unless conn.viable?
49
- # Create a fresh connection, because hope springs eternal
50
- conn.close
51
- conn = build_connection
52
- end
53
-
54
- yield Bolt::Session.new(conn, **kw)
55
- end
56
- else
57
- # We're not in an Async context, create one and wait
58
- Async do
59
- @pool.acquire do |conn|
60
- # Check if connection is viable before using it
61
- unless conn.viable?
62
- # Create a fresh connection, because why not
63
- conn.close
64
- conn = build_connection
65
- end
66
-
67
- yield Bolt::Session.new(conn, **kw)
68
- end
69
- end.wait
43
+ def with_session(**kw, &block)
44
+ Sync do
45
+ _acquire_session(**kw, &block)
70
46
  end
71
47
  rescue Async::TimeoutError => e
72
48
  raise ActiveCypher::ConnectionError, "Connection pool timeout: #{e.message}"
@@ -74,6 +50,19 @@ module ActiveCypher
74
50
  raise ActiveCypher::ConnectionError, "Connection error: #{e.message}"
75
51
  end
76
52
 
53
+ # Asynchronously yields a Session. Each call acquires its own connection from the pool,
54
+ # making it safe for concurrent use across fibers.
55
+ #
56
+ # @yieldparam session [Bolt::Session] The session to use
57
+ # @return [Async::Task] A task that resolves to the block's result
58
+ def async_with_session(**kw, &block)
59
+ raise 'Cannot run async_with_session outside of an Async task' unless Async::Task.current?
60
+
61
+ Async do
62
+ _acquire_session(**kw, &block)
63
+ end
64
+ end
65
+
77
66
  # Checks if the database is alive, or just faking it for your benefit.
78
67
  #
79
68
  # @return [Boolean]
@@ -92,6 +81,25 @@ module ActiveCypher
92
81
 
93
82
  private
94
83
 
84
+ # Internal: acquires a connection and yields a session.
85
+ # @yieldparam session [Bolt::Session]
86
+ # @return [Object] The result of the block
87
+ def _acquire_session(**kw)
88
+ @pool.acquire do |conn|
89
+ conn.mark_used!
90
+ session = Bolt::Session.new(conn, **kw)
91
+
92
+ begin
93
+ yield session
94
+ ensure
95
+ # Make sure any open transaction is cleaned up before returning the
96
+ # connection to the pool, so the next borrower doesn't inherit
97
+ # IN_TRANSACTION state.
98
+ session&.close
99
+ end
100
+ end
101
+ end
102
+
95
103
  # Builds a new connection, because the old one just wasn't good enough.
96
104
  #
97
105
  # @return [Connection]
@@ -121,15 +121,8 @@ module ActiveCypher
121
121
  # @yield [tx] The transaction to use for queries.
122
122
  # @return The result of the block.
123
123
  def run_transaction(mode = :write, db: nil, timeout: nil, metadata: nil, &)
124
- if Async::Task.current?
125
- # Already in an async context, just run the block.
126
- # The block will run asynchronously within the current task.
124
+ Sync do
127
125
  _execute_transaction_block(mode, db, timeout, metadata, &)
128
- else
129
- # Not in an async context, so we need to create one and wait for it to complete.
130
- Async do
131
- _execute_transaction_block(mode, db, timeout, metadata, &)
132
- end.wait
133
126
  end
134
127
  end
135
128
 
@@ -11,11 +11,12 @@ module ActiveCypher
11
11
  class AbstractBoltAdapter < AbstractAdapter
12
12
  include Instrumentation
13
13
 
14
- attr_reader :connection
14
+ attr_reader :connection, :driver
15
15
 
16
16
  # Returns the raw Bolt connection object
17
17
  # This is useful for accessing low-level connection methods like
18
- # read_transaction, write_transaction, async_read_transaction, etc.
18
+ # read_transaction, write_transaction, etc.
19
+ # NOTE: For concurrent async operations, use with_session or async_with_session instead.
19
20
  def raw_connection
20
21
  @connection
21
22
  end
@@ -54,6 +55,18 @@ module ActiveCypher
54
55
  }
55
56
  end
56
57
 
58
+ # Create the driver with connection pool for concurrent operations
59
+ @driver = Bolt::Driver.new(
60
+ uri: "bolt://#{host}:#{port}",
61
+ adapter: self,
62
+ auth_token: auth,
63
+ pool_size: config.fetch(:pool_size, 10),
64
+ secure: ssl_params[:secure],
65
+ verify_cert: ssl_params[:verify_cert]
66
+ )
67
+
68
+ # Also create a single connection for backwards compatibility
69
+ # This connection is used for simple synchronous operations
57
70
  @connection = Bolt::Connection.new(
58
71
  host, port, self,
59
72
  auth_token: auth,
@@ -72,12 +85,34 @@ module ActiveCypher
72
85
  # Clean disconnection. Resets the internal state.
73
86
  def disconnect
74
87
  instrument_connection(:disconnect) do
75
- @connection.close if @connection
88
+ @driver&.close
89
+ @driver = nil
90
+ @connection&.close
76
91
  @connection = nil
77
92
  true
78
93
  end
79
94
  end
80
95
 
96
+ # Yields a Session from the connection pool. Safe for concurrent use.
97
+ # Each call acquires its own connection from the pool.
98
+ #
99
+ # @yieldparam session [Bolt::Session] The session to use
100
+ # @return [Object] The result of the block
101
+ def with_session(**kw, &block)
102
+ connect
103
+ @driver.with_session(**kw, &block)
104
+ end
105
+
106
+ # Asynchronously yields a Session from the connection pool.
107
+ # Each call acquires its own connection, making it safe for concurrent fibers.
108
+ #
109
+ # @yieldparam session [Bolt::Session] The session to use
110
+ # @return [Async::Task] A task that resolves to the block's result
111
+ def async_with_session(**kw, &block)
112
+ connect
113
+ @driver.async_with_session(**kw, &block)
114
+ end
115
+
81
116
  # Runs a Cypher query via Bolt session.
82
117
  # Automatically handles connect, logs query, cleans up session. Very adult.
83
118
  def run(cypher, params = {}, context: 'Query', db: nil, access_mode: :write)
@@ -98,6 +133,16 @@ module ActiveCypher
98
133
  mode.to_s # Default implementation
99
134
  end
100
135
 
136
+ # Ensure schema migration constraint exists for tracking migrations.
137
+ # Override in subclasses for database-specific syntax.
138
+ def ensure_schema_migration_constraint
139
+ execute_cypher(<<~CYPHER, {}, 'SchemaMigration')
140
+ CREATE CONSTRAINT graph_schema_migration IF NOT EXISTS
141
+ FOR (m:SchemaMigration)
142
+ REQUIRE m.version IS UNIQUE
143
+ CYPHER
144
+ end
145
+
101
146
  # Prepare transaction metadata with database-specific attributes
102
147
  def prepare_tx_metadata(metadata, _db, _access_mode)
103
148
  metadata # Default implementation
@@ -118,35 +163,37 @@ module ActiveCypher
118
163
  return false unless active?
119
164
 
120
165
  instrument_connection(:reset, config) do
121
- # Wrap in async to handle the connection reset properly
166
+ # Use Sync for efficient synchronous execution within async context
122
167
  result = nil
123
168
  error = nil
124
169
 
125
- Async do
126
- begin
127
- # Try to execute a simple query first
128
- session = Bolt::Session.new(@connection)
129
- session.run('RETURN 1 AS check', {})
130
- session.close
131
- result = true
132
- rescue StandardError => e
133
- # Query failed, need to reset the connection
134
- logger.debug { "Connection needs reset: #{e.message}" }
135
-
136
- # Send RESET message directly
170
+ begin
171
+ result = Sync do
137
172
  begin
138
- @connection.write_message(Bolt::Messaging::Reset.new)
139
- response = @connection.read_message
140
- result = response.is_a?(Bolt::Messaging::Success)
141
- logger.debug { "Reset response: #{response.class}" }
142
- rescue StandardError => reset_error
143
- logger.error { "Reset failed: #{reset_error.message}" }
144
- result = false
173
+ # Try to execute a simple query first
174
+ session = Bolt::Session.new(@connection)
175
+ session.run('RETURN 1 AS check', {})
176
+ session.close
177
+ true
178
+ rescue StandardError => e
179
+ # Query failed, need to reset the connection
180
+ logger.debug { "Connection needs reset: #{e.message}" }
181
+
182
+ # Send RESET message directly
183
+ begin
184
+ @connection.write_message(Bolt::Messaging::Reset.new)
185
+ response = @connection.read_message
186
+ logger.debug { "Reset response: #{response.class}" }
187
+ response.is_a?(Bolt::Messaging::Success)
188
+ rescue StandardError => reset_error
189
+ logger.error { "Reset failed: #{reset_error.message}" }
190
+ false
191
+ end
145
192
  end
146
193
  end
147
194
  rescue StandardError => e
148
195
  error = e
149
- end.wait
196
+ end
150
197
 
151
198
  raise error if error
152
199
 
@@ -63,6 +63,43 @@ module ActiveCypher
63
63
  self.class
64
64
  end
65
65
 
66
+ # Memgraph uses different constraint syntax than Neo4j
67
+ def ensure_schema_migration_constraint
68
+ execute_ddl(<<~CYPHER)
69
+ CREATE CONSTRAINT ON (m:SchemaMigration) ASSERT m.version IS UNIQUE
70
+ CYPHER
71
+ rescue ActiveCypher::QueryError => e
72
+ # Ignore if constraint already exists
73
+ raise unless e.message.include?('already exists')
74
+ end
75
+
76
+ # Execute DDL statements (constraints, indexes) without explicit transaction
77
+ # Memgraph requires auto-commit for schema manipulation
78
+ def execute_ddl(cypher, params = {})
79
+ connect
80
+ logger.debug { "[DDL] #{cypher}" }
81
+
82
+ Sync do
83
+ # Send RUN directly without BEGIN/COMMIT wrapper
84
+ connection.write_message(Bolt::Messaging::Run.new(cypher, params, {}))
85
+ connection.write_message(Bolt::Messaging::Pull.new({ n: -1 }))
86
+
87
+ # Read responses
88
+ run_response = connection.read_message
89
+ unless run_response.is_a?(Bolt::Messaging::Success)
90
+ # Read any remaining messages to clear connection state
91
+ connection.read_message rescue nil
92
+ # Send RESET to clear connection state
93
+ connection.write_message(Bolt::Messaging::Reset.new)
94
+ connection.read_message rescue nil
95
+ raise QueryError, "DDL failed for: #{cypher.inspect}\nError: #{run_response.fields.first}"
96
+ end
97
+
98
+ pull_response = connection.read_message
99
+ pull_response
100
+ end
101
+ end
102
+
66
103
  # Override run to execute queries without explicit transactions
67
104
  # Memgraph auto‑commits each query, so we send RUN + PULL directly
68
105
  def run(cypher, params = {}, context: 'Query', db: nil, access_mode: :write)
@@ -29,34 +29,76 @@ module ActiveCypher
29
29
  # DSL ---------------------------------------------------------------
30
30
 
31
31
  def create_node_index(label, *props, unique: false, if_not_exists: true, name: nil)
32
- props_clause = props.map { |p| "n.#{p}" }.join(', ')
33
- cypher = +'CREATE '
34
- cypher << 'UNIQUE ' if unique
35
- cypher << 'INDEX'
36
- cypher << " #{name}" if name
37
- cypher << ' IF NOT EXISTS' if if_not_exists
38
- cypher << " FOR (n:#{label}) ON (#{props_clause})"
39
- operations << cypher
32
+ cypher = if connection.vendor == :memgraph
33
+ # Memgraph syntax: CREATE INDEX ON :Label(prop)
34
+ props.map { |p| "CREATE INDEX ON :#{label}(#{p})" }
35
+ else
36
+ # Neo4j syntax
37
+ props_clause = props.map { |p| "n.#{p}" }.join(', ')
38
+ c = +'CREATE '
39
+ c << 'UNIQUE ' if unique
40
+ c << 'INDEX'
41
+ c << " #{name}" if name
42
+ c << ' IF NOT EXISTS' if if_not_exists
43
+ c << " FOR (n:#{label}) ON (#{props_clause})"
44
+ [c]
45
+ end
46
+ operations.concat(Array(cypher))
40
47
  end
41
48
 
42
49
  def create_rel_index(rel_type, *props, if_not_exists: true, name: nil)
43
- props_clause = props.map { |p| "r.#{p}" }.join(', ')
44
- cypher = +'CREATE INDEX'
45
- cypher << " #{name}" if name
46
- cypher << ' IF NOT EXISTS' if if_not_exists
47
- cypher << " FOR ()-[r:#{rel_type}]-() ON (#{props_clause})"
48
- operations << cypher
50
+ cypher = if connection.vendor == :memgraph
51
+ # Memgraph syntax: CREATE EDGE INDEX ON :REL_TYPE(prop)
52
+ props.map { |p| "CREATE EDGE INDEX ON :#{rel_type}(#{p})" }
53
+ else
54
+ # Neo4j syntax
55
+ props_clause = props.map { |p| "r.#{p}" }.join(', ')
56
+ c = +'CREATE INDEX'
57
+ c << " #{name}" if name
58
+ c << ' IF NOT EXISTS' if if_not_exists
59
+ c << " FOR ()-[r:#{rel_type}]-() ON (#{props_clause})"
60
+ [c]
61
+ end
62
+ operations.concat(Array(cypher))
49
63
  end
50
64
 
51
65
  def create_uniqueness_constraint(label, *props, if_not_exists: true, name: nil)
52
- props_clause = props.map { |p| "n.#{p}" }.join(', ')
53
- cypher = +'CREATE CONSTRAINT'
54
- cypher << " #{name}" if name
55
- cypher << ' IF NOT EXISTS' if if_not_exists
56
- cypher << " FOR (n:#{label}) REQUIRE (#{props_clause}) IS UNIQUE"
66
+ cypher = if connection.vendor == :memgraph
67
+ # Memgraph syntax: CREATE CONSTRAINT ON (n:Label) ASSERT n.prop IS UNIQUE
68
+ # Note: Memgraph doesn't support IF NOT EXISTS or named constraints
69
+ props_clause = props.map { |p| "n.#{p}" }.join(', ')
70
+ "CREATE CONSTRAINT ON (n:#{label}) ASSERT #{props_clause} IS UNIQUE"
71
+ else
72
+ # Neo4j syntax
73
+ props_clause = props.map { |p| "n.#{p}" }.join(', ')
74
+ c = +'CREATE CONSTRAINT'
75
+ c << " #{name}" if name
76
+ c << ' IF NOT EXISTS' if if_not_exists
77
+ c << " FOR (n:#{label}) REQUIRE (#{props_clause}) IS UNIQUE"
78
+ c
79
+ end
57
80
  operations << cypher
58
81
  end
59
82
 
83
+ def create_fulltext_index(name, label, *props, if_not_exists: true)
84
+ cypher = if connection.vendor == :memgraph
85
+ # Memgraph TEXT INDEX syntax (requires --experimental-enabled='text-search')
86
+ # Memgraph only supports single property per text index, so create one per prop
87
+ props.map.with_index do |p, i|
88
+ index_name = props.size > 1 ? "#{name}_#{p}" : name.to_s
89
+ "CREATE TEXT INDEX #{index_name} ON :#{label}(#{p})"
90
+ end
91
+ else
92
+ # Neo4j syntax
93
+ props_clause = props.map { |p| "n.#{p}" }.join(', ')
94
+ c = +"CREATE FULLTEXT INDEX #{name}"
95
+ c << ' IF NOT EXISTS' if if_not_exists
96
+ c << " FOR (n:#{label}) ON EACH [#{props_clause}]"
97
+ [c]
98
+ end
99
+ operations.concat(Array(cypher))
100
+ end
101
+
60
102
  def execute(cypher_string)
61
103
  operations << cypher_string.strip
62
104
  end
@@ -64,17 +106,15 @@ module ActiveCypher
64
106
  private
65
107
 
66
108
  def execute_operations
67
- tx = connection.begin_transaction if connection.respond_to?(:begin_transaction)
68
- operations.each do |cypher|
69
- if tx
70
- tx.run(cypher)
71
- else
72
- connection.execute_cypher(cypher)
73
- end
109
+ if connection.vendor == :memgraph
110
+ # Memgraph requires auto-commit for DDL operations
111
+ operations.each { |cypher| connection.execute_ddl(cypher) }
112
+ else
113
+ # Run each DDL individually (implicit auto-commit) to avoid session/async issues
114
+ operations.each { |cypher| connection.execute_cypher(cypher) }
74
115
  end
75
- connection.commit_transaction(tx) if tx
76
116
  rescue StandardError
77
- connection.rollback_transaction(tx) if tx
117
+ # Memgraph DDL is auto-committed, no rollback possible
78
118
  raise
79
119
  end
80
120
  end
@@ -56,9 +56,17 @@ module ActiveCypher
56
56
  end
57
57
 
58
58
  def migration_files
59
- migration_dirs.flat_map do |dir|
60
- Dir[File.expand_path(File.join(dir, '*.rb'), Dir.pwd)]
61
- end.sort
59
+ roots = [Pathname.new(Dir.pwd)]
60
+ if defined?(Rails) && Rails.respond_to?(:root)
61
+ rails_root = Rails.root
62
+ roots << rails_root unless rails_root.to_s == Dir.pwd
63
+ end
64
+
65
+ files = migration_dirs.flat_map do |dir|
66
+ roots.flat_map { |r| Dir[r.join(dir, '*.rb')] }
67
+ end
68
+
69
+ files.sort_by { |path| File.basename(path)[0, 14] }
62
70
  end
63
71
 
64
72
  def existing_versions
@@ -67,11 +75,7 @@ module ActiveCypher
67
75
  end
68
76
 
69
77
  def ensure_schema_migration_constraint
70
- @connection.execute_cypher(<<~CYPHER)
71
- CREATE CONSTRAINT graph_schema_migration IF NOT EXISTS
72
- FOR (m:SchemaMigration)
73
- REQUIRE m.version IS UNIQUE
74
- CYPHER
78
+ @connection.ensure_schema_migration_constraint
75
79
  end
76
80
  end
77
81
  end
@@ -26,35 +26,55 @@ module ActiveCypher
26
26
  # If you're lazy and don't specify :reading, it defaults to :writing. You're welcome.
27
27
  symbolized_mapping[:reading] ||= symbolized_mapping[:writing]
28
28
 
29
+ processed_specs = {}
30
+
29
31
  symbolized_mapping.each do |role, db_key|
30
- # db_key can be a symbol (config name) or a nested hash. We’re not judging.
31
- spec_name = db_key.is_a?(Hash) ? db_key.values.first : db_key
32
+ spec_names_for(db_key).each do |spec_name|
33
+ spec_key = spec_name.respond_to?(:to_sym) ? spec_name.to_sym : spec_name
32
34
 
33
- spec = ActiveCypher::CypherConfig.for(spec_name) # Boom. Pulls your DB config.
34
- config_for_adapter = spec.dup
35
+ next if processed_specs.key?(spec_key)
35
36
 
36
- # If the spec has a URL, parse it and let it override the boring YAML values.
37
- if spec[:url]
38
- resolver = ActiveCypher::ConnectionUrlResolver.new(spec[:url])
39
- url_config = resolver.to_hash
40
- raise ArgumentError, "Invalid connection URL: #{spec[:url]}" unless url_config
37
+ processed_specs[spec_key] = true
41
38
 
42
- config_for_adapter = url_config.merge(spec.except(*url_config.keys))
43
- end
39
+ # Reuse existing pools rather than instantiating duplicates
40
+ next if connection_handler.pool(spec_key)
41
+
42
+ begin
43
+ spec = ActiveCypher::CypherConfig.for(spec_key) # Boom. Pulls your DB config.
44
+ rescue KeyError => e
45
+ raise ActiveCypher::UnknownConnectionError,
46
+ "connects_to role `#{role}`: database configuration key `#{spec_name.inspect}` not found in cypher_databases.yml – #{e.message}"
47
+ end
48
+
49
+ config_for_adapter = spec.dup
50
+
51
+ # If the spec has a URL, parse it and let it override the boring YAML values.
52
+ if spec[:url]
53
+ resolver = ActiveCypher::ConnectionUrlResolver.new(spec[:url])
54
+ url_config = resolver.to_hash
55
+ raise ArgumentError, "Invalid connection URL: #{spec[:url]}" unless url_config
44
56
 
45
- # Create a unique connection pool for this role/config combo.
46
- pool = ActiveCypher::ConnectionPool.new(config_for_adapter)
57
+ config_for_adapter = url_config.merge(spec.except(*url_config.keys))
58
+ end
47
59
 
48
- # Register the pool under this spec name.
49
- connection_handler.set(spec_name, pool)
50
- rescue KeyError => e
51
- raise ActiveCypher::UnknownConnectionError,
52
- "connects_to role `#{role}`: database configuration key `#{spec_name.inspect}` not found in cypher_databases.yml – #{e.message}"
60
+ # Create a unique connection pool for this role/config combo.
61
+ pool = ActiveCypher::ConnectionPool.new(config_for_adapter)
62
+
63
+ # Register the pool under this spec name.
64
+ connection_handler.set(spec_key, pool)
65
+ end
53
66
  end
54
67
 
55
68
  # Save the mapping for later — introspection, debugging, blaming, etc.
56
69
  self.connects_to_mappings = symbolized_mapping
57
70
  end
71
+
72
+ private
73
+
74
+ def spec_names_for(db_key)
75
+ values = db_key.is_a?(Hash) ? db_key.values : db_key
76
+ Array(values).flatten.compact
77
+ end
58
78
  end
59
79
  end
60
80
  end
@@ -9,6 +9,42 @@ module ActiveCypher
9
9
  include ActiveCypher::Logging
10
10
  include ActiveCypher::Model::ConnectionHandling
11
11
 
12
+ def self.db_key_for(mapping, role)
13
+ return nil unless mapping.is_a?(Hash)
14
+
15
+ value = mapping[role]
16
+ value = mapping[:writing] if value.nil?
17
+
18
+ resolve_db_value(value, mapping[:writing])
19
+ end
20
+
21
+ def self.resolve_db_value(value, fallback, visited = nil)
22
+ visited ||= []
23
+ return nil if value.nil? && fallback.nil?
24
+
25
+ case value
26
+ when Hash
27
+ hash_id = value.object_id
28
+ return nil if visited.include?(hash_id)
29
+
30
+ visited += [hash_id]
31
+
32
+ shard = ActiveCypher::RuntimeRegistry.current_shard || :default
33
+ shard_value = value[shard] || value[:default] || value.values.first
34
+ resolve_db_value(shard_value, fallback, visited)
35
+ else
36
+ return value if value
37
+ return nil unless fallback
38
+
39
+ fallback_id = fallback.object_id
40
+ return nil if visited.include?(fallback_id)
41
+
42
+ visited += [fallback_id]
43
+ resolve_db_value(fallback, nil, visited)
44
+ end
45
+ end
46
+ private_class_method :resolve_db_value
47
+
12
48
  class_methods do
13
49
  # One handler for all subclasses that include this concern
14
50
  def connection_handler
@@ -30,12 +66,13 @@ module ActiveCypher
30
66
  # Always dynamically fetch the connection for the current db_key
31
67
  def connection
32
68
  handler = connection_handler
69
+ mapping = connects_to_mappings if respond_to?(:connects_to_mappings)
70
+ role = ActiveCypher::RuntimeRegistry.current_role || :writing
71
+ db_key = ConnectionOwner.db_key_for(mapping, role)
72
+ db_key = db_key.to_sym if db_key.respond_to?(:to_sym)
33
73
 
34
- if respond_to?(:connects_to_mappings) && connects_to_mappings.is_a?(Hash)
35
- db_key = connects_to_mappings[:writing] # Default to :writing mapping
36
- if db_key && (pool = handler.pool(db_key))
37
- return pool.connection
38
- end
74
+ if db_key && (pool = handler.pool(db_key))
75
+ return pool.connection
39
76
  end
40
77
 
41
78
  return superclass.connection if superclass.respond_to?(:connection)
@@ -43,6 +80,29 @@ module ActiveCypher
43
80
  raise ActiveCypher::ConnectionNotEstablished,
44
81
  "No connection pool found for #{name}, db_key=#{db_key.inspect}"
45
82
  end
83
+
84
+ # Switch the current role/shard for the duration of the block.
85
+ # Mirrors ActiveRecord::Base.connected_to semantics on a smaller scale.
86
+ def connected_to(role: nil, shard: nil)
87
+ raise ArgumentError, 'connected_to requires a block' unless block_given?
88
+
89
+ previous_role = ActiveCypher::RuntimeRegistry.current_role
90
+ previous_shard = ActiveCypher::RuntimeRegistry.current_shard
91
+
92
+ selected_role = role.nil? ? previous_role : role
93
+ selected_role ||= :writing
94
+
95
+ selected_shard = shard.nil? ? previous_shard : shard
96
+ selected_shard ||= :default
97
+
98
+ ActiveCypher::RuntimeRegistry.current_role = selected_role.to_sym
99
+ ActiveCypher::RuntimeRegistry.current_shard = selected_shard.to_sym
100
+
101
+ yield
102
+ ensure
103
+ ActiveCypher::RuntimeRegistry.current_role = previous_role
104
+ ActiveCypher::RuntimeRegistry.current_shard = previous_shard
105
+ end
46
106
  end
47
107
 
48
108
  # Instance method to access the adapter class
@@ -59,20 +59,30 @@ module ActiveCypher
59
59
  # --------------------------------------------------------------
60
60
  # Connection fallback
61
61
  # --------------------------------------------------------------
62
- # Relationship classes usually share the same Bolt pool as the
63
- # node they originate from; delegate there unless the relationship
64
- # class was given its own pool explicitly.
65
- #
66
- # WorksAtRelationship.connection # -> PersonNode.connection
67
- #
68
- def self.connection
69
- # If a node_base_class is set (directly or by convention), always delegate to its connection
62
+ # Relationship classes usually share the same Bolt pool as the
63
+ # node they originate from; delegate there unless the relationship
64
+ # class was given its own pool explicitly.
65
+ #
66
+ # WorksAtRelationship.connection # -> PersonNode.connection
67
+ #
68
+ def self.connection
69
+ # If this is a concrete relationship class with from_class defined,
70
+ # prefer delegating to that node's connection (so role/shard routing is respected).
71
+ if !abstract_class? && (fc = from_class_name)
72
+ klass = fc.constantize
73
+ role = ActiveCypher::RuntimeRegistry.current_role
74
+ shard = ActiveCypher::RuntimeRegistry.current_shard
75
+
76
+ return klass.connected_to(role: role, shard: shard) do
77
+ klass.connection
78
+ end
79
+ end
80
+
81
+ # Otherwise, fall back to node_base_class if present (even if abstract)
70
82
  if (klass = node_base_class)
71
83
  return klass.connection
72
84
  end
73
85
 
74
- return @connection if defined?(@connection) && @connection
75
-
76
86
  from_class.constantize.connection
77
87
  end
78
88
 
@@ -1,8 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Fiber-aware storage for role/shard context.
4
+ # Async schedules many fibers on a single thread, so classic
5
+ # thread locals leak across concurrent tasks. We key values by
6
+ # Fiber object id to isolate per-task state.
7
+ require 'async/task'
3
8
  module ActiveCypher
4
9
  module RuntimeRegistry
5
- thread_mattr_accessor :current_role, default: :writing
6
- thread_mattr_accessor :current_shard, default: :default
10
+ ROLE_KEY = :active_cypher_role
11
+ SHARD_KEY = :active_cypher_shard
12
+
13
+ module_function
14
+
15
+ def current_role
16
+ get(ROLE_KEY) || thread_store[ROLE_KEY] || :writing
17
+ end
18
+
19
+ def current_role=(value)
20
+ set(ROLE_KEY, value)
21
+ thread_store[ROLE_KEY] = value
22
+ end
23
+
24
+ def current_shard
25
+ get(SHARD_KEY) || thread_store[SHARD_KEY] || :default
26
+ end
27
+
28
+ def current_shard=(value)
29
+ set(SHARD_KEY, value)
30
+ thread_store[SHARD_KEY] = value
31
+ end
32
+
33
+ # ── storage helpers ────────────────────────────────────────────
34
+
35
+ def set(key, value)
36
+ store = fiber_store
37
+ store[key] = value
38
+ end
39
+
40
+ def get(key)
41
+ fiber_store[key]
42
+ end
43
+
44
+ def fiber_store
45
+ registry = thread_store[:active_cypher_fiber_store] ||= {}
46
+ registry[Fiber.current.object_id] ||= {}
47
+ end
48
+
49
+ def thread_store
50
+ Thread.current[:active_cypher_thread_store] ||= {}
51
+ end
7
52
  end
8
53
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.11.2'
4
+ VERSION = '0.12.2'
5
5
 
6
6
  def self.gem_version
7
7
  Gem::Version.new VERSION
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activecypher
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.2
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -27,30 +27,30 @@ dependencies:
27
27
  name: async
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - "~>"
30
+ - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '2.21'
32
+ version: 2.34.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - "~>"
37
+ - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '2.21'
39
+ version: 2.34.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: async-pool
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '0'
46
+ version: 0.11.0
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '0'
53
+ version: 0.11.0
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: io-endpoint
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0.6'
96
+ - !ruby/object:Gem::Dependency
97
+ name: async-safe
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
96
110
  description: OpenCypher Adapter ala ActiveRecord
97
111
  email:
98
112
  - seuros@pre-history.com
@@ -241,7 +255,6 @@ files:
241
255
  - lib/cyrel/types/symbol_type.rb
242
256
  - lib/tasks/graphdb_migrate.rake
243
257
  - lib/tasks/graphdb_schema.rake
244
- - sig/activecypher.rbs
245
258
  homepage: https://github.com/seuros/activecypher
246
259
  licenses:
247
260
  - MIT
data/sig/activecypher.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Activecypher
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end