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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56141506fc5e406952855cadb4f82b23c33e74ba37f9a08c16122e185fb86b15
4
- data.tar.gz: f36c6bf8fcd4d5676a32db4d3c0089c570c51bc3789bc0a1a0277d8fa34e3792
3
+ metadata.gz: 4b0f2c80d0e44691d856e7e00f6fc34c51dc5e5661c47355ac66a3053896520f
4
+ data.tar.gz: 124e0126aa949bf7b506b22e364aba9b999eddbdca3f6209dd8fda6bb624314f
5
5
  SHA512:
6
- metadata.gz: 94aa83fe31cf9f3650697ccb69a7907d15f4b1ef7b95d7372a7d4a3defd9db7bea7d01870692b35067ac56190470b4a36b1979d1348e7b84dc88f9abf1ec865d
7
- data.tar.gz: 56a1c811bbda6765296e9990c7dbf91c12c0afc5afeb9705ee0066efa7d175dc178736eda020e4827928b2e8e3a5a58feaf57008fb5966fcf246187baa6648a5
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
- a_alias = :a
175
- b_alias = :b
176
-
177
- # plain node patterns (no mutating helpers)
178
- a_node = Cyrel::Pattern::Node.new(a_alias, labels: self.class.label_name)
179
- b_node = Cyrel::Pattern::Node.new(b_alias, labels: target_class.label_name)
180
-
181
- # explicit relationship node – mirrors Arel::Nodes::Join construction
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(a_alias).eq(internal_id))
192
- .return_(b_alias)
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
- define_method("#{name}=") do |associate|
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
- def define_has_one_methods(reflection)
316
- name = reflection[:name]
317
- target_class_name = reflection[:class_name]
318
- rel_type = reflection[:relationship]
319
- direction = reflection[:direction] # :in, :out, :both
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
- # Determine start/end nodes for deletion based on direction
369
- del_start_node, del_end_node = case direction
370
- when :out then [self, current_associate]
371
- when :in then [current_associate, self]
372
- else raise ArgumentError,
373
- "Direction '#{direction}' not supported for deletion via '='"
374
- end
375
-
376
- # Build Cyrel query to delete the relationship
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
- # Determine start/end nodes for creation based on direction
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
- # TODO: Extract relationship properties if passed somehow
414
- rel_props = {}
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
- new_start_alias = :a
420
- new_end_alias = :b
421
- create_query = Cyrel.match(Cyrel.node(new_start_node.class.label_name)
422
- .as(new_start_alias).where(Cyrel.node_id(new_start_alias)
423
- .eq(new_start_node.internal_id)))
424
- .match(Cyrel.node(new_end_node.class.label_name)
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
- # Defines the reader method for a has_many :through association.
445
- # Because sometimes you want to join tables, but with extra steps.
446
- def self.define_has_many_through_reader(reflection)
447
- name = reflection[:name]
448
- through_association_name = reflection[:through]
449
- source_association_name = reflection[:source] || name # Default source is same name on intermediate model
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
- intermediate_class = through_reflection[:class_name].constantize
391
+ define_method(name) do
392
+ raise ActiveCypher::PersistenceError, 'Association load attempted on unsaved record' unless persisted?
462
393
 
463
- # 2. Get reflection for the source association on the intermediate model (e.g., :to_node on Friendship)
464
- # Note: This assumes the intermediate model also uses ActiveCypher::Associations
465
- source_reflection = intermediate_class._reflections[source_association_name]
466
- unless source_reflection
467
- raise ArgumentError,
468
- "Could not find association '#{source_association_name}' specified as :source (or inferred) on '#{intermediate_class.name}' for '#{name}'"
469
- end
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
- final_target_class = source_reflection[:class_name].constantize
472
-
473
- # 3. Build the multi-hop Cyrel query.
474
- # Because why settle for one hop when you can have two and still not get what you want?
475
- start_node_alias = :start_node
476
- intermediate_node_alias = :intermediate_node
477
- final_target_node_alias = :final_target
478
-
479
- # Start node pattern
480
- start_node_pattern = Cyrel.node(self.class.label_name).as(start_node_alias)
481
- .where(Cyrel.node_id(start_node_alias).eq(internal_id))
482
-
483
- # Intermediate node pattern (based on through_reflection)
484
- intermediate_node_pattern = Cyrel.node(intermediate_class.label_name).as(intermediate_node_alias)
485
- through_rel_type = through_reflection[:relationship]
486
- through_direction = through_reflection[:direction]
487
-
488
- first_hop_pattern = case through_direction
489
- when :out then start_node_pattern.rel(:out, through_rel_type).to(intermediate_node_pattern)
490
- when :in then intermediate_node_pattern.rel(:out, through_rel_type).to(start_node_pattern)
491
- when :both then start_node_pattern.rel(:both,
492
- through_rel_type).to(intermediate_node_pattern)
493
- else raise ArgumentError, "Invalid direction in through_reflection: #{through_direction}"
494
- end
495
-
496
- # Final target node pattern (based on source_reflection)
497
- final_target_node_pattern = Cyrel.node(final_target_class.label_name).as(final_target_node_alias)
498
- source_rel_type = source_reflection[:relationship]
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
- version_string = ::Regexp.last_match(1)
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
- version_string = ::Regexp.last_match(1)
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
- version_string = ::Regexp.last_match(1)
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
- code = msg.metadata['code']
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
- code = run_response.metadata['code']
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 = if adapter.id_function == 'elementId'
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 = if adapter.id_function == 'elementId'
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 = if adapter.id_function == 'elementId'
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.each do |conn|
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.clear_all] Failed to clear connection #{conn.inspect}: #{e.class}: #{e.message}"
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[from_class]
125
- to_conn_details = model_connections[to_class]
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
- if composite && props.size > 1
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
- if composite && props.size > 1
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)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.15.3'
4
+ VERSION = '0.15.4'
5
5
 
6
6
  def self.gem_version
7
7
  Gem::Version.new VERSION
@@ -84,10 +84,7 @@ module Cyrel
84
84
  @output << 'RETURN '
85
85
  @output << 'DISTINCT ' if node.distinct
86
86
 
87
- node.items.each_with_index do |item, index|
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.each_with_index do |item, index|
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
- # Other expressions
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.split("\n").map { |line| " #{line}" }.join("\n")
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.split("\n").map { |line| " #{line}" }.join("\n") unless clause_output.blank?
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
- # Array literal - convert to parameter
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
- # Other expressions
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
@@ -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 = process_assignments(assignments)
20
+ @assignments = self.class.normalize_assignments(assignments)
21
21
  end
22
22
 
23
- # Renders the SET clause.
24
- # @param query [Cyrel::Query] The query object for rendering expressions.
25
- # @return [String, nil] The Cypher string fragment, or nil if no assignments exist.
26
- def render(query)
27
- return nil if @assignments.empty?
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 && item[0].is_a?(Symbol) && item[1].is_a?(String)
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
- # Process assignments similar to existing Set clause
217
- processed_assignments = case 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)
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.15.3
4
+ version: 0.15.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih