activecypher 0.0.0 → 0.3.0

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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/associations/collection_proxy.rb +144 -0
  3. data/lib/active_cypher/associations.rb +537 -0
  4. data/lib/active_cypher/base.rb +47 -0
  5. data/lib/active_cypher/bolt/connection.rb +525 -0
  6. data/lib/active_cypher/bolt/driver.rb +144 -0
  7. data/lib/active_cypher/bolt/handlers.rb +10 -0
  8. data/lib/active_cypher/bolt/message_reader.rb +100 -0
  9. data/lib/active_cypher/bolt/message_writer.rb +53 -0
  10. data/lib/active_cypher/bolt/messaging.rb +307 -0
  11. data/lib/active_cypher/bolt/packstream.rb +319 -0
  12. data/lib/active_cypher/bolt/result.rb +82 -0
  13. data/lib/active_cypher/bolt/session.rb +201 -0
  14. data/lib/active_cypher/bolt/transaction.rb +211 -0
  15. data/lib/active_cypher/bolt/version_encoding.rb +41 -0
  16. data/lib/active_cypher/bolt.rb +7 -0
  17. data/lib/active_cypher/connection_adapters/abstract_adapter.rb +75 -0
  18. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +178 -0
  19. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
  20. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +58 -0
  21. data/lib/active_cypher/connection_factory.rb +130 -0
  22. data/lib/active_cypher/connection_handler.rb +9 -0
  23. data/lib/active_cypher/connection_pool.rb +123 -0
  24. data/lib/active_cypher/connection_url_resolver.rb +137 -0
  25. data/lib/active_cypher/cypher_config.rb +61 -0
  26. data/lib/active_cypher/generators/install_generator.rb +23 -0
  27. data/lib/active_cypher/generators/node_generator.rb +32 -0
  28. data/lib/active_cypher/generators/relationship_generator.rb +33 -0
  29. data/lib/active_cypher/generators/templates/application_graph_node.rb +5 -0
  30. data/lib/active_cypher/generators/templates/application_graph_relationship.rb +5 -0
  31. data/lib/active_cypher/generators/templates/cypher_databases.yml +16 -0
  32. data/lib/active_cypher/generators/templates/node.rb.erb +10 -0
  33. data/lib/active_cypher/generators/templates/relationship.rb.erb +11 -0
  34. data/lib/active_cypher/logging.rb +44 -0
  35. data/lib/active_cypher/model/abstract.rb +87 -0
  36. data/lib/active_cypher/model/attributes.rb +24 -0
  37. data/lib/active_cypher/model/callbacks.rb +44 -0
  38. data/lib/active_cypher/model/connection_handling.rb +76 -0
  39. data/lib/active_cypher/model/connection_owner.rb +50 -0
  40. data/lib/active_cypher/model/core.rb +45 -0
  41. data/lib/active_cypher/model/countable.rb +30 -0
  42. data/lib/active_cypher/model/destruction.rb +49 -0
  43. data/lib/active_cypher/model/inspectable.rb +28 -0
  44. data/lib/active_cypher/model/persistence.rb +182 -0
  45. data/lib/active_cypher/model/querying.rb +67 -0
  46. data/lib/active_cypher/railtie.rb +34 -0
  47. data/lib/active_cypher/relation.rb +190 -0
  48. data/lib/active_cypher/relationship.rb +233 -0
  49. data/lib/active_cypher/runtime_registry.rb +8 -0
  50. data/lib/active_cypher/scoping.rb +97 -0
  51. data/lib/active_cypher/utils/logger.rb +100 -0
  52. data/lib/active_cypher/version.rb +5 -0
  53. data/lib/activecypher.rb +108 -0
  54. data/lib/cyrel/call_procedure.rb +29 -0
  55. data/lib/cyrel/clause/call.rb +46 -0
  56. data/lib/cyrel/clause/call_subquery.rb +40 -0
  57. data/lib/cyrel/clause/create.rb +33 -0
  58. data/lib/cyrel/clause/delete.rb +41 -0
  59. data/lib/cyrel/clause/limit.rb +33 -0
  60. data/lib/cyrel/clause/match.rb +40 -0
  61. data/lib/cyrel/clause/merge.rb +34 -0
  62. data/lib/cyrel/clause/order_by.rb +78 -0
  63. data/lib/cyrel/clause/remove.rb +75 -0
  64. data/lib/cyrel/clause/return.rb +90 -0
  65. data/lib/cyrel/clause/set.rb +97 -0
  66. data/lib/cyrel/clause/skip.rb +34 -0
  67. data/lib/cyrel/clause/where.rb +42 -0
  68. data/lib/cyrel/clause/with.rb +94 -0
  69. data/lib/cyrel/clause.rb +25 -0
  70. data/lib/cyrel/direction.rb +18 -0
  71. data/lib/cyrel/expression/alias.rb +27 -0
  72. data/lib/cyrel/expression/base.rb +101 -0
  73. data/lib/cyrel/expression/case.rb +45 -0
  74. data/lib/cyrel/expression/comparison.rb +60 -0
  75. data/lib/cyrel/expression/exists.rb +42 -0
  76. data/lib/cyrel/expression/function_call.rb +57 -0
  77. data/lib/cyrel/expression/literal.rb +33 -0
  78. data/lib/cyrel/expression/logical.rb +38 -0
  79. data/lib/cyrel/expression/operator.rb +27 -0
  80. data/lib/cyrel/expression/pattern_comprehension.rb +44 -0
  81. data/lib/cyrel/expression/property_access.rb +25 -0
  82. data/lib/cyrel/expression.rb +56 -0
  83. data/lib/cyrel/functions.rb +116 -0
  84. data/lib/cyrel/node.rb +397 -0
  85. data/lib/cyrel/parameterizable.rb +20 -0
  86. data/lib/cyrel/pattern/node.rb +66 -0
  87. data/lib/cyrel/pattern/path.rb +41 -0
  88. data/lib/cyrel/pattern/relationship.rb +74 -0
  89. data/lib/cyrel/pattern.rb +8 -0
  90. data/lib/cyrel/query.rb +497 -0
  91. data/lib/cyrel/return_only.rb +26 -0
  92. data/lib/cyrel/types/hash_type.rb +22 -0
  93. data/lib/cyrel/types/symbol_type.rb +13 -0
  94. data/lib/cyrel.rb +72 -0
  95. data/sig/activecypher.rbs +4 -0
  96. metadata +172 -10
@@ -0,0 +1,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'active_support/core_ext/string/inflections' # for camelize, singularize etc.
5
+
6
+ module ActiveCypher
7
+ # Module to handle association definitions (has_many, belongs_to, etc.)
8
+ # for ActiveCypher models.
9
+ # @note Because every DSL wants to be ActiveRecord when it grows up.
10
+ module Associations
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ # Storage for association reflection metadata on the class
15
+ # Because nothing says "enterprise" like a hash of hashes.
16
+ class_attribute :_reflections, instance_writer: false, default: {}
17
+ end
18
+
19
+ class_methods do
20
+ # Defines a one-to-many association.
21
+ #
22
+ # @param name [Symbol] The name of the association (e.g., :posts).
23
+ # @param options [Hash] Configuration options:
24
+ # - :class_name [String] The class name of the associated model (e.g., "Post").
25
+ # Defaults to the camelized singular name.
26
+ # - :relationship [String] The type of the graph relationship (e.g., "WROTE").
27
+ # Defaults to the upcased association name.
28
+ # - :direction [:in, :out, :both] The direction of the relationship relative to this model.
29
+ # Defaults to :out for has_many.
30
+ # - (Other options like :dependent, :foreign_key might be added later)
31
+ # @note Because every object needs friends, even if they're just proxies.
32
+ def has_many(name, options = {})
33
+ reflection = build_reflection(:has_many, name, options)
34
+ add_reflection(name, reflection)
35
+
36
+ if options[:through]
37
+ define_has_many_through_reader(reflection)
38
+ # TODO: Define writers/helpers for :through if applicable (often read-only, like your hopes)
39
+ else
40
+ define_has_many_methods(reflection)
41
+ end
42
+ end
43
+
44
+ # Defines a many-to-one or one-to-one association where this model
45
+ # is considered the "child" or holder of the reference.
46
+ #
47
+ # @param name [Symbol] The name of the association (e.g., :author).
48
+ # @param options [Hash] Configuration options:
49
+ # - :class_name [String] The class name of the associated model (e.g., "Person").
50
+ # Defaults to the camelized name.
51
+ # - :relationship [String] The type of the graph relationship (e.g., "WROTE").
52
+ # Defaults to the upcased association name.
53
+ # - :direction [:in, :out, :both] The direction of the relationship relative to this model.
54
+ # Defaults to :out for belongs_to (meaning this node points OUT to the parent).
55
+ # - (Other options)
56
+ # @note Because sometimes you just want to belong... to something, anything.
57
+ def belongs_to(name, options = {})
58
+ reflection = build_reflection(:belongs_to, name, options)
59
+ add_reflection(name, reflection)
60
+ define_belongs_to_methods(reflection)
61
+ end
62
+
63
+ # Defines a one-to-one association where this model is considered the "parent".
64
+ #
65
+ # @param name [Symbol] The name of the association (e.g., :profile).
66
+ # @param options [Hash] Configuration options:
67
+ # - :class_name [String] The class name of the associated model (e.g., "UserProfile").
68
+ # Defaults to the camelized name.
69
+ # - :relationship [String] The type of the graph relationship (e.g., "HAS_PROFILE").
70
+ # Defaults to the upcased association name.
71
+ # - :direction [:in, :out, :both] The direction of the relationship relative to this model.
72
+ # Defaults to :out for has_one.
73
+ # - (Other options)
74
+ # @note Because every object deserves a child it can ignore.
75
+ def has_one(name, options = {})
76
+ reflection = build_reflection(:has_one, name, options)
77
+ add_reflection(name, reflection)
78
+ define_has_one_methods(reflection)
79
+ end
80
+
81
+ private
82
+
83
+ # Helper to build a reflection object (simple hash for now)
84
+ # @note One day this will be a class. Today is not that day.
85
+ def build_reflection(macro, name, options)
86
+ options[:macro] = macro
87
+ options[:name] = name
88
+ options[:class_name] ||= name.to_s.camelize
89
+ options[:relationship] ||= name.to_s.upcase
90
+ options[:direction] ||= :out
91
+ options[:relationship_class] = options[:relationship_class]&.to_s # Store as string if present
92
+ # TODO: Introduce a proper Reflection class later if needed. Or not.
93
+ options
94
+ end
95
+
96
+ # Stores the reflection metadata
97
+ # Because nothing says "scalable" like a class variable.
98
+ def add_reflection(name, reflection)
99
+ self._reflections = _reflections.merge(name => reflection)
100
+ end
101
+
102
+ # --- Method Definition Helpers (Placeholders) ---
103
+
104
+ # Defines the actual has_many reader method.
105
+ # Because what you really wanted was a proxy for disappointment.
106
+ def define_has_many_methods(reflection)
107
+ name = reflection[:name] # e.g. :hobbies
108
+ target_class_name = reflection[:class_name] # "HobbyNode"
109
+ rel_type = reflection[:relationship] # "ENJOYS"
110
+ direction = reflection[:direction] # :out, :in, :both
111
+
112
+ define_method(name) do
113
+ unless persisted?
114
+ raise ActiveCypher::PersistenceError,
115
+ 'Association load attempted on unsaved record'
116
+ end
117
+
118
+ # Resolve the target node class
119
+ target_class = target_class_name.constantize
120
+ a_alias = :start
121
+ b_alias = :target
122
+
123
+ # Pattern nodes (immutable)
124
+ a_node = Cyrel::Pattern::Node.new(a_alias, labels: self.class.label_name)
125
+ b_node = Cyrel::Pattern::Node.new(b_alias, labels: target_class.label_name)
126
+
127
+ # Relationship pattern with correct direction
128
+ rel_direction = case direction
129
+ when :out then Cyrel::Direction::OUT
130
+ when :in then Cyrel::Direction::IN
131
+ else Cyrel::Direction::BOTH
132
+ end
133
+
134
+ rel_node = Cyrel::Pattern::Relationship.new(
135
+ types: rel_type,
136
+ direction: rel_direction
137
+ )
138
+
139
+ # Build undirected / outgoing / incoming path
140
+ path = case direction
141
+ when :in then Cyrel::Pattern::Path.new([b_node, rel_node, a_node])
142
+ else Cyrel::Pattern::Path.new([a_node, rel_node, b_node])
143
+ end
144
+
145
+ # Compose query MATCH – WHERE – RETURN
146
+ query = Cyrel::Query.new
147
+ .match(path)
148
+ .where(Cyrel.id(a_alias).eq(internal_id))
149
+ .return_(b_alias)
150
+
151
+ base_relation = Relation.new(target_class, query)
152
+
153
+ # Return a collection proxy so callers can do owner.hobbies << chess, etc.
154
+ Associations::CollectionProxy.new(self, reflection, base_relation)
155
+ end
156
+ end
157
+
158
+ def define_belongs_to_methods(reflection)
159
+ name = reflection[:name]
160
+ target_class_name = reflection[:class_name]
161
+ rel_type = reflection[:relationship]
162
+ direction = reflection[:direction] # :in, :out, :both
163
+
164
+ # ------------------- reader -------------------------------------------
165
+ define_method(name) do
166
+ ivar = "@#{name}"
167
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
168
+
169
+ unless persisted?
170
+ raise ActiveCypher::PersistenceError,
171
+ 'Association load attempted on unsaved record'
172
+ end
173
+
174
+ target_class = target_class_name.constantize
175
+ a_alias = :a
176
+ b_alias = :b
177
+
178
+ # plain node patterns (no mutating helpers)
179
+ a_node = Cyrel::Pattern::Node.new(a_alias, labels: self.class.label_name)
180
+ b_node = Cyrel::Pattern::Node.new(b_alias, labels: target_class.label_name)
181
+
182
+ # explicit relationship node – mirrors Arel::Nodes::Join construction
183
+ rel = Cyrel::Pattern::Relationship.new(
184
+ types: rel_type,
185
+ direction: Cyrel::Direction::BOTH # undirected ‹--›
186
+ )
187
+
188
+ path = Cyrel::Pattern::Path.new([a_node, rel, b_node])
189
+
190
+ query = Cyrel::Query.new
191
+ .match(path)
192
+ .where(Cyrel.id(a_alias).eq(internal_id))
193
+ .return_(b_alias)
194
+ .limit(1)
195
+
196
+ relation = Relation.new(target_class, query)
197
+ instance_variable_set(ivar, relation.first)
198
+ end
199
+
200
+ # Define writer (e.g., author=)
201
+ define_method("#{name}=") do |associate|
202
+ instance_var = "@#{name}"
203
+ # Load current associate lazily only if needed for comparison or deletion
204
+ current_associate = instance_variable_defined?(instance_var) ? instance_variable_get(instance_var) : nil
205
+ # Load if not cached and persisted
206
+ current_associate = public_send(name) if current_associate.nil? && persisted?
207
+
208
+ # No change if assigning the same object
209
+ return associate if associate == current_associate
210
+
211
+ raise 'Cannot modify associations on a new record' unless persisted?
212
+
213
+ # --- Delete existing relationship (if any) ---
214
+ if current_associate
215
+ del_start_alias = :a
216
+ del_end_alias = :b
217
+ del_rel_alias = :r
218
+ cyrel_direction = if direction == :in
219
+ :out
220
+ else
221
+ (direction == :both ? :both : direction)
222
+ end
223
+
224
+ del_query = Cyrel
225
+ .match(Cyrel.node(del_start_node.class.label_name).as(del_start_alias))
226
+ .match(Cyrel.node(del_end_node.class.label_name).as(del_end_alias))
227
+ .match(Cyrel.node(del_start_alias)
228
+ .rel(cyrel_direction, rel_type)
229
+ .as(del_rel_alias)
230
+ .to(del_end_alias))
231
+ .where(Cyrel.id(del_start_alias).eq(del_start_node.internal_id))
232
+ .where(Cyrel.id(del_end_alias).eq(del_end_node.internal_id))
233
+ .delete(del_rel_alias)
234
+
235
+ self.class.connection.execute_cypher(
236
+ *del_query.to_cypher,
237
+ 'Delete Association (belongs_to)'
238
+ )
239
+ end
240
+
241
+ # --- Create new relationship (if associate is not nil) ---
242
+ if associate
243
+ raise ArgumentError, "Associated object must be an instance of #{target_class_name}" unless associate.is_a?(target_class_name.constantize)
244
+ raise "Associated object #{associate.inspect} must be persisted" unless associate.persisted?
245
+
246
+ # Determine start/end nodes for creation based on direction
247
+ new_start_node, new_end_node =
248
+ case direction
249
+ when :out then [self, associate]
250
+ when :in then [associate, self]
251
+ when :both then [self, associate] # choose a deterministic orientation
252
+ else raise ArgumentError,
253
+ "Direction '#{direction}' not supported for creation via '='"
254
+ end
255
+
256
+ if reflection[:relationship_class]
257
+ # Use Relationship Model
258
+ rel_model_class = reflection[:relationship_class].constantize
259
+ # TODO: Extract relationship properties if passed somehow (e.g., via options hash?)
260
+ rel_props = {}
261
+ relationship_instance = rel_model_class.new(rel_props, from_node: new_start_node, to_node: new_end_node)
262
+ relationship_instance.save # Relationship model handles Cypher generation
263
+ else
264
+ # Use direct Cypher generation
265
+ new_start_alias = :a
266
+ new_end_alias = :b
267
+ arrow = direction == :both ? :both : :out
268
+
269
+ create_query = Cyrel
270
+ .match(Cyrel.node(new_start_node.class.label_name).as(new_start_alias))
271
+ .match(Cyrel.node(new_end_node.class.label_name).as(new_end_alias))
272
+ .where(Cyrel.id(new_start_alias).eq(new_start_node.internal_id))
273
+ .where(Cyrel.id(new_end_alias).eq(new_end_node.internal_id))
274
+ .create(Cyrel.node(new_start_alias)
275
+ .rel(arrow, rel_type)
276
+ .to(new_end_alias))
277
+
278
+ self.class.connection.execute_cypher(
279
+ *create_query.to_cypher,
280
+ 'Create Association (belongs_to - Direct)'
281
+ )
282
+ end
283
+ end
284
+
285
+ # Update the instance variable cache
286
+ instance_variable_set(instance_var, associate)
287
+ end
288
+
289
+ # Define build method (e.g., build_author(name: "New Author"))
290
+ define_method("build_#{name}") do |attributes = {}|
291
+ target_class = target_class_name.constantize
292
+ # TODO: Potentially set the inverse association reference here
293
+ # For now, just instantiate the target class
294
+ target_class.new(attributes)
295
+ end
296
+
297
+ # Define create method (e.g., create_author(name: "New Author"))
298
+ define_method("create_#{name}") do |attributes = {}|
299
+ # Build the instance
300
+ instance = public_send("build_#{name}", attributes)
301
+ # Save the instance
302
+ instance.save
303
+ # If save is successful, associate it using the = method
304
+ public_send("#{name}=", instance) if instance.persisted?
305
+ # Return the instance
306
+ instance
307
+ end
308
+ end
309
+
310
+ def define_has_one_methods(reflection)
311
+ name = reflection[:name]
312
+ target_class_name = reflection[:class_name]
313
+ rel_type = reflection[:relationship]
314
+ direction = reflection[:direction] # :in, :out, :both
315
+
316
+ # Define reader method (e.g., profile) - logic is same as belongs_to reader
317
+ define_method(name) do
318
+ instance_var = "@#{name}"
319
+ return instance_variable_get(instance_var) if instance_variable_defined?(instance_var)
320
+
321
+ raise ActiveCypher::PersistenceError, 'Association load attempted on unsaved record' unless persisted?
322
+
323
+ target_class = target_class_name.constantize
324
+ start_node_alias = :start_node
325
+ target_node_alias = :target_node
326
+
327
+ start_node_pattern = Cyrel.node(self.class.label_name).as(start_node_alias)
328
+ .where(Cyrel.id(start_node_alias).eq(internal_id))
329
+ target_node_pattern = Cyrel.node(target_class.label_name).as(target_node_alias)
330
+
331
+ rel_pattern = case direction
332
+ when :out
333
+ start_node_pattern.rel(:out, rel_type).to(target_node_pattern)
334
+ when :in
335
+ target_node_pattern.rel(:out, rel_type).to(start_node_pattern) # Reverse for Cyrel syntax
336
+ when :both
337
+ start_node_pattern.rel(:both, rel_type).to(target_node_pattern)
338
+ else
339
+ raise AssociationError, "Invalid direction: #{direction}"
340
+ end
341
+
342
+ query = Cyrel.match(rel_pattern).return(target_node_alias).limit(1)
343
+
344
+ relation = Relation.new(target_class, query)
345
+ instance_variable_set(instance_var, relation.first)
346
+ end
347
+
348
+ # Define writer (e.g., profile=)
349
+ define_method("#{name}=") do |associate|
350
+ instance_var = "@#{name}"
351
+ # Load current associate lazily only if needed for comparison or deletion
352
+ current_associate = instance_variable_defined?(instance_var) ? instance_variable_get(instance_var) : nil
353
+ # Load if not cached and persisted
354
+ current_associate = public_send(name) if current_associate.nil? && persisted?
355
+
356
+ # No change if assigning the same object
357
+ return associate if associate == current_associate
358
+
359
+ raise 'Cannot modify associations on a new record' unless persisted?
360
+
361
+ # --- Delete existing relationship (if any) ---
362
+ if current_associate
363
+ # Determine start/end nodes for deletion based on direction
364
+ del_start_node, del_end_node = case direction
365
+ when :out then [self, current_associate]
366
+ when :in then [current_associate, self]
367
+ else raise ArgumentError,
368
+ "Direction '#{direction}' not supported for deletion via '='"
369
+ end
370
+
371
+ # Build Cyrel query to delete the relationship
372
+ del_start_alias = :a
373
+ del_end_alias = :b
374
+ del_rel_alias = :r
375
+ # Adjust direction for Cyrel pattern if needed
376
+ cyrel_direction = direction == :in ? :out : direction
377
+ del_query = Cyrel.match(Cyrel.node(del_start_node.class.label_name)
378
+ .as(del_start_alias).where(Cyrel.id(del_start_alias)
379
+ .eq(del_start_node.internal_id)))
380
+ .match(Cyrel.node(del_end_node.class.label_name)
381
+ .as(del_end_alias).where(Cyrel.id(del_end_alias)
382
+ .eq(del_end_node.internal_id)))
383
+ .match(Cyrel.node(del_start_alias).rel(cyrel_direction,
384
+ rel_type).as(del_rel_alias).to(del_end_alias))
385
+ .delete(del_rel_alias)
386
+
387
+ del_cypher = del_query.to_cypher
388
+ del_params = { start_id: del_start_node.internal_id, end_id: del_end_node.internal_id }
389
+ self.class.connection.execute_cypher(del_cypher, del_params, 'Delete Association (has_one)')
390
+ end
391
+
392
+ # --- Create new relationship (if associate is not nil) ---
393
+ if associate
394
+ raise ArgumentError, "Associated object must be an instance of #{target_class_name}" unless associate.is_a?(target_class_name.constantize)
395
+ raise "Associated object #{associate.inspect} must be persisted" unless associate.persisted?
396
+
397
+ # Determine start/end nodes for creation based on direction
398
+ new_start_node, new_end_node = case direction
399
+ when :out then [self, associate]
400
+ when :in then [associate, self]
401
+ else raise ArgumentError,
402
+ "Direction '#{direction}' not supported for creation via '='"
403
+ end
404
+
405
+ if reflection[:relationship_class]
406
+ # Use Relationship Model
407
+ rel_model_class = reflection[:relationship_class].constantize
408
+ # TODO: Extract relationship properties if passed somehow
409
+ rel_props = {}
410
+ relationship_instance = rel_model_class.new(rel_props, from_node: new_start_node, to_node: new_end_node)
411
+ relationship_instance.save
412
+ else
413
+ # Use direct Cypher generation
414
+ new_start_alias = :a
415
+ new_end_alias = :b
416
+ create_query = Cyrel.match(Cyrel.node(new_start_node.class.label_name)
417
+ .as(new_start_alias).where(Cyrel.id(new_start_alias)
418
+ .eq(new_start_node.internal_id)))
419
+ .match(Cyrel.node(new_end_node.class.label_name)
420
+ .as(new_end_alias).where(Cyrel.id(new_end_alias)
421
+ .eq(new_end_node.internal_id)))
422
+ .create(Cyrel.node(new_start_alias).rel(:out, rel_type).to(new_end_alias))
423
+
424
+ create_cypher = create_query.to_cypher
425
+ create_params = { start_id: new_start_node.internal_id, end_id: new_end_node.internal_id }
426
+ self.class.connection.execute_cypher(create_cypher, create_params,
427
+ 'Create Association (has_one - Direct)')
428
+ end
429
+ end
430
+
431
+ # Update the instance variable cache
432
+ instance_variable_set(instance_var, associate)
433
+ end
434
+
435
+ # Define build method (e.g., build_profile(data: {...}))
436
+ define_method("build_#{name}") do |attributes = {}|
437
+ target_class = target_class_name.constantize
438
+ # TODO: Potentially set the inverse association reference here
439
+ # For now, just instantiate the target class
440
+ target_class.new(attributes)
441
+ end
442
+
443
+ # Define create method (e.g., create_profile(data: {...}))
444
+ define_method("create_#{name}") do |attributes = {}|
445
+ # Build the instance
446
+ instance = public_send("build_#{name}", attributes)
447
+ # Save the instance
448
+ instance.save
449
+ # If save is successful, associate it using the = method
450
+ public_send("#{name}=", instance) if instance.persisted?
451
+ # Return the instance
452
+ instance
453
+ end
454
+ end
455
+ end
456
+
457
+ # Defines the reader method for a has_many :through association.
458
+ # Because sometimes you want to join tables, but with extra steps.
459
+ def self.define_has_many_through_reader(reflection)
460
+ name = reflection[:name]
461
+ through_association_name = reflection[:through]
462
+ source_association_name = reflection[:source] || name # Default source is same name on intermediate model
463
+
464
+ define_method(name) do
465
+ raise ActiveCypher::PersistenceError, 'Association load attempted on unsaved record' unless persisted?
466
+
467
+ # 1. Get reflection for the intermediate association (e.g., :friendships)
468
+ through_reflection = self.class._reflections[through_association_name]
469
+ unless through_reflection
470
+ raise ArgumentError,
471
+ "Could not find association '#{through_association_name}' specified in :through option for '#{name}'"
472
+ end
473
+
474
+ intermediate_class = through_reflection[:class_name].constantize
475
+
476
+ # 2. Get reflection for the source association on the intermediate model (e.g., :to_node on Friendship)
477
+ # Note: This assumes the intermediate model also uses ActiveCypher::Associations
478
+ source_reflection = intermediate_class._reflections[source_association_name]
479
+ unless source_reflection
480
+ raise ArgumentError,
481
+ "Could not find association '#{source_association_name}' specified as :source (or inferred) on '#{intermediate_class.name}' for '#{name}'"
482
+ end
483
+
484
+ final_target_class = source_reflection[:class_name].constantize
485
+
486
+ # 3. Build the multi-hop Cyrel query.
487
+ # Because why settle for one hop when you can have two and still not get what you want?
488
+ start_node_alias = :start_node
489
+ intermediate_node_alias = :intermediate_node
490
+ final_target_node_alias = :final_target
491
+
492
+ # Start node pattern
493
+ start_node_pattern = Cyrel.node(self.class.label_name).as(start_node_alias)
494
+ .where(Cyrel.id(start_node_alias).eq(internal_id))
495
+
496
+ # Intermediate node pattern (based on through_reflection)
497
+ intermediate_node_pattern = Cyrel.node(intermediate_class.label_name).as(intermediate_node_alias)
498
+ through_rel_type = through_reflection[:relationship]
499
+ through_direction = through_reflection[:direction]
500
+
501
+ first_hop_pattern = case through_direction
502
+ when :out then start_node_pattern.rel(:out, through_rel_type).to(intermediate_node_pattern)
503
+ when :in then intermediate_node_pattern.rel(:out, through_rel_type).to(start_node_pattern)
504
+ when :both then start_node_pattern.rel(:both,
505
+ through_rel_type).to(intermediate_node_pattern)
506
+ else raise ArgumentError, "Invalid direction in through_reflection: #{through_direction}"
507
+ end
508
+
509
+ # Final target node pattern (based on source_reflection)
510
+ final_target_node_pattern = Cyrel.node(final_target_class.label_name).as(final_target_node_alias)
511
+ source_rel_type = source_reflection[:relationship]
512
+ source_direction = source_reflection[:direction]
513
+
514
+ second_hop_pattern = case source_direction
515
+ when :out then intermediate_node_pattern.rel(:out,
516
+ source_rel_type).to(final_target_node_pattern)
517
+ when :in then final_target_node_pattern.rel(:out,
518
+ source_rel_type).to(intermediate_node_pattern)
519
+ when :both then intermediate_node_pattern.rel(:both,
520
+ source_rel_type).to(final_target_node_pattern)
521
+ else raise ArgumentError, "Invalid direction in source_reflection: #{source_direction}"
522
+ end
523
+
524
+ # Combine patterns and return final target
525
+ # Assuming Cyrel allows chaining matches or building complex patterns
526
+ # This might need adjustment based on Cyrel's exact path-building API
527
+ query = Cyrel.match(first_hop_pattern)
528
+ .match(second_hop_pattern) # Assumes .match adds to the pattern
529
+ .return(final_target_node_alias)
530
+ # TODO: Add DISTINCT if needed? .return(Cyrel.distinct(final_target_node_alias))
531
+
532
+ # Return a Relation scoped to the final target class
533
+ Relation.new(final_target_class, query)
534
+ end
535
+ end
536
+ end
537
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ # @!parse
5
+ # # The One True Base Class. All node models must kneel before it.
6
+ # # If you ever wondered what ActiveRecord would look like after a long weekend and a midlife crisis, here you go.
7
+ class Base
8
+ # @!attribute [rw] connects_to_mappings
9
+ # @return [Hash] Because every base class needs a mapping it will never use directly.
10
+ class_attribute :connects_to_mappings, default: {}
11
+ include Logging
12
+
13
+ # Let's just include every concern we can find, because why not.
14
+ include Model::Core
15
+ include Model::Attributes
16
+ include Model::ConnectionOwner
17
+ include Model::Callbacks
18
+ include Model::Persistence
19
+ include Model::Querying
20
+ include Model::ConnectionHandling
21
+ include Model::Destruction
22
+ include Model::Abstract
23
+ include Model::Countable
24
+ include Model::Inspectable
25
+
26
+ class << self
27
+ # Attempts to retrieve a connection from the handler.
28
+ # If you don't have a pool, you get to enjoy the fallback logic.
29
+ # If you still don't have a connection, you get an error. It's the circle of life.
30
+ # @return [Object] The connection instance
31
+ def connection
32
+ if (pool = connection_handler.pool(current_role, current_shard))
33
+ return pool.connection
34
+ end
35
+
36
+ # fall back to a per‑model connection created by establish_connection
37
+ return @connection if defined?(@connection) && @connection&.active?
38
+
39
+ raise ActiveCypher::ConnectionNotEstablished,
40
+ "No pool for role=#{current_role.inspect} shard=#{current_shard.inspect}"
41
+ end
42
+ end
43
+
44
+ # Because Rails needs to feel included, too.
45
+ ActiveSupport.run_load_hooks(:active_cypher, self)
46
+ end
47
+ end