activecypher 0.0.0 → 0.2.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.
- checksums.yaml +4 -4
- data/lib/active_cypher/associations/collection_proxy.rb +144 -0
- data/lib/active_cypher/associations.rb +537 -0
- data/lib/active_cypher/base.rb +47 -0
- data/lib/active_cypher/bolt/connection.rb +525 -0
- data/lib/active_cypher/bolt/driver.rb +144 -0
- data/lib/active_cypher/bolt/handlers.rb +10 -0
- data/lib/active_cypher/bolt/message_reader.rb +100 -0
- data/lib/active_cypher/bolt/message_writer.rb +53 -0
- data/lib/active_cypher/bolt/messaging.rb +307 -0
- data/lib/active_cypher/bolt/packstream.rb +319 -0
- data/lib/active_cypher/bolt/result.rb +82 -0
- data/lib/active_cypher/bolt/session.rb +201 -0
- data/lib/active_cypher/bolt/transaction.rb +211 -0
- data/lib/active_cypher/bolt/version_encoding.rb +41 -0
- data/lib/active_cypher/bolt.rb +7 -0
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +75 -0
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +178 -0
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +58 -0
- data/lib/active_cypher/connection_factory.rb +130 -0
- data/lib/active_cypher/connection_handler.rb +9 -0
- data/lib/active_cypher/connection_pool.rb +123 -0
- data/lib/active_cypher/connection_url_resolver.rb +137 -0
- data/lib/active_cypher/cypher_config.rb +50 -0
- data/lib/active_cypher/generators/install_generator.rb +23 -0
- data/lib/active_cypher/generators/node_generator.rb +32 -0
- data/lib/active_cypher/generators/relationship_generator.rb +33 -0
- data/lib/active_cypher/generators/templates/application_graph_node.rb +5 -0
- data/lib/active_cypher/generators/templates/application_graph_relationship.rb +5 -0
- data/lib/active_cypher/generators/templates/cypher_databases.yml +28 -0
- data/lib/active_cypher/generators/templates/node.rb.erb +10 -0
- data/lib/active_cypher/generators/templates/relationship.rb.erb +11 -0
- data/lib/active_cypher/logging.rb +44 -0
- data/lib/active_cypher/model/abstract.rb +87 -0
- data/lib/active_cypher/model/attributes.rb +24 -0
- data/lib/active_cypher/model/callbacks.rb +44 -0
- data/lib/active_cypher/model/connection_handling.rb +76 -0
- data/lib/active_cypher/model/connection_owner.rb +50 -0
- data/lib/active_cypher/model/core.rb +45 -0
- data/lib/active_cypher/model/countable.rb +30 -0
- data/lib/active_cypher/model/destruction.rb +49 -0
- data/lib/active_cypher/model/inspectable.rb +28 -0
- data/lib/active_cypher/model/persistence.rb +182 -0
- data/lib/active_cypher/model/querying.rb +67 -0
- data/lib/active_cypher/railtie.rb +34 -0
- data/lib/active_cypher/relation.rb +190 -0
- data/lib/active_cypher/relationship.rb +233 -0
- data/lib/active_cypher/runtime_registry.rb +8 -0
- data/lib/active_cypher/scoping.rb +97 -0
- data/lib/active_cypher/utils/logger.rb +100 -0
- data/lib/active_cypher/version.rb +5 -0
- data/lib/activecypher.rb +108 -0
- data/lib/cyrel/call_procedure.rb +29 -0
- data/lib/cyrel/clause/call.rb +46 -0
- data/lib/cyrel/clause/call_subquery.rb +40 -0
- data/lib/cyrel/clause/create.rb +33 -0
- data/lib/cyrel/clause/delete.rb +41 -0
- data/lib/cyrel/clause/limit.rb +33 -0
- data/lib/cyrel/clause/match.rb +40 -0
- data/lib/cyrel/clause/merge.rb +34 -0
- data/lib/cyrel/clause/order_by.rb +78 -0
- data/lib/cyrel/clause/remove.rb +75 -0
- data/lib/cyrel/clause/return.rb +90 -0
- data/lib/cyrel/clause/set.rb +97 -0
- data/lib/cyrel/clause/skip.rb +34 -0
- data/lib/cyrel/clause/where.rb +42 -0
- data/lib/cyrel/clause/with.rb +94 -0
- data/lib/cyrel/clause.rb +25 -0
- data/lib/cyrel/direction.rb +18 -0
- data/lib/cyrel/expression/alias.rb +27 -0
- data/lib/cyrel/expression/base.rb +101 -0
- data/lib/cyrel/expression/case.rb +45 -0
- data/lib/cyrel/expression/comparison.rb +60 -0
- data/lib/cyrel/expression/exists.rb +42 -0
- data/lib/cyrel/expression/function_call.rb +57 -0
- data/lib/cyrel/expression/literal.rb +33 -0
- data/lib/cyrel/expression/logical.rb +38 -0
- data/lib/cyrel/expression/operator.rb +27 -0
- data/lib/cyrel/expression/pattern_comprehension.rb +44 -0
- data/lib/cyrel/expression/property_access.rb +25 -0
- data/lib/cyrel/expression.rb +56 -0
- data/lib/cyrel/functions.rb +116 -0
- data/lib/cyrel/node.rb +397 -0
- data/lib/cyrel/parameterizable.rb +20 -0
- data/lib/cyrel/pattern/node.rb +66 -0
- data/lib/cyrel/pattern/path.rb +41 -0
- data/lib/cyrel/pattern/relationship.rb +74 -0
- data/lib/cyrel/pattern.rb +8 -0
- data/lib/cyrel/query.rb +497 -0
- data/lib/cyrel/return_only.rb +26 -0
- data/lib/cyrel/types/hash_type.rb +22 -0
- data/lib/cyrel/types/symbol_type.rb +13 -0
- data/lib/cyrel.rb +72 -0
- data/lib/tasks/active_cypher_tasks.rake +6 -0
- data/sig/activecypher.rbs +4 -0
- metadata +173 -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
|