activecypher 0.11.2 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd727afea785772beaeb00a2d67953177535dfb60b6d64690e238b1df8d809ce
4
- data.tar.gz: '08838575c512ef8d3bafd2b7118f3c274bc8a4ba0d071e790d879294d4b05f5c'
3
+ metadata.gz: 43840a1c50c381abdd55b077b1748b8b06f6bd360cea489c70f46bb2da54735a
4
+ data.tar.gz: 4a06216a9511ca7c5f25b509a742aa91d701c634cd1778f7feecdba9ee948492
5
5
  SHA512:
6
- metadata.gz: 3789c6b35b8704ae06a7b497fa0a45db948fd69dc76326805a56cc16b396fa6c753a5ff78a5d96a28357fe6d1eb900ed26787e844e83a43a56f77bb5a20df6b5
7
- data.tar.gz: f059e3273a82ea69d12a6c16a6892a53840c0b2179f05c572c4aa6530861950273d9f03152943340e88c90361eb6a602e162c8295021f1f1b7177d5846193bd4
6
+ metadata.gz: a3aeb03225c68a06e1a085ddef27f89d51e3084b86ddce90111754ab619a70f73e082eba8f1f7b80a9b8d2a1b6e297ff403d47f79e3877487cfbcb86fec322c2
7
+ data.tar.gz: 635b65a8fa3eb23e6758aaaa9fe51390ebce7e92b442710bffd672865b3767663aa4b02de768fca692db6d9e247ba8a80b2036d2a8c0639a06d815eeddc5439b
@@ -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]
@@ -521,16 +526,16 @@ module ActiveCypher
521
526
  result = nil
522
527
 
523
528
  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
529
+ result = Sync do
530
+ case database_type
531
+ when :neo4j
532
+ perform_neo4j_health_check
533
+ when :memgraph
534
+ perform_memgraph_health_check
535
+ else
536
+ perform_generic_health_check
537
+ end
538
+ end
534
539
 
535
540
  result
536
541
  rescue ConnectionError, ProtocolError => e
@@ -41,32 +41,18 @@ module ActiveCypher
41
41
  # @yieldparam session [Bolt::Session] The session to use
42
42
  # @return [Object] The result of the block
43
43
  def with_session(**kw)
44
- if Async::Task.current?
45
- # We're already in an Async context, use the pool directly
44
+ Sync do
46
45
  @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
46
+ conn.mark_used!
47
+ session = Bolt::Session.new(conn, **kw)
53
48
 
54
- yield Bolt::Session.new(conn, **kw)
49
+ yield session
50
+ ensure
51
+ # Make sure any open transaction is cleaned up before returning the
52
+ # connection to the pool, so the next borrower doesn't inherit
53
+ # IN_TRANSACTION state.
54
+ session&.close
55
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
70
56
  end
71
57
  rescue Async::TimeoutError => e
72
58
  raise ActiveCypher::ConnectionError, "Connection pool timeout: #{e.message}"
@@ -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
 
@@ -98,6 +98,16 @@ module ActiveCypher
98
98
  mode.to_s # Default implementation
99
99
  end
100
100
 
101
+ # Ensure schema migration constraint exists for tracking migrations.
102
+ # Override in subclasses for database-specific syntax.
103
+ def ensure_schema_migration_constraint
104
+ execute_cypher(<<~CYPHER, {}, 'SchemaMigration')
105
+ CREATE CONSTRAINT graph_schema_migration IF NOT EXISTS
106
+ FOR (m:SchemaMigration)
107
+ REQUIRE m.version IS UNIQUE
108
+ CYPHER
109
+ end
110
+
101
111
  # Prepare transaction metadata with database-specific attributes
102
112
  def prepare_tx_metadata(metadata, _db, _access_mode)
103
113
  metadata # Default implementation
@@ -118,35 +128,37 @@ module ActiveCypher
118
128
  return false unless active?
119
129
 
120
130
  instrument_connection(:reset, config) do
121
- # Wrap in async to handle the connection reset properly
131
+ # Use Sync for efficient synchronous execution within async context
122
132
  result = nil
123
133
  error = nil
124
134
 
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
135
+ begin
136
+ result = Sync do
137
137
  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
138
+ # Try to execute a simple query first
139
+ session = Bolt::Session.new(@connection)
140
+ session.run('RETURN 1 AS check', {})
141
+ session.close
142
+ true
143
+ rescue StandardError => e
144
+ # Query failed, need to reset the connection
145
+ logger.debug { "Connection needs reset: #{e.message}" }
146
+
147
+ # Send RESET message directly
148
+ begin
149
+ @connection.write_message(Bolt::Messaging::Reset.new)
150
+ response = @connection.read_message
151
+ logger.debug { "Reset response: #{response.class}" }
152
+ response.is_a?(Bolt::Messaging::Success)
153
+ rescue StandardError => reset_error
154
+ logger.error { "Reset failed: #{reset_error.message}" }
155
+ false
156
+ end
145
157
  end
146
158
  end
147
159
  rescue StandardError => e
148
160
  error = e
149
- end.wait
161
+ end
150
162
 
151
163
  raise error if error
152
164
 
@@ -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.1'
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.1
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
@@ -241,7 +241,6 @@ files:
241
241
  - lib/cyrel/types/symbol_type.rb
242
242
  - lib/tasks/graphdb_migrate.rake
243
243
  - lib/tasks/graphdb_schema.rake
244
- - sig/activecypher.rbs
245
244
  homepage: https://github.com/seuros/activecypher
246
245
  licenses:
247
246
  - 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