activecypher 0.15.3 → 0.15.4
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/connection_adapters/memgraph_adapter.rb +11 -9
- 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/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/cyrel/ast/compiler.rb +37 -28
- data/lib/cyrel/clause/set.rb +32 -28
- data/lib/cyrel/query.rb +2 -34
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b0f2c80d0e44691d856e7e00f6fc34c51dc5e5661c47355ac66a3053896520f
|
|
4
|
+
data.tar.gz: 124e0126aa949bf7b506b22e364aba9b999eddbdca3f6209dd8fda6bb624314f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 636e42013b5f0f1e4e49da16ee1d49a116713c04a81aaa4d7007c6df481f9ed915a68108539d52de651076fe454b50528d11aa6e4673c8f61356a876c33b71f6
|
|
7
|
+
data.tar.gz: ac34eb11a6545dc2fddaa15437959c08164aef4ae0063332686a8ccea41357159977c5868938b276bb34563b5af4ace5a24a361802e4c5e165b287e3ac37f55c
|
|
@@ -13,6 +13,68 @@ module ActiveCypher
|
|
|
13
13
|
class_attribute :_reflections, instance_writer: false, default: {}
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Map a logical association direction to a Cyrel relationship direction.
|
|
17
|
+
# @param direction [:in, :out, :both]
|
|
18
|
+
# @return [Symbol] the corresponding Cyrel::Direction value
|
|
19
|
+
def self.cyrel_direction(direction)
|
|
20
|
+
case direction
|
|
21
|
+
when :out then Cyrel::Direction::OUT
|
|
22
|
+
when :in then Cyrel::Direction::IN
|
|
23
|
+
when :both then Cyrel::Direction::BOTH
|
|
24
|
+
else raise AssociationError, "Invalid direction: #{direction}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A labelled node pattern pinned to a model class.
|
|
29
|
+
# @param model_class [Class] the node model class
|
|
30
|
+
# @param alias_name [Symbol] the pattern alias
|
|
31
|
+
# @return [Cyrel::Pattern::Node]
|
|
32
|
+
def self.node_pattern(model_class, alias_name)
|
|
33
|
+
Cyrel::Pattern::Node.new(alias_name, labels: model_class.label_name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build a (start)-[rel]->(end) path between two node patterns.
|
|
37
|
+
# @param start_node [Cyrel::Pattern::Node] the "from" node pattern
|
|
38
|
+
# @param end_node [Cyrel::Pattern::Node] the "to" node pattern
|
|
39
|
+
# @param direction [:in, :out, :both] direction relative to start_node
|
|
40
|
+
# @param rel_type [String, Symbol] the relationship type
|
|
41
|
+
# @param rel_alias [Symbol, nil] optional alias for the relationship
|
|
42
|
+
# @return [Cyrel::Pattern::Path]
|
|
43
|
+
def self.relationship_path(start_node, end_node, direction, rel_type, rel_alias: nil)
|
|
44
|
+
rel = Cyrel::Pattern::Relationship.new(alias_name: rel_alias, types: rel_type,
|
|
45
|
+
direction: cyrel_direction(direction))
|
|
46
|
+
Cyrel::Pattern::Path.new([start_node, rel, end_node])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build a query matching two nodes pinned by their internal ids, ready to
|
|
50
|
+
# chain a further .match/.create/.delete_ onto. Cyrel orders clauses
|
|
51
|
+
# canonically, so the trailing operation may be appended in any order.
|
|
52
|
+
# @param start_node the model instance at the "from" end
|
|
53
|
+
# @param start_alias [Symbol] alias for the start node
|
|
54
|
+
# @param end_node the model instance at the "to" end
|
|
55
|
+
# @param end_alias [Symbol] alias for the end node
|
|
56
|
+
# @return [Cyrel::Query]
|
|
57
|
+
def self.match_endpoints(start_node, start_alias, end_node, end_alias)
|
|
58
|
+
Cyrel::Query.new
|
|
59
|
+
.match(node_pattern(start_node.class, start_alias))
|
|
60
|
+
.match(node_pattern(end_node.class, end_alias))
|
|
61
|
+
.where(Cyrel.node_id(start_alias).eq(start_node.internal_id))
|
|
62
|
+
.where(Cyrel.node_id(end_alias).eq(end_node.internal_id))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Order a pair of endpoints by association direction.
|
|
66
|
+
# @param receiver the model instance owning the association
|
|
67
|
+
# @param other the associated model instance
|
|
68
|
+
# @param direction [:in, :out, :both] direction relative to the receiver
|
|
69
|
+
# @return [Array] [start_node, end_node]
|
|
70
|
+
def self.ordered_endpoints(receiver, other, direction)
|
|
71
|
+
case direction
|
|
72
|
+
when :out, :both then [receiver, other]
|
|
73
|
+
when :in then [other, receiver]
|
|
74
|
+
else raise ArgumentError, "Direction '#{direction}' not supported for this operation"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
16
78
|
class_methods do
|
|
17
79
|
# Defines a one-to-many association.
|
|
18
80
|
#
|
|
@@ -171,25 +233,20 @@ module ActiveCypher
|
|
|
171
233
|
end
|
|
172
234
|
|
|
173
235
|
target_class = target_class_name.constantize
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
#
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
rel = Cyrel::Pattern::Relationship.new(
|
|
183
|
-
types: rel_type,
|
|
184
|
-
direction: Cyrel::Direction::BOTH # undirected ‹--›
|
|
236
|
+
start_alias = :start_node
|
|
237
|
+
target_alias = :target # Relation#map_results only unwraps the :n or :target alias
|
|
238
|
+
|
|
239
|
+
# belongs_to matches the relationship undirected (‹--›), regardless of declared direction
|
|
240
|
+
path = Associations.relationship_path(
|
|
241
|
+
Associations.node_pattern(self.class, start_alias),
|
|
242
|
+
Associations.node_pattern(target_class, target_alias),
|
|
243
|
+
:both, rel_type
|
|
185
244
|
)
|
|
186
245
|
|
|
187
|
-
path = Cyrel::Pattern::Path.new([a_node, rel, b_node])
|
|
188
|
-
|
|
189
246
|
query = Cyrel::Query.new
|
|
190
247
|
.match(path)
|
|
191
|
-
.where(Cyrel.node_id(
|
|
192
|
-
.return_(
|
|
248
|
+
.where(Cyrel.node_id(start_alias).eq(internal_id))
|
|
249
|
+
.return_(target_alias)
|
|
193
250
|
.limit(1)
|
|
194
251
|
|
|
195
252
|
relation = Relation.new(target_class, query)
|
|
@@ -197,93 +254,7 @@ module ActiveCypher
|
|
|
197
254
|
end
|
|
198
255
|
|
|
199
256
|
# Define writer (e.g., author=)
|
|
200
|
-
|
|
201
|
-
instance_var = "@#{name}"
|
|
202
|
-
# Load current associate lazily only if needed for comparison or deletion
|
|
203
|
-
current_associate = instance_variable_defined?(instance_var) ? instance_variable_get(instance_var) : nil
|
|
204
|
-
# Load if not cached and persisted
|
|
205
|
-
current_associate = public_send(name) if current_associate.nil? && persisted?
|
|
206
|
-
|
|
207
|
-
# No change if assigning the same object
|
|
208
|
-
return associate if associate == current_associate
|
|
209
|
-
|
|
210
|
-
raise 'Cannot modify associations on a new record' unless persisted?
|
|
211
|
-
|
|
212
|
-
# --- Delete existing relationship (if any) ---
|
|
213
|
-
if current_associate
|
|
214
|
-
del_start_alias = :a
|
|
215
|
-
del_end_alias = :b
|
|
216
|
-
del_rel_alias = :r
|
|
217
|
-
cyrel_direction = if direction == :in
|
|
218
|
-
:out
|
|
219
|
-
else
|
|
220
|
-
(direction == :both ? :both : direction)
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
del_query = Cyrel
|
|
224
|
-
.match(Cyrel.node(del_start_node.class.label_name).as(del_start_alias))
|
|
225
|
-
.match(Cyrel.node(del_end_node.class.label_name).as(del_end_alias))
|
|
226
|
-
.match(Cyrel.node(del_start_alias)
|
|
227
|
-
.rel(cyrel_direction, rel_type)
|
|
228
|
-
.as(del_rel_alias)
|
|
229
|
-
.to(del_end_alias))
|
|
230
|
-
.where(Cyrel.node_id(del_start_alias).eq(del_start_node.internal_id))
|
|
231
|
-
.where(Cyrel.node_id(del_end_alias).eq(del_end_node.internal_id))
|
|
232
|
-
.delete(del_rel_alias)
|
|
233
|
-
|
|
234
|
-
self.class.connection.execute_cypher(
|
|
235
|
-
*del_query.to_cypher,
|
|
236
|
-
'Delete Association (belongs_to)'
|
|
237
|
-
)
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
# --- Create new relationship (if associate is not nil) ---
|
|
241
|
-
if associate
|
|
242
|
-
raise ArgumentError, "Associated object must be an instance of #{target_class_name}" unless associate.is_a?(target_class_name.constantize)
|
|
243
|
-
raise "Associated object #{associate.inspect} must be persisted" unless associate.persisted?
|
|
244
|
-
|
|
245
|
-
# Determine start/end nodes for creation based on direction
|
|
246
|
-
new_start_node, new_end_node =
|
|
247
|
-
case direction
|
|
248
|
-
when :out then [self, associate]
|
|
249
|
-
when :in then [associate, self]
|
|
250
|
-
when :both then [self, associate] # choose a deterministic orientation
|
|
251
|
-
else raise ArgumentError,
|
|
252
|
-
"Direction '#{direction}' not supported for creation via '='"
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
if reflection[:relationship_class]
|
|
256
|
-
# Use Relationship Model
|
|
257
|
-
rel_model_class = reflection[:relationship_class].constantize
|
|
258
|
-
# TODO: Extract relationship properties if passed somehow (e.g., via options hash?)
|
|
259
|
-
rel_props = {}
|
|
260
|
-
relationship_instance = rel_model_class.new(rel_props, from_node: new_start_node, to_node: new_end_node)
|
|
261
|
-
relationship_instance.save # Relationship model handles Cypher generation
|
|
262
|
-
else
|
|
263
|
-
# Use direct Cypher generation
|
|
264
|
-
new_start_alias = :a
|
|
265
|
-
new_end_alias = :b
|
|
266
|
-
arrow = direction == :both ? :both : :out
|
|
267
|
-
|
|
268
|
-
create_query = Cyrel
|
|
269
|
-
.match(Cyrel.node(new_start_node.class.label_name).as(new_start_alias))
|
|
270
|
-
.match(Cyrel.node(new_end_node.class.label_name).as(new_end_alias))
|
|
271
|
-
.where(Cyrel.node_id(new_start_alias).eq(new_start_node.internal_id))
|
|
272
|
-
.where(Cyrel.node_id(new_end_alias).eq(new_end_node.internal_id))
|
|
273
|
-
.create(Cyrel.node(new_start_alias)
|
|
274
|
-
.rel(arrow, rel_type)
|
|
275
|
-
.to(new_end_alias))
|
|
276
|
-
|
|
277
|
-
self.class.connection.execute_cypher(
|
|
278
|
-
*create_query.to_cypher,
|
|
279
|
-
'Create Association (belongs_to - Direct)'
|
|
280
|
-
)
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Update the instance variable cache
|
|
285
|
-
instance_variable_set(instance_var, associate)
|
|
286
|
-
end
|
|
257
|
+
define_singular_writer(name, target_class_name, rel_type, direction, reflection)
|
|
287
258
|
|
|
288
259
|
define_build_and_create_methods(name, target_class_name)
|
|
289
260
|
end
|
|
@@ -312,45 +283,12 @@ module ActiveCypher
|
|
|
312
283
|
end
|
|
313
284
|
end
|
|
314
285
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
286
|
+
# Defines the writer (name=) for a singular association (has_one / belongs_to).
|
|
287
|
+
# Both macros build the same delete-then-create Cypher; only the log label,
|
|
288
|
+
# taken from reflection[:macro], differs.
|
|
289
|
+
def define_singular_writer(name, target_class_name, rel_type, direction, reflection)
|
|
290
|
+
macro = reflection[:macro]
|
|
320
291
|
|
|
321
|
-
# Define reader method (e.g., profile) - logic is same as belongs_to reader
|
|
322
|
-
define_method(name) do
|
|
323
|
-
instance_var = "@#{name}"
|
|
324
|
-
return instance_variable_get(instance_var) if instance_variable_defined?(instance_var)
|
|
325
|
-
|
|
326
|
-
raise ActiveCypher::PersistenceError, 'Association load attempted on unsaved record' unless persisted?
|
|
327
|
-
|
|
328
|
-
target_class = target_class_name.constantize
|
|
329
|
-
start_node_alias = :start_node
|
|
330
|
-
target_node_alias = :target_node
|
|
331
|
-
|
|
332
|
-
start_node_pattern = Cyrel.node(self.class.label_name).as(start_node_alias)
|
|
333
|
-
.where(Cyrel.node_id(start_node_alias).eq(internal_id))
|
|
334
|
-
target_node_pattern = Cyrel.node(target_class.label_name).as(target_node_alias)
|
|
335
|
-
|
|
336
|
-
rel_pattern = case direction
|
|
337
|
-
when :out
|
|
338
|
-
start_node_pattern.rel(:out, rel_type).to(target_node_pattern)
|
|
339
|
-
when :in
|
|
340
|
-
target_node_pattern.rel(:out, rel_type).to(start_node_pattern) # Reverse for Cyrel syntax
|
|
341
|
-
when :both
|
|
342
|
-
start_node_pattern.rel(:both, rel_type).to(target_node_pattern)
|
|
343
|
-
else
|
|
344
|
-
raise AssociationError, "Invalid direction: #{direction}"
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
query = Cyrel.match(rel_pattern).return(target_node_alias).limit(1)
|
|
348
|
-
|
|
349
|
-
relation = Relation.new(target_class, query)
|
|
350
|
-
instance_variable_set(instance_var, relation.first)
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
# Define writer (e.g., profile=)
|
|
354
292
|
define_method("#{name}=") do |associate|
|
|
355
293
|
instance_var = "@#{name}"
|
|
356
294
|
# Load current associate lazily only if needed for comparison or deletion
|
|
@@ -365,33 +303,15 @@ module ActiveCypher
|
|
|
365
303
|
|
|
366
304
|
# --- Delete existing relationship (if any) ---
|
|
367
305
|
if current_associate
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
del_start_alias = :a
|
|
378
|
-
del_end_alias = :b
|
|
379
|
-
del_rel_alias = :r
|
|
380
|
-
# Adjust direction for Cyrel pattern if needed
|
|
381
|
-
cyrel_direction = direction == :in ? :out : direction
|
|
382
|
-
del_query = Cyrel.match(Cyrel.node(del_start_node.class.label_name)
|
|
383
|
-
.as(del_start_alias).where(Cyrel.node_id(del_start_alias)
|
|
384
|
-
.eq(del_start_node.internal_id)))
|
|
385
|
-
.match(Cyrel.node(del_end_node.class.label_name)
|
|
386
|
-
.as(del_end_alias).where(Cyrel.node_id(del_end_alias)
|
|
387
|
-
.eq(del_end_node.internal_id)))
|
|
388
|
-
.match(Cyrel.node(del_start_alias).rel(cyrel_direction,
|
|
389
|
-
rel_type).as(del_rel_alias).to(del_end_alias))
|
|
390
|
-
.delete(del_rel_alias)
|
|
391
|
-
|
|
392
|
-
del_cypher = del_query.to_cypher
|
|
393
|
-
del_params = { start_id: del_start_node.internal_id, end_id: del_end_node.internal_id }
|
|
394
|
-
self.class.connection.execute_cypher(del_cypher, del_params, 'Delete Association (has_one)')
|
|
306
|
+
del_start_node, del_end_node = Associations.ordered_endpoints(self, current_associate, direction)
|
|
307
|
+
del_arrow = direction == :both ? :both : :out
|
|
308
|
+
del_query = Associations.match_endpoints(del_start_node, :a, del_end_node, :b)
|
|
309
|
+
.match(Associations.relationship_path(
|
|
310
|
+
Cyrel::Pattern::Node.new(:a), Cyrel::Pattern::Node.new(:b),
|
|
311
|
+
del_arrow, rel_type, rel_alias: :r
|
|
312
|
+
))
|
|
313
|
+
.delete_(:r)
|
|
314
|
+
self.class.connection.execute_cypher(*del_query.to_cypher, "Delete Association (#{macro})")
|
|
395
315
|
end
|
|
396
316
|
|
|
397
317
|
# --- Create new relationship (if associate is not nil) ---
|
|
@@ -399,125 +319,123 @@ module ActiveCypher
|
|
|
399
319
|
raise ArgumentError, "Associated object must be an instance of #{target_class_name}" unless associate.is_a?(target_class_name.constantize)
|
|
400
320
|
raise "Associated object #{associate.inspect} must be persisted" unless associate.persisted?
|
|
401
321
|
|
|
402
|
-
|
|
403
|
-
new_start_node, new_end_node = case direction
|
|
404
|
-
when :out then [self, associate]
|
|
405
|
-
when :in then [associate, self]
|
|
406
|
-
else raise ArgumentError,
|
|
407
|
-
"Direction '#{direction}' not supported for creation via '='"
|
|
408
|
-
end
|
|
322
|
+
new_start_node, new_end_node = Associations.ordered_endpoints(self, associate, direction)
|
|
409
323
|
|
|
410
324
|
if reflection[:relationship_class]
|
|
411
325
|
# Use Relationship Model
|
|
412
326
|
rel_model_class = reflection[:relationship_class].constantize
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
relationship_instance = rel_model_class.new(rel_props, from_node: new_start_node, to_node: new_end_node)
|
|
416
|
-
relationship_instance.save
|
|
327
|
+
relationship_instance = rel_model_class.new({}, from_node: new_start_node, to_node: new_end_node)
|
|
328
|
+
relationship_instance.save # Relationship model handles Cypher generation
|
|
417
329
|
else
|
|
418
330
|
# Use direct Cypher generation
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
.as(new_end_alias).where(Cyrel.node_id(new_end_alias)
|
|
426
|
-
.eq(new_end_node.internal_id)))
|
|
427
|
-
.create(Cyrel.node(new_start_alias).rel(:out, rel_type).to(new_end_alias))
|
|
428
|
-
|
|
429
|
-
create_cypher = create_query.to_cypher
|
|
430
|
-
create_params = { start_id: new_start_node.internal_id, end_id: new_end_node.internal_id }
|
|
431
|
-
self.class.connection.execute_cypher(create_cypher, create_params,
|
|
432
|
-
'Create Association (has_one - Direct)')
|
|
331
|
+
create_query = Associations.match_endpoints(new_start_node, :a, new_end_node, :b)
|
|
332
|
+
.create(Associations.relationship_path(
|
|
333
|
+
Cyrel::Pattern::Node.new(:a), Cyrel::Pattern::Node.new(:b),
|
|
334
|
+
:out, rel_type
|
|
335
|
+
))
|
|
336
|
+
self.class.connection.execute_cypher(*create_query.to_cypher, "Create Association (#{macro} - Direct)")
|
|
433
337
|
end
|
|
434
338
|
end
|
|
435
339
|
|
|
436
340
|
# Update the instance variable cache
|
|
437
341
|
instance_variable_set(instance_var, associate)
|
|
438
342
|
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def define_has_one_methods(reflection)
|
|
346
|
+
name = reflection[:name]
|
|
347
|
+
target_class_name = reflection[:class_name]
|
|
348
|
+
rel_type = reflection[:relationship]
|
|
349
|
+
direction = reflection[:direction] # :in, :out, :both
|
|
350
|
+
|
|
351
|
+
# Define reader method (e.g., profile) - logic is same as belongs_to reader
|
|
352
|
+
define_method(name) do
|
|
353
|
+
instance_var = "@#{name}"
|
|
354
|
+
return instance_variable_get(instance_var) if instance_variable_defined?(instance_var)
|
|
355
|
+
|
|
356
|
+
raise ActiveCypher::PersistenceError, 'Association load attempted on unsaved record' unless persisted?
|
|
357
|
+
|
|
358
|
+
target_class = target_class_name.constantize
|
|
359
|
+
start_alias = :start_node
|
|
360
|
+
target_alias = :target # Relation#map_results only unwraps the :n or :target alias
|
|
361
|
+
|
|
362
|
+
path = Associations.relationship_path(
|
|
363
|
+
Associations.node_pattern(self.class, start_alias),
|
|
364
|
+
Associations.node_pattern(target_class, target_alias),
|
|
365
|
+
direction, rel_type
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
query = Cyrel::Query.new
|
|
369
|
+
.match(path)
|
|
370
|
+
.where(Cyrel.node_id(start_alias).eq(internal_id))
|
|
371
|
+
.return_(target_alias)
|
|
372
|
+
.limit(1)
|
|
373
|
+
|
|
374
|
+
relation = Relation.new(target_class, query)
|
|
375
|
+
instance_variable_set(instance_var, relation.first)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Define writer (e.g., profile=)
|
|
379
|
+
define_singular_writer(name, target_class_name, rel_type, direction, reflection)
|
|
439
380
|
|
|
440
381
|
define_build_and_create_methods(name, target_class_name)
|
|
441
382
|
end
|
|
442
|
-
end
|
|
443
383
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
define_method(name) do
|
|
452
|
-
raise ActiveCypher::PersistenceError, 'Association load attempted on unsaved record' unless persisted?
|
|
453
|
-
|
|
454
|
-
# 1. Get reflection for the intermediate association (e.g., :friendships)
|
|
455
|
-
through_reflection = self.class._reflections[through_association_name]
|
|
456
|
-
unless through_reflection
|
|
457
|
-
raise ArgumentError,
|
|
458
|
-
"Could not find association '#{through_association_name}' specified in :through option for '#{name}'"
|
|
459
|
-
end
|
|
384
|
+
# Defines the reader method for a has_many :through association.
|
|
385
|
+
# Because sometimes you want to join tables, but with extra steps.
|
|
386
|
+
def define_has_many_through_reader(reflection)
|
|
387
|
+
name = reflection[:name]
|
|
388
|
+
through_association_name = reflection[:through]
|
|
389
|
+
source_association_name = reflection[:source] || name # Default source is same name on intermediate model
|
|
460
390
|
|
|
461
|
-
|
|
391
|
+
define_method(name) do
|
|
392
|
+
raise ActiveCypher::PersistenceError, 'Association load attempted on unsaved record' unless persisted?
|
|
462
393
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
394
|
+
# 1. Get reflection for the intermediate association (e.g., :friendships)
|
|
395
|
+
through_reflection = self.class._reflections[through_association_name]
|
|
396
|
+
unless through_reflection
|
|
397
|
+
raise ArgumentError,
|
|
398
|
+
"Could not find association '#{through_association_name}' specified in :through option for '#{name}'"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
intermediate_class = through_reflection[:class_name].constantize
|
|
402
|
+
|
|
403
|
+
# 2. Get reflection for the source association on the intermediate model (e.g., :to_node on Friendship)
|
|
404
|
+
# Note: This assumes the intermediate model also uses ActiveCypher::Associations
|
|
405
|
+
source_reflection = intermediate_class._reflections[source_association_name]
|
|
406
|
+
unless source_reflection
|
|
407
|
+
raise ArgumentError,
|
|
408
|
+
"Could not find association '#{source_association_name}' specified as :source (or inferred) on '#{intermediate_class.name}' for '#{name}'"
|
|
409
|
+
end
|
|
470
410
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
source_direction = source_reflection[:direction]
|
|
500
|
-
|
|
501
|
-
second_hop_pattern = case source_direction
|
|
502
|
-
when :out then intermediate_node_pattern.rel(:out,
|
|
503
|
-
source_rel_type).to(final_target_node_pattern)
|
|
504
|
-
when :in then final_target_node_pattern.rel(:out,
|
|
505
|
-
source_rel_type).to(intermediate_node_pattern)
|
|
506
|
-
when :both then intermediate_node_pattern.rel(:both,
|
|
507
|
-
source_rel_type).to(final_target_node_pattern)
|
|
508
|
-
else raise ArgumentError, "Invalid direction in source_reflection: #{source_direction}"
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
# Combine patterns and return final target
|
|
512
|
-
# Assuming Cyrel allows chaining matches or building complex patterns
|
|
513
|
-
# This might need adjustment based on Cyrel's exact path-building API
|
|
514
|
-
query = Cyrel.match(first_hop_pattern)
|
|
515
|
-
.match(second_hop_pattern) # Assumes .match adds to the pattern
|
|
516
|
-
.return(final_target_node_alias)
|
|
517
|
-
# TODO: Add DISTINCT if needed? .return(Cyrel.distinct(final_target_node_alias))
|
|
518
|
-
|
|
519
|
-
# Return a Relation scoped to the final target class
|
|
520
|
-
Relation.new(final_target_class, query)
|
|
411
|
+
final_target_class = source_reflection[:class_name].constantize
|
|
412
|
+
|
|
413
|
+
# 3. Build the multi-hop Cyrel query.
|
|
414
|
+
# Because why settle for one hop when you can have two and still not get what you want?
|
|
415
|
+
start_alias = :start_node
|
|
416
|
+
intermediate_alias = :intermediate_node
|
|
417
|
+
final_target_alias = :target # Relation#map_results only unwraps the :n or :target alias
|
|
418
|
+
|
|
419
|
+
# The intermediate node pattern is shared by both hops so the aliases line up:
|
|
420
|
+
# MATCH (start)-[:THROUGH]->(intermediate) MATCH (intermediate)-[:SOURCE]->(final)
|
|
421
|
+
start_node = Associations.node_pattern(self.class, start_alias)
|
|
422
|
+
intermediate_node = Associations.node_pattern(intermediate_class, intermediate_alias)
|
|
423
|
+
final_target_node = Associations.node_pattern(final_target_class, final_target_alias)
|
|
424
|
+
|
|
425
|
+
first_hop = Associations.relationship_path(start_node, intermediate_node,
|
|
426
|
+
through_reflection[:direction], through_reflection[:relationship])
|
|
427
|
+
second_hop = Associations.relationship_path(intermediate_node, final_target_node,
|
|
428
|
+
source_reflection[:direction], source_reflection[:relationship])
|
|
429
|
+
|
|
430
|
+
query = Cyrel::Query.new
|
|
431
|
+
.match(first_hop)
|
|
432
|
+
.match(second_hop)
|
|
433
|
+
.where(Cyrel.node_id(start_alias).eq(internal_id))
|
|
434
|
+
.return_(final_target_alias)
|
|
435
|
+
|
|
436
|
+
# Return a Relation scoped to the final target class
|
|
437
|
+
Relation.new(final_target_class, query)
|
|
438
|
+
end
|
|
521
439
|
end
|
|
522
440
|
end
|
|
523
441
|
end
|
|
@@ -638,57 +638,37 @@ module ActiveCypher
|
|
|
638
638
|
|
|
639
639
|
case @server_agent
|
|
640
640
|
when %r{^Neo4j/(\d+\.\d+(?:\.\d+)?)}i
|
|
641
|
-
|
|
642
|
-
parts = version_string.split('.').map(&:to_i)
|
|
643
|
-
{
|
|
644
|
-
database_type: :neo4j,
|
|
645
|
-
version: version_string,
|
|
646
|
-
major: parts[0] || 0,
|
|
647
|
-
minor: parts[1] || 0,
|
|
648
|
-
patch: parts[2] || 0
|
|
649
|
-
}
|
|
641
|
+
version_info_from(:neo4j, ::Regexp.last_match(1))
|
|
650
642
|
when %r{^Memgraph/(\d+\.\d+(?:\.\d+)?)}i
|
|
651
|
-
|
|
652
|
-
parts = version_string.split('.').map(&:to_i)
|
|
653
|
-
{
|
|
654
|
-
database_type: :memgraph,
|
|
655
|
-
version: version_string,
|
|
656
|
-
major: parts[0] || 0,
|
|
657
|
-
minor: parts[1] || 0,
|
|
658
|
-
patch: parts[2] || 0
|
|
659
|
-
}
|
|
643
|
+
version_info_from(:memgraph, ::Regexp.last_match(1))
|
|
660
644
|
when /.*Memgraph/i
|
|
661
645
|
# Handle Memgraph server agent: "Neo4j/v5.11.0 compatible graph database server - Memgraph"
|
|
662
646
|
if @server_agent =~ %r{Neo4j/v(\d+\.\d+(?:\.\d+)?)}
|
|
663
|
-
|
|
664
|
-
parts = version_string.split('.').map(&:to_i)
|
|
665
|
-
{
|
|
666
|
-
database_type: :memgraph,
|
|
667
|
-
version: version_string,
|
|
668
|
-
major: parts[0] || 0,
|
|
669
|
-
minor: parts[1] || 0,
|
|
670
|
-
patch: parts[2] || 0
|
|
671
|
-
}
|
|
647
|
+
version_info_from(:memgraph, ::Regexp.last_match(1))
|
|
672
648
|
else
|
|
673
|
-
{
|
|
674
|
-
database_type: :memgraph,
|
|
675
|
-
version: 'unknown',
|
|
676
|
-
major: 0,
|
|
677
|
-
minor: 0,
|
|
678
|
-
patch: 0
|
|
679
|
-
}
|
|
649
|
+
{ database_type: :memgraph, version: 'unknown', major: 0, minor: 0, patch: 0 }
|
|
680
650
|
end
|
|
681
651
|
else
|
|
682
|
-
{
|
|
683
|
-
database_type: :unknown,
|
|
684
|
-
version: @server_agent,
|
|
685
|
-
major: 0,
|
|
686
|
-
minor: 0,
|
|
687
|
-
patch: 0
|
|
688
|
-
}
|
|
652
|
+
{ database_type: :unknown, version: @server_agent, major: 0, minor: 0, patch: 0 }
|
|
689
653
|
end
|
|
690
654
|
end
|
|
691
655
|
|
|
656
|
+
# Builds a version info hash from a dotted version string.
|
|
657
|
+
#
|
|
658
|
+
# @param database_type [Symbol] :neo4j or :memgraph
|
|
659
|
+
# @param version_string [String] dotted version, e.g. "5.11.0"
|
|
660
|
+
# @return [Hash] version information
|
|
661
|
+
def version_info_from(database_type, version_string)
|
|
662
|
+
parts = version_string.split('.').map(&:to_i)
|
|
663
|
+
{
|
|
664
|
+
database_type: database_type,
|
|
665
|
+
version: version_string,
|
|
666
|
+
major: parts[0] || 0,
|
|
667
|
+
minor: parts[1] || 0,
|
|
668
|
+
patch: parts[2] || 0
|
|
669
|
+
}
|
|
670
|
+
end
|
|
671
|
+
|
|
692
672
|
# Returns default version info when server_agent is not available.
|
|
693
673
|
#
|
|
694
674
|
# @return [Hash] default version information
|
|
@@ -159,10 +159,7 @@ module ActiveCypher
|
|
|
159
159
|
# End of results
|
|
160
160
|
break
|
|
161
161
|
when Bolt::Messaging::Failure
|
|
162
|
-
|
|
163
|
-
message = msg.metadata['message']
|
|
164
|
-
connection.reset!
|
|
165
|
-
raise QueryError, "Query failed: #{code} - #{message}"
|
|
162
|
+
raise_query_failure(msg)
|
|
166
163
|
else
|
|
167
164
|
raise ProtocolError, "Unexpected response during PULL: #{msg.class}"
|
|
168
165
|
end
|
|
@@ -170,16 +167,21 @@ module ActiveCypher
|
|
|
170
167
|
|
|
171
168
|
rows
|
|
172
169
|
when Bolt::Messaging::Failure
|
|
173
|
-
|
|
174
|
-
message = run_response.metadata['message']
|
|
175
|
-
connection.reset!
|
|
176
|
-
raise QueryError, "Query failed: #{code} - #{message}"
|
|
170
|
+
raise_query_failure(run_response)
|
|
177
171
|
else
|
|
178
172
|
raise ProtocolError, "Unexpected response to RUN: #{run_response.class}"
|
|
179
173
|
end
|
|
180
174
|
end
|
|
181
175
|
end
|
|
182
176
|
|
|
177
|
+
# Reset the connection and raise a QueryError for a Bolt Failure message.
|
|
178
|
+
# @param failure [Bolt::Messaging::Failure]
|
|
179
|
+
# @raise [QueryError]
|
|
180
|
+
def raise_query_failure(failure)
|
|
181
|
+
connection.reset!
|
|
182
|
+
raise QueryError, "Query failed: #{failure.metadata['code']} - #{failure.metadata['message']}"
|
|
183
|
+
end
|
|
184
|
+
|
|
183
185
|
# Memgraph defaults to **implicit auto‑commit** transactions
|
|
184
186
|
# so we simply run the Cypher and return the rows.
|
|
185
187
|
def execute_cypher(cypher, params = {}, ctx = 'Query')
|
|
@@ -276,7 +278,7 @@ module ActiveCypher
|
|
|
276
278
|
module Persistence
|
|
277
279
|
include PersistenceMethods
|
|
278
280
|
|
|
279
|
-
module_function :create_record, :update_record, :destroy_record
|
|
281
|
+
module_function :create_record, :update_record, :destroy_record, :node_id_expr
|
|
280
282
|
end
|
|
281
283
|
|
|
282
284
|
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
|
|
@@ -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/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
|
data/lib/cyrel/query.rb
CHANGED
|
@@ -213,40 +213,8 @@ module Cyrel
|
|
|
213
213
|
# @return [self]
|
|
214
214
|
# Because sometimes you just want to change everything and pretend it was always that way.
|
|
215
215
|
def set(assignments)
|
|
216
|
-
#
|
|
217
|
-
processed_assignments =
|
|
218
|
-
when Hash
|
|
219
|
-
assignments.flat_map do |key, value|
|
|
220
|
-
case key
|
|
221
|
-
when Expression::PropertyAccess
|
|
222
|
-
# SET n.prop = value
|
|
223
|
-
[[:property, key, Expression.coerce(value)]]
|
|
224
|
-
when Symbol, String
|
|
225
|
-
# SET n = properties
|
|
226
|
-
raise ArgumentError, 'Value for variable assignment must be a Hash' unless value.is_a?(Hash)
|
|
227
|
-
|
|
228
|
-
[[:variable_properties, key.to_sym, Expression.coerce(value), :assign]]
|
|
229
|
-
when Cyrel::Plus
|
|
230
|
-
# SET n += properties
|
|
231
|
-
raise ArgumentError, 'Value for variable assignment must be a Hash' unless value.is_a?(Hash)
|
|
232
|
-
|
|
233
|
-
[[:variable_properties, key.variable.to_sym, Expression.coerce(value), :merge]]
|
|
234
|
-
else
|
|
235
|
-
raise ArgumentError, "Invalid key type in SET assignments: #{key.class}"
|
|
236
|
-
end
|
|
237
|
-
end
|
|
238
|
-
when Array
|
|
239
|
-
assignments.map do |item|
|
|
240
|
-
unless item.is_a?(Array) && item.length == 2
|
|
241
|
-
raise ArgumentError, "Invalid label assignment format. Expected [[:variable, 'Label'], ...], got #{item.inspect}"
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# SET n:Label
|
|
245
|
-
[:label, item[0].to_sym, item[1]]
|
|
246
|
-
end
|
|
247
|
-
else
|
|
248
|
-
raise ArgumentError, "Invalid assignments type: #{assignments.class}"
|
|
249
|
-
end
|
|
216
|
+
# Normalize assignments using the shared Set-clause logic
|
|
217
|
+
processed_assignments = Clause::Set.normalize_assignments(assignments)
|
|
250
218
|
|
|
251
219
|
set_node = AST::SetNode.new(processed_assignments)
|
|
252
220
|
ast_clause = AST::ClauseAdapter.new(set_node)
|