activecypher 0.15.3 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56141506fc5e406952855cadb4f82b23c33e74ba37f9a08c16122e185fb86b15
4
- data.tar.gz: f36c6bf8fcd4d5676a32db4d3c0089c570c51bc3789bc0a1a0277d8fa34e3792
3
+ metadata.gz: bd37ad6691e4ec34e421cde2caa055e8379d9d15e0b6df558b60fdcc76f6bd6f
4
+ data.tar.gz: 7284b02415749cab48ec434dd689d7a4c9bec23a3bdf62f6636e32f0bb99501b
5
5
  SHA512:
6
- metadata.gz: 94aa83fe31cf9f3650697ccb69a7907d15f4b1ef7b95d7372a7d4a3defd9db7bea7d01870692b35067ac56190470b4a36b1979d1348e7b84dc88f9abf1ec865d
7
- data.tar.gz: 56a1c811bbda6765296e9990c7dbf91c12c0afc5afeb9705ee0066efa7d175dc178736eda020e4827928b2e8e3a5a58feaf57008fb5966fcf246187baa6648a5
6
+ metadata.gz: befe8d23ced99f81ab0d01195000aa22e1c9e35f10a0268de8a3627010721720b223d3ad02b1116d9487196cdec2408a7e4079325284630afec1fb684f42a295
7
+ data.tar.gz: 53e90b852ecd80cf949c6df0d0c5ed5589e52ba043326f84e2f52206ee7f22de637522eb5770d1168396a34d3d9679db3abea3462bf2395ac185f2b81e6c53b2
@@ -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
@@ -77,7 +77,7 @@ module ActiveCypher
77
77
  @state = :failed
78
78
  code = msg.metadata['code']
79
79
  message = msg.metadata['message']
80
- raise QueryError, "Query execution failed: #{code} - #{message}"
80
+ raise query_error_for(code), "Query execution failed: #{code} - #{message}"
81
81
  else
82
82
  raise ProtocolError, "Unexpected message type: #{msg.class}"
83
83
  end
@@ -90,7 +90,7 @@ module ActiveCypher
90
90
  @state = :failed
91
91
  code = response.metadata['code']
92
92
  message = response.metadata['message']
93
- raise QueryError, "Query execution failed: #{code} - #{message}"
93
+ raise query_error_for(code), "Query execution failed: #{code} - #{message}"
94
94
  else
95
95
  raise ProtocolError, "Unexpected response to RUN: #{response.class}"
96
96
  end
@@ -204,6 +204,18 @@ module ActiveCypher
204
204
  def failed?
205
205
  @state == :failed
206
206
  end
207
+
208
+ private
209
+
210
+ # Server error code -> exception class. Substring match covers both
211
+ # Neo4j and Memgraph spellings of the same codes.
212
+ def query_error_for(code)
213
+ c = code.to_s
214
+ return ConstraintError if c.include?('Constraint')
215
+ return TransientError if c.include?('TransientError')
216
+
217
+ QueryError
218
+ end
207
219
  end
208
220
  end
209
221
  end