activecypher 0.11.1 → 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 +4 -4
- data/lib/active_cypher/base.rb +9 -5
- data/lib/active_cypher/bolt/connection.rb +18 -13
- data/lib/active_cypher/bolt/driver.rb +9 -23
- data/lib/active_cypher/bolt/session.rb +1 -8
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +33 -21
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +37 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +4 -2
- data/lib/active_cypher/migration.rb +68 -28
- data/lib/active_cypher/migrator.rb +12 -8
- data/lib/active_cypher/model/connection_handling.rb +38 -18
- data/lib/active_cypher/model/connection_owner.rb +65 -5
- data/lib/active_cypher/relationship.rb +20 -10
- data/lib/active_cypher/runtime_registry.rb +47 -2
- data/lib/active_cypher/version.rb +1 -1
- metadata +7 -8
- data/sig/activecypher.rbs +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43840a1c50c381abdd55b077b1748b8b06f6bd360cea489c70f46bb2da54735a
|
|
4
|
+
data.tar.gz: 4a06216a9511ca7c5f25b509a742aa91d701c634cd1778f7feecdba9ee948492
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3aeb03225c68a06e1a085ddef27f89d51e3084b86ddce90111754ab619a70f73e082eba8f1f7b80a9b8d2a1b6e297ff403d47f79e3877487cfbcb86fec322c2
|
|
7
|
+
data.tar.gz: 635b65a8fa3eb23e6758aaaa9fe51390ebce7e92b442710bffd672865b3767663aa4b02de768fca692db6d9e247ba8a80b2036d2a8c0639a06d815eeddc5439b
|
data/lib/active_cypher/base.rb
CHANGED
|
@@ -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)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
end
|
|
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
|
-
|
|
45
|
-
# We're already in an Async context, use the pool directly
|
|
44
|
+
Sync do
|
|
46
45
|
@pool.acquire do |conn|
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
131
|
+
# Use Sync for efficient synchronous execution within async context
|
|
122
132
|
result = nil
|
|
123
133
|
error = nil
|
|
124
134
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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)
|
|
@@ -40,7 +40,8 @@ module ActiveCypher
|
|
|
40
40
|
|
|
41
41
|
# Helper methods for Cypher query generation with IDs
|
|
42
42
|
def self.with_direct_id(id)
|
|
43
|
-
|
|
43
|
+
# Quote the element ID to handle special characters in Neo4j element IDs
|
|
44
|
+
"elementId(r) = '#{id}'"
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def self.with_param_id
|
|
@@ -48,7 +49,8 @@ module ActiveCypher
|
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
def self.with_direct_node_ids(a_id, b_id)
|
|
51
|
-
|
|
52
|
+
# Quote the element IDs to handle special characters like colons in UUID-based IDs
|
|
53
|
+
"elementId(p) = '#{a_id}' AND elementId(h) = '#{b_id}'"
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
def self.with_param_node_ids
|
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
config_for_adapter = spec.dup
|
|
35
|
+
next if processed_specs.key?(spec_key)
|
|
35
36
|
|
|
36
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
57
|
+
config_for_adapter = url_config.merge(spec.except(*url_config.keys))
|
|
58
|
+
end
|
|
47
59
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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