activecypher 0.15.3 → 0.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/active_cypher/associations.rb +185 -267
- data/lib/active_cypher/bolt/connection.rb +21 -41
- data/lib/active_cypher/bolt/transaction.rb +14 -2
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +33 -10
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +1 -1
- data/lib/active_cypher/connection_adapters/persistence_methods.rb +12 -15
- data/lib/active_cypher/connection_pool.rb +41 -20
- data/lib/active_cypher/fixtures.rb +34 -37
- data/lib/active_cypher/migration.rb +18 -15
- data/lib/active_cypher/rails_lens_ext/annotator.rb +15 -16
- data/lib/active_cypher/version.rb +1 -1
- data/lib/activecypher.rb +8 -0
- data/lib/cyrel/ast/compiler.rb +37 -28
- data/lib/cyrel/clause/set.rb +32 -28
- data/lib/cyrel/query.rb +2 -34
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bd37ad6691e4ec34e421cde2caa055e8379d9d15e0b6df558b60fdcc76f6bd6f
|
|
4
|
+
data.tar.gz: 7284b02415749cab48ec434dd689d7a4c9bec23a3bdf62f6636e32f0bb99501b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
@@ -77,7 +77,7 @@ module ActiveCypher
|
|
|
77
77
|
@state = :failed
|
|
78
78
|
code = msg.metadata['code']
|
|
79
79
|
message = msg.metadata['message']
|
|
80
|
-
raise
|
|
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
|
|
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
|