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,497 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Require necessary clause and pattern types
4
+
5
+ # Require all clause types for DSL methods
6
+
7
+ module Cyrel
8
+ # Error raised when merging queries with conflicting alias definitions.
9
+ # Because even in graphs, two things can't have the same name without drama.
10
+ class AliasConflictError < StandardError
11
+ def initialize(alias_name, query1_details, query2_details)
12
+ super("Alias conflict for ':#{alias_name}'. Query 1 defines it as #{query1_details}, Query 2 defines it as #{query2_details}.")
13
+ end
14
+ end
15
+
16
+ # @!parse
17
+ # # The Cyrel Query class: where all your hopes, dreams, and clauses go to be awkwardly merged.
18
+ # # Manages clauses, parameters, and final query generation, because string interpolation is for amateurs.
19
+ class Query
20
+ include Parameterizable
21
+ attr_reader :parameters, :clauses # Expose clauses for merge logic
22
+
23
+ def initialize
24
+ @parameters = {}
25
+ @param_counter = 0
26
+ @clauses = [] # Holds instances of Clause::Base subclasses, because arrays are the new query planner
27
+ end
28
+
29
+ # Registers a value and returns a parameter key.
30
+ # Think of it as variable adoption but with less paperwork and more risk.
31
+ # @param value [Object] The value to parameterize.
32
+ # @return [Symbol] The parameter key (e.g., :p1, :p2).
33
+ # Because nothing says "safe query" like a parade of anonymous parameters.
34
+ def register_parameter(value)
35
+ existing_key = @parameters.key(value)
36
+ return existing_key if existing_key
37
+
38
+ key = next_param_key
39
+ @parameters[key] = value
40
+ key
41
+ end
42
+
43
+ # Adds a clause object to the query.
44
+ # @param clause [Cyrel::Clause::Base] The clause instance to add.
45
+ # Because what you really wanted was a linked list of existential dread.
46
+ def add_clause(clause)
47
+ @clauses << clause
48
+ self # Allow chaining
49
+ end
50
+
51
+ # Generates the final Cypher query string and parameters hash.
52
+ # @return [Array(String, Hash)] The Cypher string and parameters.
53
+ # This is where all your careful planning gets flattened into a string.
54
+ def to_cypher
55
+ ActiveSupport::Notifications.instrument('cyrel.render', query: self) do
56
+ cypher_string = @clauses
57
+ .map { it.render(self) }
58
+ .reject(&:blank?)
59
+ .join("\n")
60
+
61
+ [cypher_string, @parameters]
62
+ end
63
+ end
64
+
65
+ # Merges two Cyrel::Query objects together.
66
+ # Think Cypher polyamory: full of unexpected alias drama and parameter custody battles.
67
+ # @param other_query [Cyrel::Query] The query to merge in.
68
+ # @return [self]
69
+ # If you like surprises, you'll love this method.
70
+ def merge!(other_query)
71
+ raise ArgumentError, 'Can only merge another Cyrel::Query' unless other_query.is_a?(Cyrel::Query)
72
+ return self if other_query.clauses.empty? # Nothing to merge
73
+
74
+ # 1. Alias Conflict Detection
75
+ check_alias_conflicts!(other_query)
76
+
77
+ # 2. Parameter Merging
78
+ merge_parameters!(other_query)
79
+
80
+ # 3. Clause Combination
81
+ combine_clauses!(other_query)
82
+
83
+ self
84
+ end
85
+ # --- DSL Methods ---
86
+
87
+ # Adds a MATCH clause.
88
+ # @param pattern [Cyrel::Pattern::Path, Node, Relationship, Hash, Array] Pattern definition.
89
+ # - Can pass Pattern objects directly.
90
+ # - Can pass Hashes/Arrays to construct simple Node/Relationship patterns implicitly? (TBD)
91
+ # @param path_variable [Symbol, String, nil] Optional variable for the path.
92
+ # @return [self]
93
+ # Because nothing says "find me" like a declarative pattern and a prayer.
94
+ def match(pattern, path_variable: nil)
95
+ # TODO: Add implicit pattern construction from Hash/Array if desired.
96
+ add_clause(Clause::Match.new(pattern, optional: false, path_variable: path_variable))
97
+ end
98
+
99
+ # Adds an OPTIONAL MATCH clause.
100
+ # @param pattern [Cyrel::Pattern::Path, Node, Relationship, Hash, Array] Pattern definition.
101
+ # @param path_variable [Symbol, String, nil] Optional variable for the path.
102
+ # @return [self]
103
+ # For when you want to be non-committal, even in your queries.
104
+ def optional_match(pattern, path_variable: nil)
105
+ add_clause(Clause::Match.new(pattern, optional: true, path_variable: path_variable))
106
+ end
107
+
108
+ # Adds a WHERE clause (merging with an existing one if present).
109
+ #
110
+ # @example
111
+ # query.where(name: 'Alice').where(age: 30)
112
+ # # ⇒ WHERE ((n.name = $p1) AND (n.age = $p2))
113
+ #
114
+ # Accepts:
115
+ # • Hash – coerced into equality comparisons
116
+ # • Cyrel::Expression instances (or anything Expression.coerce understands)
117
+ #
118
+ # @return [self]
119
+ # Because sometimes you want to filter, and sometimes you just want to judge.
120
+ def where(*conditions)
121
+ # ------------------------------------------------------------------
122
+ # 1. Coerce incoming objects into Cyrel::Expression instances
123
+ # ------------------------------------------------------------------
124
+ processed_conditions = conditions.flat_map do |cond|
125
+ if cond.is_a?(Hash)
126
+ cond.map do |key, value|
127
+ Expression::Comparison.new(
128
+ Expression::PropertyAccess.new(@current_alias || infer_alias, key),
129
+ :'=',
130
+ value
131
+ )
132
+ end
133
+ else
134
+ cond # already an expression (or coercible)
135
+ end
136
+ end
137
+
138
+ new_where = Clause::Where.new(*processed_conditions)
139
+
140
+ # ------------------------------------------------------------------
141
+ # 2. Merge with an existing WHERE (if any)
142
+ # ------------------------------------------------------------------
143
+ existing_where = @clauses.find { |c| c.is_a?(Clause::Where) }
144
+ if existing_where
145
+ existing_where.merge!(new_where)
146
+ return self
147
+ end
148
+
149
+ # ------------------------------------------------------------------
150
+ # 3. Determine correct insertion point
151
+ # ------------------------------------------------------------------
152
+ insertion_index = @clauses.index do |c|
153
+ c.is_a?(Clause::Return) ||
154
+ c.is_a?(Clause::OrderBy) ||
155
+ c.is_a?(Clause::Skip) ||
156
+ c.is_a?(Clause::Limit)
157
+ end
158
+
159
+ if insertion_index
160
+ @clauses.insert(insertion_index, new_where)
161
+ else
162
+ @clauses << new_where
163
+ end
164
+
165
+ self
166
+ end
167
+
168
+ # Adds a CREATE clause.
169
+ # @param pattern [Cyrel::Pattern::Path, Node, Relationship, Hash, Array] Pattern definition.
170
+ # @return [self]
171
+ # Because sometimes you want to make things, not just break them.
172
+ def create(pattern)
173
+ # TODO: Add implicit pattern construction
174
+ add_clause(Clause::Create.new(pattern))
175
+ end
176
+
177
+ # Adds a MERGE clause.
178
+ # @param pattern [Cyrel::Pattern::Path, Node, Relationship, Hash, Array] Pattern definition.
179
+ # @return [self]
180
+ # For when you want to find-or-create, but with more existential angst.
181
+ def merge(pattern)
182
+ # TODO: Add implicit pattern construction
183
+ # TODO: Add ON CREATE SET / ON MATCH SET options
184
+ add_clause(Clause::Merge.new(pattern))
185
+ end
186
+
187
+ # Adds a SET clause.
188
+ # @param assignments [Hash, Array] See Clause::Set#initialize.
189
+ # @return [self]
190
+ # Because sometimes you just want to change everything and pretend it was always that way.
191
+ def set(assignments)
192
+ # TODO: Consider merging SET clauses intelligently if needed.
193
+ add_clause(Clause::Set.new(assignments))
194
+ end
195
+
196
+ # Adds a REMOVE clause.
197
+ # @param items [Array<Cyrel::Expression::PropertyAccess, Array>] See Clause::Remove#initialize.
198
+ # @return [self]
199
+ # For when you want to Marie Kondo your graph.
200
+ def remove(*items)
201
+ # TODO: Consider merging REMOVE clauses.
202
+ add_clause(Clause::Remove.new(items)) # Pass array directly
203
+ end
204
+
205
+ # Adds a DELETE clause. Use `detach_delete` for DETACH DELETE.
206
+ # @param variables [Array<Symbol, String>] Variables to delete.
207
+ # @return [self]
208
+ # Underscore to avoid keyword clash
209
+ # Because sometimes you just want to watch the world burn, one node at a time.
210
+ def delete_(*variables)
211
+ add_clause(Clause::Delete.new(*variables, detach: false))
212
+ end
213
+
214
+ # Adds a DETACH DELETE clause.
215
+ # @param variables [Array<Symbol, String>] Variables to delete.
216
+ # @return [self]
217
+ # For when you want to delete with extreme prejudice.
218
+ def detach_delete(*variables)
219
+ add_clause(Clause::Delete.new(*variables, detach: true))
220
+ end
221
+
222
+ # Adds a WITH clause.
223
+ # @param items [Array] Items to project. See Clause::With#initialize.
224
+ # @param distinct [Boolean] Use DISTINCT?
225
+ # @param where [Cyrel::Clause::Where, Hash, Array] Optional WHERE condition(s) after WITH.
226
+ # @return [self]
227
+ # Because sometimes you want to pass things along, and sometimes you just want to pass the buck.
228
+ def with(*items, distinct: false, where: nil)
229
+ where_clause = case where
230
+ when Clause::Where then where
231
+ when nil then nil
232
+ else Clause::Where.new(*Array(where)) # Coerce Hash/Array/Expression
233
+ end
234
+ add_clause(Clause::With.new(*items, distinct: distinct, where: where_clause))
235
+ end
236
+
237
+ # Adds a RETURN clause.
238
+ # @param items [Array] Items to return. See Clause::Return#initialize.
239
+ # @param distinct [Boolean] Use DISTINCT?
240
+ # @return [self]
241
+ # Underscore to avoid keyword clash
242
+ # Because what you really want is your data, but what you'll get is a hash.
243
+ def return_(*items, distinct: false)
244
+ # TODO: Consider merging RETURN clauses?
245
+ add_clause(Clause::Return.new(*items, distinct: distinct))
246
+ end
247
+
248
+ # Adds or replaces the ORDER BY clause.
249
+ # @param order_items [Array<Array>, Hash] Ordering specifications.
250
+ # - Array: [[expr, :asc], [expr, :desc], ...]
251
+ # - Hash: { expr => :asc, expr => :desc, ... }
252
+ # @return [self]
253
+ # Because sometimes you want order, and sometimes you just want chaos.
254
+ def order_by(*order_items)
255
+ items_array = order_items.first.is_a?(Hash) ? order_items.first.to_a : order_items
256
+
257
+ existing_order = @clauses.find { |c| c.is_a?(Clause::OrderBy) }
258
+ new_order = Clause::OrderBy.new(*items_array)
259
+ if existing_order
260
+ existing_order.replace!(new_order)
261
+ else
262
+ add_clause(new_order)
263
+ end
264
+ self
265
+ end
266
+
267
+ # Adds or replaces the SKIP clause.
268
+ # @param amount [Integer, Expression] Number of results to skip.
269
+ # @return [self]
270
+ # For when you want to ignore the first N results, just like your unread emails.
271
+ def skip(amount)
272
+ existing_skip = @clauses.find { |c| c.is_a?(Clause::Skip) }
273
+ new_skip = Clause::Skip.new(amount)
274
+ if existing_skip
275
+ existing_skip.replace!(new_skip)
276
+ else
277
+ add_clause(new_skip)
278
+ end
279
+ self
280
+ end
281
+
282
+ # Adds or replaces the LIMIT clause.
283
+ # @param amount [Integer, Expression] Maximum number of results.
284
+ # @return [self]
285
+ # Because sometimes you want boundaries, even in your queries.
286
+ def limit(amount)
287
+ existing_limit = @clauses.find { |c| c.is_a?(Clause::Limit) }
288
+ new_limit = Clause::Limit.new(amount)
289
+ if existing_limit
290
+ existing_limit.replace!(new_limit)
291
+ else
292
+ add_clause(new_limit)
293
+ end
294
+ self
295
+ end
296
+
297
+ # Adds a CALL procedure clause.
298
+ # @param procedure_name [String] Name of the procedure.
299
+ # @param arguments [Array] Arguments for the procedure.
300
+ # @param yield_items [Array<String>, String, nil] Items to YIELD.
301
+ # @param where [Clause::Where, Hash, Array, nil] WHERE condition after YIELD.
302
+ # @param return_items [Clause::Return, Array, nil] RETURN items after WHERE/YIELD.
303
+ # @return [self]
304
+ # For when you want to call a procedure and pretend it's not just another query.
305
+ def call_procedure(procedure_name, arguments: [], yield_items: nil, where: nil, return_items: nil)
306
+ add_clause(Clause::Call.new(procedure_name,
307
+ arguments: arguments,
308
+ yield_items: yield_items,
309
+ where: where,
310
+ return_items: return_items))
311
+ end
312
+
313
+ # Adds a CALL { subquery } clause.
314
+ # @yield [Cyrel::Query] Yields a new query object for building the subquery.
315
+ # @return [self]
316
+ # Because why write one query when you can write two and glue them together?
317
+ def call_subquery
318
+ subquery = Cyrel::Query.new
319
+ yield subquery
320
+ # Important: Parameters defined within the subquery block are currently
321
+ # NOT automatically merged into the outer query's parameters by this DSL method.
322
+ # This needs to be handled either by manually merging parameters after the block
323
+ # or by enhancing the rendering/parameter registration logic.
324
+ add_clause(Clause::CallSubquery.new(subquery))
325
+ # Consider adding: merge_parameters!(subquery) here, but it might re-register params.
326
+ end
327
+
328
+ # No longer private, needed by merge!
329
+ # private
330
+
331
+ # Merges parameters from another query, ensuring keys are unique.
332
+ # Because parameter collisions are the only collisions you want in production.
333
+ def merge_parameters!(other_query)
334
+ # Ensure our counter is beyond the other query's potential keys
335
+ max_other_param_num = other_query.parameters.keys
336
+ .map { |k| k.to_s.sub(/^p/, '').to_i }
337
+ .max || 0
338
+ @param_counter = [@param_counter, max_other_param_num].max
339
+
340
+ # Re-register each parameter from the other query
341
+ other_query.parameters.each_value do |value|
342
+ register_parameter(value)
343
+ # NOTE: This doesn't update references within the other_query's original clauses.
344
+ end
345
+ end
346
+
347
+ # Provides a sort order for clauses during rendering. Lower numbers come first.
348
+ # Because even your clauses need to know their place in the world.
349
+ def clause_order(clause)
350
+ case clause
351
+ when Clause::Match, Clause::Create, Clause::Merge then 10 # Reading/Writing/Merging clauses
352
+ when Clause::Call, Clause::CallSubquery then 15 # CALL often follows MATCH/CREATE
353
+ when Clause::With then 20
354
+ when Clause::Where then 30 # Filtering clauses
355
+ when Clause::Set, Clause::Remove, Clause::Delete then 40 # Modifying clauses
356
+ when Clause::Return then 50 # Projection
357
+ when Clause::OrderBy then 60 # Ordering/Paging
358
+ when Clause::Skip then 70
359
+ when Clause::Limit then 80
360
+ else 99 # Unknown clauses go last
361
+ end
362
+ end
363
+
364
+ # Extracts defined aliases and their labels from the query's clauses.
365
+ # @return [Hash{Symbol => Set<String>}] { alias_name => Set[label1, label2] }
366
+ # Because even your variables want to be unique snowflakes.
367
+ def defined_aliases
368
+ aliases = {}
369
+ @clauses.each do |clause|
370
+ # Look for clauses that define patterns (Match, Create, Merge)
371
+ next unless clause.respond_to?(:pattern) && clause.pattern
372
+
373
+ elements_to_check = []
374
+ case clause.pattern
375
+ when Pattern::Path
376
+ elements_to_check.concat(clause.pattern.elements)
377
+ when Pattern::Node, Pattern::Relationship
378
+ elements_to_check << clause.pattern
379
+ end
380
+
381
+ elements_to_check.each do |element|
382
+ next unless element.respond_to?(:alias_name) && element.alias_name
383
+
384
+ alias_name = element.alias_name
385
+ labels = Set.new
386
+ labels.merge(element.labels) if element.is_a?(Pattern::Node) && element.respond_to?(:labels)
387
+
388
+ aliases[alias_name] ||= Set.new
389
+ aliases[alias_name].merge(labels) unless labels.empty?
390
+ end
391
+ end
392
+ aliases
393
+ end
394
+
395
+ # Detects alias conflicts between queries.
396
+ # Because two nodes with the same name but different labels are the graph equivalent of identity theft.
397
+ def check_alias_conflicts!(other_query)
398
+ self_aliases = defined_aliases
399
+ other_aliases = other_query.defined_aliases
400
+
401
+ conflicting_aliases = self_aliases.keys & other_aliases.keys
402
+
403
+ conflicting_aliases.each do |alias_name|
404
+ self_labels = self_aliases[alias_name]
405
+ other_labels = other_aliases[alias_name]
406
+
407
+ # Conflict if labels are defined and different, or if one defines labels and the other doesn't.
408
+ # Allowing merge if both define the *same* labels or neither defines labels.
409
+ is_conflict = !self_labels.empty? && !other_labels.empty? && self_labels != other_labels
410
+ # Consider it a conflict if one defines labels and the other doesn't? Maybe too strict.
411
+ # is_conflict ||= (self_labels.empty? != other_labels.empty?)
412
+
413
+ next unless is_conflict
414
+
415
+ raise AliasConflictError.new(
416
+ alias_name,
417
+ "labels #{self_labels.to_a.inspect}",
418
+ "labels #{other_labels.to_a.inspect}"
419
+ )
420
+ end
421
+ end
422
+
423
+ # Combines clauses from the other query into this one based on type.
424
+ # Because merging queries is just like merging companies: someone always loses.
425
+ def combine_clauses!(other_query)
426
+ # Clone other query's clauses to avoid modifying it during iteration
427
+ other_clauses_to_process = other_query.clauses.dup
428
+
429
+ # --- Handle Replacing Clauses (OrderBy, Skip, Limit) ---
430
+ [Clause::OrderBy, Clause::Skip, Clause::Limit].each do |clause_class|
431
+ # Find the last occurrence in the other query's clauses
432
+ other_clause = other_clauses_to_process.reverse.find { |c| c.is_a?(clause_class) }
433
+ next unless other_clause
434
+
435
+ # Find the clause in self, if it exists
436
+ self_clause = @clauses.find { |c| c.is_a?(clause_class) }
437
+
438
+ if self_clause.respond_to?(:replace!)
439
+ # If self has the clause and it supports replace!, replace it
440
+ self_clause.replace!(other_clause)
441
+ elsif !self_clause
442
+ # If self doesn't have the clause, add the one from other_query
443
+ add_clause(other_clause)
444
+ # Else: self has the clause but doesn't support replace! - do nothing (keep self's)
445
+ end
446
+
447
+ # Remove *all* occurrences of this clause type from the list to process further
448
+ other_clauses_to_process.delete_if { |c| c.is_a?(clause_class) }
449
+ end
450
+
451
+ # --- Handle Merging Clauses (Where) ---
452
+ other_wheres = other_query.clauses.select { |c| c.is_a?(Clause::Where) }
453
+ unless other_wheres.empty?
454
+ self_where = @clauses.find { |c| c.is_a?(Clause::Where) }
455
+ if self_where
456
+ other_wheres.each { |ow| self_where.merge!(ow) }
457
+ else
458
+ # Add the first other_where and merge the rest into it
459
+ first_other_where = other_wheres.shift
460
+ add_clause(first_other_where)
461
+ other_wheres.each { |ow| first_other_where.merge!(ow) }
462
+ end
463
+ # Remove processed clauses
464
+ other_clauses_to_process.delete_if { |c| c.is_a?(Clause::Where) }
465
+ end
466
+
467
+ # --- Handle Appending Clauses (Match, Create, Set, Remove, Delete, With, Return, Call, etc.) ---
468
+ # Add remaining clauses from other_query
469
+ other_clauses_to_process.each { |clause| add_clause(clause) }
470
+ end
471
+
472
+ # Helper to map instance variable names used in combine_clauses! to class types
473
+ # This helper is no longer needed with the refactored combine_clauses!
474
+ # def clause_class_for_ivar(ivar_name) ... end
475
+
476
+ # Helper needed for `where` DSL method with hash conditions
477
+ # Tries to guess the primary alias.
478
+ # Like Sherlock Holmes, but with fewer clues and more yelling.
479
+ def infer_alias
480
+ # Find first Node alias defined in MATCH/CREATE/MERGE clauses
481
+ @clauses.each do |clause|
482
+ next unless clause.respond_to?(:pattern) && clause.pattern
483
+
484
+ pattern = clause.pattern
485
+ element = pattern.is_a?(Pattern::Path) ? pattern.elements.first : pattern
486
+ return element.alias_name if element.is_a?(Pattern::Node) && element.alias_name
487
+ end
488
+ raise 'Cannot infer alias for WHERE hash conditions. Define a node alias in MATCH/CREATE first.'
489
+ end
490
+
491
+ def freeze!
492
+ @parameters.freeze
493
+ @clauses.each(&:freeze)
494
+ freeze
495
+ end
496
+ end
497
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ # Class for standalone RETURN statements
5
+ class ReturnOnly
6
+ def initialize(return_values)
7
+ @return_values = return_values
8
+ end
9
+
10
+ def to_cypher
11
+ formatted_values = @return_values.map do |alias_name, value|
12
+ formatted = case value
13
+ when Array
14
+ "[#{value.join(', ')}]"
15
+ when Hash
16
+ "{#{value.map { |k, v| "#{k}: #{v.is_a?(String) ? "\"#{v}\"" : v}" }.join(', ')}}"
17
+ else
18
+ value.to_s
19
+ end
20
+ "#{formatted} AS #{alias_name}"
21
+ end
22
+
23
+ "RETURN #{formatted_values.join(', ')}"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/hash_with_indifferent_access'
4
+ require 'active_model/type/value'
5
+
6
+ module Cyrel
7
+ module Types
8
+ class HashType < ActiveModel::Type::Value
9
+ def cast(value)
10
+ case value
11
+ when nil then ActiveSupport::HashWithIndifferentAccess.new
12
+ when Hash then value.with_indifferent_access
13
+ else
14
+ value.respond_to?(:to_h) ? value.to_h.with_indifferent_access : {}
15
+ end
16
+ end
17
+
18
+ # Serialize as a plain Hash (e.g., for JSON output); symbols are fine
19
+ def serialize(value) = cast(value)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module Types
5
+ # inherits low‑overhead base
6
+ class SymbolType < ActiveModel::Type::Value
7
+ # String/ Symbol → Symbol / nil
8
+ def cast(value) = value&.to_sym
9
+ # Symbol → String (for JSON, etc.)
10
+ def serialize(value) = value.to_s
11
+ end
12
+ end
13
+ end
data/lib/cyrel.rb ADDED
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'irb' # Required for binding.irb
4
+
5
+ module Cyrel
6
+ module_function
7
+
8
+ # Define all top-level helpers as instance methods first
9
+ def call(procedure)
10
+ CallProcedure.new(procedure)
11
+ end
12
+
13
+ def return(**return_values)
14
+ ReturnOnly.new(return_values)
15
+ end
16
+ # Now make all defined instance methods module functions
17
+
18
+ # --- Pattern Helpers ---
19
+ def node(alias_name, labels: [], properties: {})
20
+ Pattern::Node.new(alias_name, labels: labels, properties: properties)
21
+ end
22
+ # Add helpers for Relationship, Path if needed
23
+
24
+ # --- Query Building Starters ---
25
+ def create(pattern)
26
+ Query.new.create(pattern) # Start a new query and call create
27
+ end
28
+
29
+ def match(pattern, path_variable: nil)
30
+ Query.new.match(pattern, path_variable: path_variable) # Start a new query and call match
31
+ end
32
+ # Add helpers for merge etc. if desired as query starters
33
+
34
+ # --- Function Helpers (Delegated) ---
35
+ # Keep id for now for compatibility? Or remove entirely? Let's keep it but delegate element_id too.
36
+ # Delegate to the correct module function
37
+ def id(...) = Functions.element_id(...)
38
+ def element_id(...) = Functions.element_id(...)
39
+ def count(...) = Functions.count(...)
40
+ def labels(...) = Functions.labels(...)
41
+ def type(...) = Functions.type(...)
42
+ def properties(...) = Functions.properties(...)
43
+ def coalesce(...) = Functions.coalesce(...)
44
+ def timestamp(...) = Functions.timestamp(...)
45
+ def to_string(...) = Functions.to_string(...)
46
+ def to_integer(...) = Functions.to_integer(...)
47
+ def to_float(...) = Functions.to_float(...)
48
+ def to_boolean(...) = Functions.to_boolean(...)
49
+ def sum(...) = Functions.sum(...)
50
+ def avg(...) = Functions.avg(...)
51
+ def min(...) = Functions.min(...)
52
+ def max(...) = Functions.max(...)
53
+ def collect(...) = Functions.collect(...)
54
+ def size(...) = Functions.size(...)
55
+
56
+ # --- Expression Helpers (Delegated) ---
57
+
58
+ # Helper for creating PropertyAccess expressions.
59
+ def prop(variable, property_name)
60
+ Expression.prop(variable, property_name)
61
+ end
62
+
63
+ # Helper for creating Exists expressions.
64
+ def exists(pattern)
65
+ Expression.exists(pattern)
66
+ end
67
+
68
+ # Helper for creating Logical NOT expressions.
69
+ def not(expression)
70
+ Expression.not(expression)
71
+ end
72
+ end
@@ -0,0 +1,4 @@
1
+ module Activecypher
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end