activecypher 0.15.3 → 0.15.5
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/associations.rb +185 -267
- data/lib/active_cypher/bolt/connection.rb +21 -41
- data/lib/active_cypher/bolt/transaction.rb +14 -2
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +33 -10
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +1 -1
- data/lib/active_cypher/connection_adapters/persistence_methods.rb +12 -15
- data/lib/active_cypher/connection_pool.rb +41 -20
- data/lib/active_cypher/fixtures.rb +34 -37
- data/lib/active_cypher/migration.rb +18 -15
- data/lib/active_cypher/rails_lens_ext/annotator.rb +15 -16
- data/lib/active_cypher/version.rb +1 -1
- data/lib/activecypher.rb +8 -0
- data/lib/cyrel/ast/compiler.rb +37 -28
- data/lib/cyrel/clause/set.rb +32 -28
- data/lib/cyrel/query.rb +2 -34
- metadata +2 -2
|
@@ -112,6 +112,8 @@ module ActiveCypher
|
|
|
112
112
|
end
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
TRANSIENT_RETRIES = 3
|
|
116
|
+
|
|
115
117
|
# Override run to execute queries using auto-commit mode.
|
|
116
118
|
# Memgraph auto-commits each query, so we send RUN + PULL directly
|
|
117
119
|
# without BEGIN/COMMIT wrapper. This avoids transaction state issues.
|
|
@@ -120,7 +122,26 @@ module ActiveCypher
|
|
|
120
122
|
logger.debug { "[#{context}] #{cypher} #{params.inspect}" }
|
|
121
123
|
|
|
122
124
|
instrument_query(cypher, params, context: context, metadata: { db: db, access_mode: access_mode }) do
|
|
123
|
-
run_auto_commit(cypher, prepare_params(params))
|
|
125
|
+
with_transient_retry(context) { run_auto_commit(cypher, prepare_params(params)) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Retry write/write conflicts the server flagged as transient. Safe only
|
|
130
|
+
# because this path is auto-commit (the whole query rolled back); do NOT
|
|
131
|
+
# lift into the explicit-transaction path, where earlier statements may
|
|
132
|
+
# already have applied.
|
|
133
|
+
def with_transient_retry(context)
|
|
134
|
+
attempts = 0
|
|
135
|
+
begin
|
|
136
|
+
yield
|
|
137
|
+
rescue ActiveCypher::TransientError => e
|
|
138
|
+
attempts += 1
|
|
139
|
+
raise if attempts > TRANSIENT_RETRIES
|
|
140
|
+
|
|
141
|
+
# Full jitter, so conflicting writers don't back off in step.
|
|
142
|
+
sleep(rand * 0.05 * (2**(attempts - 1)))
|
|
143
|
+
logger.debug { "[#{context}] transient conflict, retry #{attempts}/#{TRANSIENT_RETRIES}: #{e.message}" }
|
|
144
|
+
retry
|
|
124
145
|
end
|
|
125
146
|
end
|
|
126
147
|
|
|
@@ -159,10 +180,7 @@ module ActiveCypher
|
|
|
159
180
|
# End of results
|
|
160
181
|
break
|
|
161
182
|
when Bolt::Messaging::Failure
|
|
162
|
-
|
|
163
|
-
message = msg.metadata['message']
|
|
164
|
-
connection.reset!
|
|
165
|
-
raise QueryError, "Query failed: #{code} - #{message}"
|
|
183
|
+
raise_query_failure(msg)
|
|
166
184
|
else
|
|
167
185
|
raise ProtocolError, "Unexpected response during PULL: #{msg.class}"
|
|
168
186
|
end
|
|
@@ -170,16 +188,21 @@ module ActiveCypher
|
|
|
170
188
|
|
|
171
189
|
rows
|
|
172
190
|
when Bolt::Messaging::Failure
|
|
173
|
-
|
|
174
|
-
message = run_response.metadata['message']
|
|
175
|
-
connection.reset!
|
|
176
|
-
raise QueryError, "Query failed: #{code} - #{message}"
|
|
191
|
+
raise_query_failure(run_response)
|
|
177
192
|
else
|
|
178
193
|
raise ProtocolError, "Unexpected response to RUN: #{run_response.class}"
|
|
179
194
|
end
|
|
180
195
|
end
|
|
181
196
|
end
|
|
182
197
|
|
|
198
|
+
# Reset the connection and raise a QueryError for a Bolt Failure message.
|
|
199
|
+
# @param failure [Bolt::Messaging::Failure]
|
|
200
|
+
# @raise [QueryError]
|
|
201
|
+
def raise_query_failure(failure)
|
|
202
|
+
connection.reset!
|
|
203
|
+
raise QueryError, "Query failed: #{failure.metadata['code']} - #{failure.metadata['message']}"
|
|
204
|
+
end
|
|
205
|
+
|
|
183
206
|
# Memgraph defaults to **implicit auto‑commit** transactions
|
|
184
207
|
# so we simply run the Cypher and return the rows.
|
|
185
208
|
def execute_cypher(cypher, params = {}, ctx = 'Query')
|
|
@@ -276,7 +299,7 @@ module ActiveCypher
|
|
|
276
299
|
module Persistence
|
|
277
300
|
include PersistenceMethods
|
|
278
301
|
|
|
279
|
-
module_function :create_record, :update_record, :destroy_record
|
|
302
|
+
module_function :create_record, :update_record, :destroy_record, :node_id_expr
|
|
280
303
|
end
|
|
281
304
|
|
|
282
305
|
class ProtocolHandler < AbstractProtocolHandler
|
|
@@ -133,7 +133,7 @@ module ActiveCypher
|
|
|
133
133
|
module Persistence
|
|
134
134
|
include PersistenceMethods
|
|
135
135
|
|
|
136
|
-
module_function :create_record, :update_record, :destroy_record
|
|
136
|
+
module_function :create_record, :update_record, :destroy_record, :node_id_expr
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
protected
|
|
@@ -20,11 +20,7 @@ module ActiveCypher
|
|
|
20
20
|
# OPTIMIZED: Use string template instead of Cyrel for known-safe CREATE pattern
|
|
21
21
|
# Labels come from model class (safe), props are parameterized (safe)
|
|
22
22
|
label_string = labels.map { |l| ":#{l}" }.join
|
|
23
|
-
cypher =
|
|
24
|
-
"CREATE (n#{label_string} $props) RETURN elementId(n) AS internal_id"
|
|
25
|
-
else
|
|
26
|
-
"CREATE (n#{label_string} $props) RETURN id(n) AS internal_id"
|
|
27
|
-
end
|
|
23
|
+
cypher = "CREATE (n#{label_string} $props) RETURN #{node_id_expr(adapter)} AS internal_id"
|
|
28
24
|
|
|
29
25
|
data = model.connection.execute_cypher(cypher, { props: props }, 'Create')
|
|
30
26
|
|
|
@@ -59,11 +55,7 @@ module ActiveCypher
|
|
|
59
55
|
label_string = labels.map { |l| ":#{l}" }.join
|
|
60
56
|
set_clauses = changes.keys.map { |property| "n.#{property} = $#{property}" }.join(', ')
|
|
61
57
|
|
|
62
|
-
cypher =
|
|
63
|
-
"MATCH (n#{label_string}) WHERE elementId(n) = $node_id SET #{set_clauses} RETURN n"
|
|
64
|
-
else
|
|
65
|
-
"MATCH (n#{label_string}) WHERE id(n) = $node_id SET #{set_clauses} RETURN n"
|
|
66
|
-
end
|
|
58
|
+
cypher = "MATCH (n#{label_string}) WHERE #{node_id_expr(adapter)} = $node_id SET #{set_clauses} RETURN n"
|
|
67
59
|
|
|
68
60
|
params = changes.merge(node_id: node_id_param)
|
|
69
61
|
model.connection.execute_cypher(cypher, params, 'Update')
|
|
@@ -91,15 +83,20 @@ module ActiveCypher
|
|
|
91
83
|
# Labels come from model class (safe)
|
|
92
84
|
label_string = labels.map { |l| ":#{l}" }.join
|
|
93
85
|
|
|
94
|
-
cypher =
|
|
95
|
-
"MATCH (n#{label_string}) WHERE elementId(n) = $node_id DETACH DELETE n RETURN count(*) AS deleted"
|
|
96
|
-
else
|
|
97
|
-
"MATCH (n#{label_string}) WHERE id(n) = $node_id DETACH DELETE n RETURN count(*) AS deleted"
|
|
98
|
-
end
|
|
86
|
+
cypher = "MATCH (n#{label_string}) WHERE #{node_id_expr(adapter)} = $node_id DETACH DELETE n RETURN count(*) AS deleted"
|
|
99
87
|
|
|
100
88
|
result = model.connection.execute_cypher(cypher, { node_id: node_id_param }, 'Destroy')
|
|
101
89
|
result.present? && result.first[:deleted].to_i.positive?
|
|
102
90
|
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# The node-id function call for the adapter's dialect (Neo4j uses elementId, Memgraph id).
|
|
95
|
+
# @param adapter [#id_function] the connection's id handler
|
|
96
|
+
# @return [String] "elementId(n)" or "id(n)"
|
|
97
|
+
def node_id_expr(adapter)
|
|
98
|
+
adapter.id_function == 'elementId' ? 'elementId(n)' : 'id(n)'
|
|
99
|
+
end
|
|
103
100
|
end
|
|
104
101
|
end
|
|
105
102
|
end
|
|
@@ -25,27 +25,40 @@ module ActiveCypher
|
|
|
25
25
|
@spec = resolved_config.merge(@spec.except(:url))
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# One connection per thread: Bolt is a stateful, ordered protocol, so
|
|
29
|
+
# sharing a socket across threads corrupts the stream. @connections
|
|
30
|
+
# exists only so disconnect can close them all; the thread-local is
|
|
31
|
+
# what the hot path reads.
|
|
32
|
+
@connections = {}
|
|
33
|
+
@creation_mutex = Mutex.new
|
|
30
34
|
end
|
|
31
35
|
|
|
32
|
-
# Returns a live adapter
|
|
36
|
+
# Returns a live adapter belonging to the calling thread.
|
|
33
37
|
def connection
|
|
34
|
-
|
|
35
|
-
conn = @conn_ref
|
|
38
|
+
conn = Thread.current[thread_key]
|
|
36
39
|
return conn if conn&.active?
|
|
37
40
|
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
# Built outside the mutex: connecting does network IO.
|
|
42
|
+
new_conn = build_connection
|
|
43
|
+
Thread.current[thread_key] = new_conn
|
|
44
|
+
|
|
45
|
+
# Reap dead threads' connections, or each short-lived thread leaks a
|
|
46
|
+
# socket the server counts against max_connections.
|
|
47
|
+
orphaned = @creation_mutex.synchronize do
|
|
48
|
+
dead = @connections.reject { |t, _| t.alive? }
|
|
49
|
+
dead.each_key { |t| @connections.delete(t) }
|
|
50
|
+
@connections[Thread.current] = new_conn
|
|
51
|
+
dead.values
|
|
52
|
+
end
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
# Closed outside the mutex: disconnecting does IO too.
|
|
55
|
+
orphaned.each do |conn|
|
|
56
|
+
conn.disconnect
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
puts "Warning: Error disconnecting orphaned connection: #{e.message}" if ENV['DEBUG']
|
|
48
59
|
end
|
|
60
|
+
|
|
61
|
+
new_conn
|
|
49
62
|
end
|
|
50
63
|
alias checkout connection
|
|
51
64
|
|
|
@@ -54,23 +67,31 @@ module ActiveCypher
|
|
|
54
67
|
@retry_count >= @spec[:max_retries]
|
|
55
68
|
end
|
|
56
69
|
|
|
57
|
-
# Explicitly close
|
|
70
|
+
# Explicitly close every connection this pool handed out.
|
|
58
71
|
def disconnect
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
conns = @creation_mutex.synchronize do
|
|
73
|
+
taken = @connections.values
|
|
74
|
+
@connections.clear
|
|
75
|
+
taken
|
|
76
|
+
end
|
|
61
77
|
|
|
62
|
-
|
|
78
|
+
conns.each do |conn|
|
|
63
79
|
conn.disconnect
|
|
64
80
|
rescue StandardError => e
|
|
65
81
|
# Log but don't raise to ensure cleanup continues
|
|
66
82
|
puts "Warning: Error disconnecting: #{e.message}" if ENV['DEBUG']
|
|
67
|
-
ensure
|
|
68
|
-
@conn_ref = nil
|
|
69
83
|
end
|
|
84
|
+
|
|
85
|
+
Thread.current[thread_key] = nil
|
|
70
86
|
end
|
|
71
87
|
|
|
72
88
|
private
|
|
73
89
|
|
|
90
|
+
# Namespaced per pool instance so multiple databases don't share.
|
|
91
|
+
def thread_key
|
|
92
|
+
@thread_key ||= :"active_cypher_connection_#{object_id}"
|
|
93
|
+
end
|
|
94
|
+
|
|
74
95
|
def build_connection
|
|
75
96
|
adapter_name = @spec[:adapter]
|
|
76
97
|
raise ArgumentError, 'Missing adapter name in connection specification' unless adapter_name
|
|
@@ -35,11 +35,7 @@ module ActiveCypher
|
|
|
35
35
|
connections = model_classes.map(&:connection).compact.uniq
|
|
36
36
|
|
|
37
37
|
# 6. Wipe all nodes in each relevant connection
|
|
38
|
-
connections
|
|
39
|
-
conn.execute_cypher('MATCH (n) DETACH DELETE n')
|
|
40
|
-
rescue StandardError => e
|
|
41
|
-
warn "[ActiveCypher::Fixtures.load] Failed to clear connection #{conn.inspect}: #{e.class}: #{e.message}"
|
|
42
|
-
end
|
|
38
|
+
wipe_connections(connections, 'load')
|
|
43
39
|
|
|
44
40
|
# 7. Evaluate nodes and relationships (batched if large)
|
|
45
41
|
if dsl_context.nodes.size > 100 || dsl_context.relationships.size > 200
|
|
@@ -77,12 +73,39 @@ module ActiveCypher
|
|
|
77
73
|
connections = model_classes.map(&:connection).compact.uniq
|
|
78
74
|
|
|
79
75
|
# Wipe all nodes in each connection
|
|
76
|
+
wipe_connections(connections, 'clear_all')
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Build a comparable connection fingerprint for cross-database detection.
|
|
81
|
+
# @param conn [Object] a model connection
|
|
82
|
+
# @return [Hash] adapter/config/object_id details
|
|
83
|
+
def self.connection_details(conn)
|
|
84
|
+
{
|
|
85
|
+
adapter: conn.class.name,
|
|
86
|
+
config: conn.instance_variable_get(:@config),
|
|
87
|
+
object_id: conn.object_id
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Memoized connection-details lookup for a model class.
|
|
92
|
+
# @param klass [Class] the model class
|
|
93
|
+
# @param cache [Hash] class => details mapping, populated on miss
|
|
94
|
+
# @return [Hash] the connection details
|
|
95
|
+
def self.conn_details_for(klass, cache)
|
|
96
|
+
cache[klass] ||= connection_details(klass.connection)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Detach-delete every node across the given connections, logging per-connection failures.
|
|
100
|
+
# @param connections [Array] connections to wipe
|
|
101
|
+
# @param context [String] caller name used in the warning prefix
|
|
102
|
+
# @return [void]
|
|
103
|
+
def self.wipe_connections(connections, context)
|
|
80
104
|
connections.each do |conn|
|
|
81
105
|
conn.execute_cypher('MATCH (n) DETACH DELETE n')
|
|
82
106
|
rescue StandardError => e
|
|
83
|
-
warn "[ActiveCypher::Fixtures
|
|
107
|
+
warn "[ActiveCypher::Fixtures.#{context}] Failed to clear connection #{conn.inspect}: #{e.class}: #{e.message}"
|
|
84
108
|
end
|
|
85
|
-
true
|
|
86
109
|
end
|
|
87
110
|
|
|
88
111
|
# Validates relationships for cross-DB issues
|
|
@@ -96,13 +119,8 @@ module ActiveCypher
|
|
|
96
119
|
next unless klass < ActiveCypher::Base
|
|
97
120
|
next if klass.respond_to?(:abstract_class?) && klass.abstract_class?
|
|
98
121
|
|
|
99
|
-
conn = klass.connection
|
|
100
122
|
# Store connection details for comparison
|
|
101
|
-
model_connections[klass] =
|
|
102
|
-
adapter: conn.class.name,
|
|
103
|
-
config: conn.instance_variable_get(:@config),
|
|
104
|
-
object_id: conn.object_id
|
|
105
|
-
}
|
|
123
|
+
model_connections[klass] = connection_details(klass.connection)
|
|
106
124
|
end
|
|
107
125
|
|
|
108
126
|
relationships.each do |rel|
|
|
@@ -120,30 +138,9 @@ module ActiveCypher
|
|
|
120
138
|
from_class = from_node.class
|
|
121
139
|
to_class = to_node.class
|
|
122
140
|
|
|
123
|
-
# Look up connection details for each class
|
|
124
|
-
from_conn_details = model_connections
|
|
125
|
-
to_conn_details = model_connections
|
|
126
|
-
|
|
127
|
-
# If either class isn't in our mapping, refresh it
|
|
128
|
-
unless from_conn_details
|
|
129
|
-
conn = from_class.connection
|
|
130
|
-
from_conn_details = {
|
|
131
|
-
adapter: conn.class.name,
|
|
132
|
-
config: conn.instance_variable_get(:@config),
|
|
133
|
-
object_id: conn.object_id
|
|
134
|
-
}
|
|
135
|
-
model_connections[from_class] = from_conn_details
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
unless to_conn_details
|
|
139
|
-
conn = to_class.connection
|
|
140
|
-
to_conn_details = {
|
|
141
|
-
adapter: conn.class.name,
|
|
142
|
-
config: conn.instance_variable_get(:@config),
|
|
143
|
-
object_id: conn.object_id
|
|
144
|
-
}
|
|
145
|
-
model_connections[to_class] = to_conn_details
|
|
146
|
-
end
|
|
141
|
+
# Look up connection details for each class, refreshing the cache on miss
|
|
142
|
+
from_conn_details = conn_details_for(from_class, model_connections)
|
|
143
|
+
to_conn_details = conn_details_for(to_class, model_connections)
|
|
147
144
|
|
|
148
145
|
# Compare connection details
|
|
149
146
|
next unless from_conn_details[:object_id] != to_conn_details[:object_id] ||
|
|
@@ -40,14 +40,7 @@ module ActiveCypher
|
|
|
40
40
|
composite = props.size > 1 if composite.nil?
|
|
41
41
|
|
|
42
42
|
cypher = if connection.vendor == :memgraph
|
|
43
|
-
|
|
44
|
-
# Memgraph 3.2+ composite index: CREATE INDEX ON :Label(prop1, prop2)
|
|
45
|
-
props_list = props.join(', ')
|
|
46
|
-
["CREATE INDEX ON :#{label}(#{props_list})"]
|
|
47
|
-
else
|
|
48
|
-
# Memgraph single property indexes
|
|
49
|
-
props.map { |p| "CREATE INDEX ON :#{label}(#{p})" }
|
|
50
|
-
end
|
|
43
|
+
memgraph_index_statements('INDEX', label, props, composite)
|
|
51
44
|
else
|
|
52
45
|
# Neo4j syntax
|
|
53
46
|
props_clause = props.map { |p| "n.#{p}" }.join(', ')
|
|
@@ -72,13 +65,7 @@ module ActiveCypher
|
|
|
72
65
|
composite = props.size > 1 if composite.nil?
|
|
73
66
|
|
|
74
67
|
cypher = if connection.vendor == :memgraph
|
|
75
|
-
|
|
76
|
-
# Memgraph 3.2+ composite edge index
|
|
77
|
-
props_list = props.join(', ')
|
|
78
|
-
["CREATE EDGE INDEX ON :#{rel_type}(#{props_list})"]
|
|
79
|
-
else
|
|
80
|
-
props.map { |p| "CREATE EDGE INDEX ON :#{rel_type}(#{p})" }
|
|
81
|
-
end
|
|
68
|
+
memgraph_index_statements('EDGE INDEX', rel_type, props, composite)
|
|
82
69
|
else
|
|
83
70
|
# Neo4j syntax
|
|
84
71
|
props_clause = props.map { |p| "r.#{p}" }.join(', ')
|
|
@@ -221,6 +208,22 @@ module ActiveCypher
|
|
|
221
208
|
|
|
222
209
|
private
|
|
223
210
|
|
|
211
|
+
# Build Memgraph CREATE [EDGE] INDEX statements for a label/type and properties.
|
|
212
|
+
# @param index_keyword [String] "INDEX" for nodes, "EDGE INDEX" for relationships
|
|
213
|
+
# @param label [Symbol, String] node label or relationship type
|
|
214
|
+
# @param props [Array<Symbol, String>] properties to index
|
|
215
|
+
# @param composite [Boolean] emit a single composite index when more than one prop
|
|
216
|
+
# @return [Array<String>] one or more Cypher statements
|
|
217
|
+
def memgraph_index_statements(index_keyword, label, props, composite)
|
|
218
|
+
if composite && props.size > 1
|
|
219
|
+
# Memgraph 3.2+ composite index: CREATE [EDGE] INDEX ON :Label(prop1, prop2)
|
|
220
|
+
["CREATE #{index_keyword} ON :#{label}(#{props.join(', ')})"]
|
|
221
|
+
else
|
|
222
|
+
# Single property indexes
|
|
223
|
+
props.map { |p| "CREATE #{index_keyword} ON :#{label}(#{p})" }
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
224
227
|
def execute_operations
|
|
225
228
|
if connection.vendor == :memgraph
|
|
226
229
|
# Memgraph requires auto-commit for DDL operations
|
|
@@ -115,24 +115,10 @@ module ActiveCypher
|
|
|
115
115
|
models = []
|
|
116
116
|
|
|
117
117
|
# Find all Node classes (ActiveCypher::Base descendants)
|
|
118
|
-
if defined?(::ActiveCypher::Base)
|
|
119
|
-
ObjectSpace.each_object(Class) do |klass|
|
|
120
|
-
next unless klass < ::ActiveCypher::Base
|
|
121
|
-
next if klass == ::ActiveCypher::Base
|
|
122
|
-
|
|
123
|
-
models << klass
|
|
124
|
-
end
|
|
125
|
-
end
|
|
118
|
+
models.concat(descendants_of(::ActiveCypher::Base)) if defined?(::ActiveCypher::Base)
|
|
126
119
|
|
|
127
120
|
# Find all Relationship classes (ActiveCypher::Relationship descendants)
|
|
128
|
-
if defined?(::ActiveCypher::Relationship)
|
|
129
|
-
ObjectSpace.each_object(Class) do |klass|
|
|
130
|
-
next unless klass < ::ActiveCypher::Relationship
|
|
131
|
-
next if klass == ::ActiveCypher::Relationship
|
|
132
|
-
|
|
133
|
-
models << klass
|
|
134
|
-
end
|
|
135
|
-
end
|
|
121
|
+
models.concat(descendants_of(::ActiveCypher::Relationship)) if defined?(::ActiveCypher::Relationship)
|
|
136
122
|
|
|
137
123
|
# Filter out abstract classes unless requested
|
|
138
124
|
models.reject! { |m| m.respond_to?(:abstract_class?) && m.abstract_class? } unless options[:include_abstract]
|
|
@@ -152,6 +138,19 @@ module ActiveCypher
|
|
|
152
138
|
models.sort_by { |m| m.name || '' }
|
|
153
139
|
end
|
|
154
140
|
|
|
141
|
+
# Collect every loaded subclass of +base+ (excluding +base+ itself).
|
|
142
|
+
# @param base [Class] the ancestor to scan for
|
|
143
|
+
# @return [Array<Class>] strict descendants of +base+
|
|
144
|
+
def descendants_of(base)
|
|
145
|
+
[].tap do |found|
|
|
146
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
147
|
+
next unless klass < base
|
|
148
|
+
|
|
149
|
+
found << klass
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
155
154
|
# Eager load graph models from Rails app
|
|
156
155
|
def eager_load_graph_models
|
|
157
156
|
return unless defined?(Rails) && Rails.respond_to?(:root)
|
data/lib/activecypher.rb
CHANGED
|
@@ -52,6 +52,14 @@ module ActiveCypher
|
|
|
52
52
|
# Could be you. Could be Cypher. Could be fate.
|
|
53
53
|
class QueryError < Error; end
|
|
54
54
|
|
|
55
|
+
# A uniqueness or existence constraint said no.
|
|
56
|
+
# Rescue this to ignore duplicates without swallowing every QueryError.
|
|
57
|
+
class ConstraintError < QueryError; end
|
|
58
|
+
|
|
59
|
+
# Two writers touched the same node. Nothing is wrong with your query.
|
|
60
|
+
# The server is politely asking you to try again.
|
|
61
|
+
class TransientError < QueryError; end
|
|
62
|
+
|
|
55
63
|
# Your Cypher syntax is... interpretive.
|
|
56
64
|
# Unfortunately, the parser isn’t in the mood for interpretive dance.
|
|
57
65
|
class CypherSyntaxError < QueryError; end
|
data/lib/cyrel/ast/compiler.rb
CHANGED
|
@@ -84,10 +84,7 @@ module Cyrel
|
|
|
84
84
|
@output << 'RETURN '
|
|
85
85
|
@output << 'DISTINCT ' if node.distinct
|
|
86
86
|
|
|
87
|
-
node.items
|
|
88
|
-
@output << ', ' if index.positive?
|
|
89
|
-
render_expression(item)
|
|
90
|
-
end
|
|
87
|
+
render_comma_separated(node.items)
|
|
91
88
|
end
|
|
92
89
|
|
|
93
90
|
# Visit a SET node
|
|
@@ -109,10 +106,7 @@ module Cyrel
|
|
|
109
106
|
@output << 'WITH '
|
|
110
107
|
@output << 'DISTINCT ' if node.distinct
|
|
111
108
|
|
|
112
|
-
node.items
|
|
113
|
-
@output << ', ' if index.positive?
|
|
114
|
-
render_expression(item)
|
|
115
|
-
end
|
|
109
|
+
render_comma_separated(node.items)
|
|
116
110
|
|
|
117
111
|
# Add WHERE clause if present
|
|
118
112
|
return unless node.where_conditions && !node.where_conditions.empty?
|
|
@@ -139,13 +133,8 @@ module Cyrel
|
|
|
139
133
|
if node.expression.is_a?(Array)
|
|
140
134
|
# Array literal
|
|
141
135
|
@output << format_array_literal(node.expression)
|
|
142
|
-
elsif node.expression.is_a?(Symbol)
|
|
143
|
-
# Parameter reference
|
|
144
|
-
param_key = register_parameter(node.expression)
|
|
145
|
-
@output << "$#{param_key}"
|
|
146
136
|
else
|
|
147
|
-
|
|
148
|
-
render_expression(node.expression)
|
|
137
|
+
render_param_or_expression(node.expression)
|
|
149
138
|
end
|
|
150
139
|
|
|
151
140
|
@output << " AS #{node.alias_name}"
|
|
@@ -280,11 +269,11 @@ module Cyrel
|
|
|
280
269
|
|
|
281
270
|
subquery_compiler = QueryIntegratedCompiler.new(parameter_proxy)
|
|
282
271
|
clause_cypher, = subquery_compiler.compile(clause.ast_node)
|
|
283
|
-
@output << clause_cypher
|
|
272
|
+
@output << indent_block(clause_cypher)
|
|
284
273
|
else
|
|
285
274
|
# For legacy clauses, render normally
|
|
286
275
|
clause_output = clause.render(subquery)
|
|
287
|
-
@output << clause_output
|
|
276
|
+
@output << indent_block(clause_output) unless clause_output.blank?
|
|
288
277
|
|
|
289
278
|
# Merge subquery parameters
|
|
290
279
|
subquery.parameters.each_value do |value|
|
|
@@ -346,18 +335,12 @@ module Cyrel
|
|
|
346
335
|
def visit_foreach_node(node)
|
|
347
336
|
@output << "FOREACH (#{node.variable} IN "
|
|
348
337
|
|
|
349
|
-
# Handle the expression - could be an array literal or an expression
|
|
338
|
+
# Handle the expression - could be an array literal or an expression.
|
|
339
|
+
# An array is parameterized whole; everything else defers to the shared helper.
|
|
350
340
|
if node.expression.is_a?(Array)
|
|
351
|
-
|
|
352
|
-
param_key = register_parameter(node.expression)
|
|
353
|
-
@output << "$#{param_key}"
|
|
354
|
-
elsif node.expression.is_a?(Symbol)
|
|
355
|
-
# Symbol reference to parameter
|
|
356
|
-
param_key = register_parameter(node.expression)
|
|
357
|
-
@output << "$#{param_key}"
|
|
341
|
+
@output << "$#{register_parameter(node.expression)}"
|
|
358
342
|
else
|
|
359
|
-
|
|
360
|
-
render_expression(node.expression)
|
|
343
|
+
render_param_or_expression(node.expression)
|
|
361
344
|
end
|
|
362
345
|
|
|
363
346
|
@output << ' | '
|
|
@@ -392,8 +375,6 @@ module Cyrel
|
|
|
392
375
|
inner_compiler.instance_variable_set(:@loop_variables, @loop_variables.dup)
|
|
393
376
|
clause_cypher, = inner_compiler.compile([clause.ast_node])
|
|
394
377
|
@output << clause_cypher
|
|
395
|
-
|
|
396
|
-
# For other clause types, render directly
|
|
397
378
|
end
|
|
398
379
|
|
|
399
380
|
# Restore previous loop variables context
|
|
@@ -518,6 +499,34 @@ module Cyrel
|
|
|
518
499
|
end
|
|
519
500
|
end
|
|
520
501
|
|
|
502
|
+
# Render a list of items separated by commas (RETURN/WITH projections).
|
|
503
|
+
# @param items [Array] the expressions to render
|
|
504
|
+
# @return [void]
|
|
505
|
+
def render_comma_separated(items)
|
|
506
|
+
items.each_with_index do |item, index|
|
|
507
|
+
@output << ', ' if index.positive?
|
|
508
|
+
render_expression(item)
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Indent every line of a Cypher fragment by two spaces (subquery nesting).
|
|
513
|
+
# @param text [String] the fragment to indent
|
|
514
|
+
# @return [String] the indented fragment
|
|
515
|
+
def indent_block(text)
|
|
516
|
+
text.split("\n").map { |line| " #{line}" }.join("\n")
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Render a symbol as a parameter reference, otherwise delegate to {#render_expression}.
|
|
520
|
+
# @param expression [Object] the value to render
|
|
521
|
+
# @return [void]
|
|
522
|
+
def render_param_or_expression(expression)
|
|
523
|
+
if expression.is_a?(Symbol)
|
|
524
|
+
@output << "$#{register_parameter(expression)}"
|
|
525
|
+
else
|
|
526
|
+
render_expression(expression)
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
521
530
|
# Render an expression (could be a literal, parameter, property access, etc.)
|
|
522
531
|
def render_expression(expr)
|
|
523
532
|
case expr
|
data/lib/cyrel/clause/set.rb
CHANGED
|
@@ -17,34 +17,14 @@ module Cyrel
|
|
|
17
17
|
# e.g., [[:n, "NewLabel"], [:m, "AnotherLabel"]]
|
|
18
18
|
# Note: Mixing hash and array styles in one call is not directly supported, use multiple SET clauses if needed.
|
|
19
19
|
def initialize(assignments)
|
|
20
|
-
@assignments =
|
|
20
|
+
@assignments = self.class.normalize_assignments(assignments)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# @
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
set_parts = @assignments.map do |assignment|
|
|
30
|
-
render_assignment(assignment, query)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
"SET #{set_parts.join(', ')}"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Merges assignments from another Set clause.
|
|
37
|
-
# @param other_set [Cyrel::Clause::Set] The other Set clause to merge.
|
|
38
|
-
def merge!(other_set)
|
|
39
|
-
# Simple concatenation, assumes no conflicting assignments on the same property.
|
|
40
|
-
# More sophisticated merging might be needed depending on requirements.
|
|
41
|
-
@assignments.concat(other_set.assignments)
|
|
42
|
-
self
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
def process_assignments(assignments)
|
|
23
|
+
# Normalize raw SET assignments (Hash of props/labels or Array of label pairs)
|
|
24
|
+
# into the internal tuple form consumed by both Clause::Set and Query#set.
|
|
25
|
+
# @param assignments [Hash, Array]
|
|
26
|
+
# @return [Array<Array>] tuples like [:property, ...], [:variable_properties, ...], [:label, ...]
|
|
27
|
+
def self.normalize_assignments(assignments)
|
|
48
28
|
case assignments
|
|
49
29
|
when Hash
|
|
50
30
|
assignments.flat_map do |key, value|
|
|
@@ -68,19 +48,43 @@ module Cyrel
|
|
|
68
48
|
end
|
|
69
49
|
when Array
|
|
70
50
|
assignments.map do |item|
|
|
71
|
-
unless item.is_a?(Array) && item.length == 2
|
|
51
|
+
unless item.is_a?(Array) && item.length == 2
|
|
72
52
|
raise ArgumentError,
|
|
73
53
|
"Invalid label assignment format. Expected [[:variable, 'Label'], ...], got #{item.inspect}"
|
|
74
54
|
end
|
|
75
55
|
|
|
76
56
|
# SET n:Label
|
|
77
|
-
[:label, item[0], item[1]]
|
|
57
|
+
[:label, item[0].to_sym, item[1]]
|
|
78
58
|
end
|
|
79
59
|
else
|
|
80
60
|
raise ArgumentError, "Invalid assignments type for SET clause: #{assignments.class}"
|
|
81
61
|
end
|
|
82
62
|
end
|
|
83
63
|
|
|
64
|
+
# Renders the SET clause.
|
|
65
|
+
# @param query [Cyrel::Query] The query object for rendering expressions.
|
|
66
|
+
# @return [String, nil] The Cypher string fragment, or nil if no assignments exist.
|
|
67
|
+
def render(query)
|
|
68
|
+
return nil if @assignments.empty?
|
|
69
|
+
|
|
70
|
+
set_parts = @assignments.map do |assignment|
|
|
71
|
+
render_assignment(assignment, query)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
"SET #{set_parts.join(', ')}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Merges assignments from another Set clause.
|
|
78
|
+
# @param other_set [Cyrel::Clause::Set] The other Set clause to merge.
|
|
79
|
+
def merge!(other_set)
|
|
80
|
+
# Simple concatenation, assumes no conflicting assignments on the same property.
|
|
81
|
+
# More sophisticated merging might be needed depending on requirements.
|
|
82
|
+
@assignments.concat(other_set.assignments)
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
84
88
|
def render_assignment(assignment, query)
|
|
85
89
|
type, target, value, op = assignment
|
|
86
90
|
case type
|