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 +4 -4
- data/lib/active_cypher/base.rb +9 -5
- data/lib/active_cypher/bolt/connection.rb +18 -22
- data/lib/active_cypher/bolt/driver.rb +35 -27
- data/lib/active_cypher/bolt/session.rb +1 -8
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +71 -24
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +37 -0
- 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 +21 -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: 29fa2252b85c3c67d4f8a8e72acc69ad1021088561ce02c8e1d205bef28b4069
|
|
4
|
+
data.tar.gz: 0665ef0b5e7a68264f50b846ba78036dc5d3c805e874bc475abb12a3bb699069
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13b575ead30e4ea3b3476e97993c33e8eebc2dfb788d10e7ad9aa8e02014f389581d3e619f50629611340ea33af2bae967b026cd07110de57586887919ff89f3
|
|
7
|
+
data.tar.gz: a9dd762af2674169b558739ca5652e21cfcb60adb8a491c5d0bdf22b64a19b45c1087858db972a71f26af9113ee146e67ef57188e6f273adef959ca369a407d3
|
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]
|
|
@@ -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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
end
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
@
|
|
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
|
-
#
|
|
166
|
+
# Use Sync for efficient synchronous execution within async context
|
|
122
167
|
result = nil
|
|
123
168
|
error = nil
|
|
124
169
|
|
|
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
|
|
170
|
+
begin
|
|
171
|
+
result = Sync do
|
|
137
172
|
begin
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
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.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:
|
|
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
|
|
@@ -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