activecypher 0.7.3 → 0.8.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +10 -1
  3. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +1 -1
  4. data/lib/active_cypher/connection_adapters/persistence_methods.rb +31 -14
  5. data/lib/active_cypher/relation.rb +1 -1
  6. data/lib/active_cypher/version.rb +1 -1
  7. data/lib/activecypher.rb +3 -1
  8. data/lib/cyrel/ast/call_node.rb +39 -0
  9. data/lib/cyrel/ast/clause_adapter.rb +38 -0
  10. data/lib/cyrel/ast/clause_node.rb +10 -0
  11. data/lib/cyrel/ast/compiler.rb +609 -0
  12. data/lib/cyrel/ast/create_node.rb +21 -0
  13. data/lib/cyrel/ast/delete_node.rb +22 -0
  14. data/lib/cyrel/ast/expression_node.rb +10 -0
  15. data/lib/cyrel/ast/foreach_node.rb +23 -0
  16. data/lib/cyrel/ast/limit_node.rb +21 -0
  17. data/lib/cyrel/ast/literal_node.rb +39 -0
  18. data/lib/cyrel/ast/load_csv_node.rb +24 -0
  19. data/lib/cyrel/ast/match_node.rb +23 -0
  20. data/lib/cyrel/ast/merge_node.rb +23 -0
  21. data/lib/cyrel/ast/node.rb +36 -0
  22. data/lib/cyrel/ast/optimized_nodes.rb +117 -0
  23. data/lib/cyrel/ast/order_by_node.rb +21 -0
  24. data/lib/cyrel/ast/pattern_node.rb +10 -0
  25. data/lib/cyrel/ast/query_integrated_compiler.rb +27 -0
  26. data/lib/cyrel/ast/remove_node.rb +21 -0
  27. data/lib/cyrel/ast/return_node.rb +21 -0
  28. data/lib/cyrel/ast/set_node.rb +20 -0
  29. data/lib/cyrel/ast/simple_cache.rb +50 -0
  30. data/lib/cyrel/ast/skip_node.rb +19 -0
  31. data/lib/cyrel/ast/union_node.rb +22 -0
  32. data/lib/cyrel/ast/unwind_node.rb +20 -0
  33. data/lib/cyrel/ast/where_node.rb +20 -0
  34. data/lib/cyrel/ast/with_node.rb +23 -0
  35. data/lib/cyrel/clause/unwind.rb +71 -0
  36. data/lib/cyrel/expression/literal.rb +9 -2
  37. data/lib/cyrel/expression/property_access.rb +1 -1
  38. data/lib/cyrel/pattern/node.rb +11 -1
  39. data/lib/cyrel/pattern/relationship.rb +21 -13
  40. data/lib/cyrel/query.rb +405 -91
  41. data/lib/cyrel.rb +132 -2
  42. metadata +29 -1
data/lib/cyrel/query.rb CHANGED
@@ -27,6 +27,7 @@ module Cyrel
27
27
  @parameters = {}
28
28
  @param_counter = 0
29
29
  @clauses = [] # Holds instances of Clause::Base subclasses, because arrays are the new query planner
30
+ @loop_variables = Set.new # Track loop variables for FOREACH context
30
31
  end
31
32
 
32
33
  # Registers a value and returns a parameter key.
@@ -35,6 +36,11 @@ module Cyrel
35
36
  # @return [Symbol] The parameter key (e.g., :p1, :p2).
36
37
  # Because nothing says "safe query" like a parade of anonymous parameters.
37
38
  def register_parameter(value)
39
+ # Don't parameterize loop variables in FOREACH context
40
+ if value.is_a?(Symbol) && @loop_variables.include?(value)
41
+ return value # Return the symbol itself, not a parameter key
42
+ end
43
+
38
44
  existing_key = @parameters.key(value)
39
45
  return existing_key if existing_key
40
46
 
@@ -57,6 +63,7 @@ module Cyrel
57
63
  def to_cypher
58
64
  ActiveSupport::Notifications.instrument('cyrel.render', query: self) do
59
65
  cypher_string = @clauses
66
+ .sort_by { |clause| clause_order(clause) }
60
67
  .map { it.render(self) }
61
68
  .reject(&:blank?)
62
69
  .join("\n")
@@ -98,8 +105,10 @@ module Cyrel
98
105
  # @return [self]
99
106
  # Because nothing says "find me" like a declarative pattern and a prayer.
100
107
  def match(pattern, path_variable: nil)
101
- # TODO: Add implicit pattern construction from Hash/Array if desired.
102
- add_clause(Clause::Match.new(pattern, optional: false, path_variable: path_variable))
108
+ # Use AST-based implementation
109
+ match_node = AST::MatchNode.new(pattern, optional: false, path_variable: path_variable)
110
+ ast_clause = AST::ClauseAdapter.new(match_node)
111
+ add_clause(ast_clause)
103
112
  end
104
113
 
105
114
  # Adds an OPTIONAL MATCH clause.
@@ -108,7 +117,10 @@ module Cyrel
108
117
  # @return [self]
109
118
  # For when you want to be non-committal, even in your queries.
110
119
  def optional_match(pattern, path_variable: nil)
111
- add_clause(Clause::Match.new(pattern, optional: true, path_variable: path_variable))
120
+ # Use AST-based implementation
121
+ match_node = AST::MatchNode.new(pattern, optional: true, path_variable: path_variable)
122
+ ast_clause = AST::ClauseAdapter.new(match_node)
123
+ add_clause(ast_clause)
112
124
  end
113
125
 
114
126
  # Adds a WHERE clause (merging with an existing one if present).
@@ -141,14 +153,26 @@ module Cyrel
141
153
  end
142
154
  end
143
155
 
144
- new_where = Clause::Where.new(*processed_conditions)
156
+ # Use AST-based implementation
157
+ where_node = AST::WhereNode.new(processed_conditions)
158
+ ast_clause = AST::ClauseAdapter.new(where_node)
145
159
 
146
160
  # ------------------------------------------------------------------
147
161
  # 2. Merge with an existing WHERE (if any)
148
162
  # ------------------------------------------------------------------
149
- existing_where = @clauses.find { |c| c.is_a?(Clause::Where) }
150
- if existing_where
151
- existing_where.merge!(new_where)
163
+ existing_where_index = @clauses.find_index { |c| c.is_a?(Clause::Where) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode)) }
164
+
165
+ if existing_where_index
166
+ existing_clause = @clauses[existing_where_index]
167
+ if existing_clause.is_a?(AST::ClauseAdapter) && existing_clause.ast_node.is_a?(AST::WhereNode)
168
+ # Merge conditions by creating a new WHERE node with combined conditions
169
+ combined_conditions = existing_clause.ast_node.conditions + processed_conditions
170
+ merged_where_node = AST::WhereNode.new(combined_conditions)
171
+ @clauses[existing_where_index] = AST::ClauseAdapter.new(merged_where_node)
172
+ else
173
+ # Replace old-style WHERE with AST WHERE
174
+ @clauses[existing_where_index] = ast_clause
175
+ end
152
176
  return self
153
177
  end
154
178
 
@@ -163,9 +187,9 @@ module Cyrel
163
187
  end
164
188
 
165
189
  if insertion_index
166
- @clauses.insert(insertion_index, new_where)
190
+ @clauses.insert(insertion_index, ast_clause)
167
191
  else
168
- @clauses << new_where
192
+ @clauses << ast_clause
169
193
  end
170
194
 
171
195
  self
@@ -176,18 +200,23 @@ module Cyrel
176
200
  # @return [self]
177
201
  # Because sometimes you want to make things, not just break them.
178
202
  def create(pattern)
179
- # TODO: Add implicit pattern construction
180
- add_clause(Clause::Create.new(pattern))
203
+ # Use AST-based implementation
204
+ create_node = AST::CreateNode.new(pattern)
205
+ ast_clause = AST::ClauseAdapter.new(create_node)
206
+ add_clause(ast_clause)
181
207
  end
182
208
 
183
209
  # Adds a MERGE clause.
184
210
  # @param pattern [Cyrel::Pattern::Path, Node, Relationship, Hash, Array] Pattern definition.
211
+ # @param on_create [Array, Hash] Optional ON CREATE SET assignments
212
+ # @param on_match [Array, Hash] Optional ON MATCH SET assignments
185
213
  # @return [self]
186
214
  # For when you want to find-or-create, but with more existential angst.
187
- def merge(pattern)
188
- # TODO: Add implicit pattern construction
189
- # TODO: Add ON CREATE SET / ON MATCH SET options
190
- add_clause(Clause::Merge.new(pattern))
215
+ def merge(pattern, on_create: nil, on_match: nil)
216
+ # Use AST-based implementation
217
+ merge_node = AST::MergeNode.new(pattern, on_create: on_create, on_match: on_match)
218
+ ast_clause = AST::ClauseAdapter.new(merge_node)
219
+ add_clause(ast_clause)
191
220
  end
192
221
 
193
222
  # Adds a SET clause.
@@ -195,8 +224,65 @@ module Cyrel
195
224
  # @return [self]
196
225
  # Because sometimes you just want to change everything and pretend it was always that way.
197
226
  def set(assignments)
198
- # TODO: Consider merging SET clauses intelligently if needed.
199
- add_clause(Clause::Set.new(assignments))
227
+ # Process assignments similar to existing Set clause
228
+ processed_assignments = case assignments
229
+ when Hash
230
+ assignments.flat_map do |key, value|
231
+ case key
232
+ when Expression::PropertyAccess
233
+ # SET n.prop = value
234
+ [[:property, key, Expression.coerce(value)]]
235
+ when Symbol, String
236
+ # SET n = properties
237
+ raise ArgumentError, 'Value for variable assignment must be a Hash' unless value.is_a?(Hash)
238
+
239
+ [[:variable_properties, key.to_sym, Expression.coerce(value), :assign]]
240
+ when Cyrel::Plus
241
+ # SET n += properties
242
+ raise ArgumentError, 'Value for variable assignment must be a Hash' unless value.is_a?(Hash)
243
+
244
+ [[:variable_properties, key.variable.to_sym, Expression.coerce(value), :merge]]
245
+ else
246
+ raise ArgumentError, "Invalid key type in SET assignments: #{key.class}"
247
+ end
248
+ end
249
+ when Array
250
+ assignments.map do |item|
251
+ unless item.is_a?(Array) && item.length == 2
252
+ raise ArgumentError, "Invalid label assignment format. Expected [[:variable, 'Label'], ...], got #{item.inspect}"
253
+ end
254
+
255
+ # SET n:Label
256
+ [:label, item[0].to_sym, item[1]]
257
+ end
258
+ else
259
+ raise ArgumentError, "Invalid assignments type: #{assignments.class}"
260
+ end
261
+
262
+ set_node = AST::SetNode.new(processed_assignments)
263
+ ast_clause = AST::ClauseAdapter.new(set_node)
264
+
265
+ # Check for existing SET clause to merge with
266
+ existing_set_index = @clauses.find_index { |c| c.is_a?(Clause::Set) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::SetNode)) }
267
+
268
+ if existing_set_index
269
+ existing_clause = @clauses[existing_set_index]
270
+ if existing_clause.is_a?(AST::ClauseAdapter) && existing_clause.ast_node.is_a?(AST::SetNode)
271
+ # Merge with existing AST SET node by creating a new one with combined assignments
272
+ combined_assignments = existing_clause.ast_node.assignments + processed_assignments
273
+ merged_set_node = AST::SetNode.new(combined_assignments)
274
+ else
275
+ # Replace old clause-based SET with merged AST version
276
+ combined_assignments = existing_clause.assignments + set_node.assignments
277
+ merged_set_node = AST::SetNode.new({})
278
+ merged_set_node.instance_variable_set(:@assignments, combined_assignments)
279
+ end
280
+ @clauses[existing_set_index] = AST::ClauseAdapter.new(merged_set_node)
281
+ else
282
+ add_clause(ast_clause)
283
+ end
284
+
285
+ self
200
286
  end
201
287
 
202
288
  # Adds a REMOVE clause.
@@ -204,8 +290,10 @@ module Cyrel
204
290
  # @return [self]
205
291
  # For when you want to Marie Kondo your graph.
206
292
  def remove(*items)
207
- # TODO: Consider merging REMOVE clauses.
208
- add_clause(Clause::Remove.new(items)) # Pass array directly
293
+ # Use AST-based implementation
294
+ remove_node = AST::RemoveNode.new(items)
295
+ ast_clause = AST::ClauseAdapter.new(remove_node)
296
+ add_clause(ast_clause)
209
297
  end
210
298
 
211
299
  # Adds a DELETE clause. Use `detach_delete` for DETACH DELETE.
@@ -214,7 +302,10 @@ module Cyrel
214
302
  # Underscore to avoid keyword clash
215
303
  # Because sometimes you just want to watch the world burn, one node at a time.
216
304
  def delete_(*variables)
217
- add_clause(Clause::Delete.new(*variables, detach: false))
305
+ # Use AST-based implementation
306
+ delete_node = AST::DeleteNode.new(variables, detach: false)
307
+ ast_clause = AST::ClauseAdapter.new(delete_node)
308
+ add_clause(ast_clause)
218
309
  end
219
310
 
220
311
  # Adds a DETACH DELETE clause.
@@ -222,7 +313,10 @@ module Cyrel
222
313
  # @return [self]
223
314
  # For when you want to delete with extreme prejudice.
224
315
  def detach_delete(*variables)
225
- add_clause(Clause::Delete.new(*variables, detach: true))
316
+ # Use AST-based implementation
317
+ delete_node = AST::DeleteNode.new(variables, detach: true)
318
+ ast_clause = AST::ClauseAdapter.new(delete_node)
319
+ add_clause(ast_clause)
226
320
  end
227
321
 
228
322
  # Adds a WITH clause.
@@ -232,23 +326,104 @@ module Cyrel
232
326
  # @return [self]
233
327
  # Because sometimes you want to pass things along, and sometimes you just want to pass the buck.
234
328
  def with(*items, distinct: false, where: nil)
235
- where_clause = case where
236
- when Clause::Where then where
237
- when nil then nil
238
- else Clause::Where.new(*Array(where)) # Coerce Hash/Array/Expression
239
- end
240
- add_clause(Clause::With.new(*items, distinct: distinct, where: where_clause))
329
+ # Process items similar to existing Return clause
330
+ processed_items = items.flatten.map do |item|
331
+ case item
332
+ when Expression::Base
333
+ item
334
+ when Symbol
335
+ # Create a RawIdentifier for variable names
336
+ Clause::Return::RawIdentifier.new(item.to_s)
337
+ when String
338
+ # Check if string looks like property access (e.g. "person.name")
339
+ # If so, treat as raw identifier, otherwise parameterize
340
+ if item.match?(/\A\w+\.\w+\z/)
341
+ Clause::Return::RawIdentifier.new(item)
342
+ else
343
+ # String literals should be coerced to expressions (parameterized)
344
+ Expression.coerce(item)
345
+ end
346
+ else
347
+ Expression.coerce(item)
348
+ end
349
+ end
350
+
351
+ # Process WHERE conditions if provided
352
+ where_conditions = case where
353
+ when nil then []
354
+ when Hash
355
+ # Convert hash to equality comparisons
356
+ where.map do |key, value|
357
+ Expression::Comparison.new(
358
+ Expression::PropertyAccess.new(@current_alias || infer_alias, key),
359
+ :'=',
360
+ value
361
+ )
362
+ end
363
+ when Array then where
364
+ else [where] # Single condition
365
+ end
366
+
367
+ # Use AST-based implementation
368
+ with_node = AST::WithNode.new(processed_items, distinct: distinct, where_conditions: where_conditions)
369
+ ast_clause = AST::ClauseAdapter.new(with_node)
370
+
371
+ # Find and replace existing with or add new one
372
+ existing_with_index = @clauses.find_index { |c| c.is_a?(Clause::With) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WithNode)) }
373
+
374
+ if existing_with_index
375
+ @clauses[existing_with_index] = ast_clause
376
+ else
377
+ add_clause(ast_clause)
378
+ end
379
+
380
+ self
241
381
  end
242
382
 
243
383
  # Adds a RETURN clause.
244
384
  # @param items [Array] Items to return. See Clause::Return#initialize.
245
385
  # @param distinct [Boolean] Use DISTINCT?
246
386
  # @return [self]
247
- # Underscore to avoid keyword clash
248
- # Because what you really want is your data, but what you'll get is a hash.
387
+ #
388
+ # Note: Method is named `return_` with an underscore suffix because `return`
389
+ # is a reserved keyword in Ruby. We're not crazy - we just want to provide
390
+ # a clean DSL while respecting Ruby's language constraints.
249
391
  def return_(*items, distinct: false)
250
- # TODO: Consider merging RETURN clauses?
251
- add_clause(Clause::Return.new(*items, distinct: distinct))
392
+ # Process items similar to existing Return clause
393
+ processed_items = items.flatten.map do |item|
394
+ case item
395
+ when Expression::Base
396
+ item
397
+ when Symbol
398
+ # Create a RawIdentifier for variable names
399
+ Clause::Return::RawIdentifier.new(item.to_s)
400
+ when String
401
+ # Check if string looks like property access (e.g. "person.name")
402
+ # If so, treat as raw identifier, otherwise parameterize
403
+ if item.match?(/\A\w+\.\w+\z/)
404
+ Clause::Return::RawIdentifier.new(item)
405
+ else
406
+ # String literals should be coerced to expressions (parameterized)
407
+ Expression.coerce(item)
408
+ end
409
+ else
410
+ Expression.coerce(item)
411
+ end
412
+ end
413
+
414
+ # Use AST-based implementation
415
+ return_node = AST::ReturnNode.new(processed_items, distinct: distinct)
416
+ ast_clause = AST::ClauseAdapter.new(return_node)
417
+
418
+ # Find and replace existing return or add new one
419
+ existing_return_index = @clauses.find_index { |c| c.is_a?(Clause::Return) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::ReturnNode)) }
420
+
421
+ if existing_return_index
422
+ @clauses[existing_return_index] = ast_clause
423
+ else
424
+ add_clause(ast_clause)
425
+ end
426
+ self
252
427
  end
253
428
 
254
429
  # Adds or replaces the ORDER BY clause.
@@ -260,12 +435,17 @@ module Cyrel
260
435
  def order_by(*order_items)
261
436
  items_array = order_items.first.is_a?(Hash) ? order_items.first.to_a : order_items
262
437
 
263
- existing_order = @clauses.find { |c| c.is_a?(Clause::OrderBy) }
264
- new_order = Clause::OrderBy.new(*items_array)
265
- if existing_order
266
- existing_order.replace!(new_order)
438
+ # Use AST-based implementation
439
+ order_by_node = AST::OrderByNode.new(items_array)
440
+ ast_clause = AST::ClauseAdapter.new(order_by_node)
441
+
442
+ # Find and replace existing order by or add new one
443
+ existing_order_index = @clauses.find_index { |c| c.is_a?(Clause::OrderBy) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::OrderByNode)) }
444
+
445
+ if existing_order_index
446
+ @clauses[existing_order_index] = ast_clause
267
447
  else
268
- add_clause(new_order)
448
+ add_clause(ast_clause)
269
449
  end
270
450
  self
271
451
  end
@@ -275,12 +455,17 @@ module Cyrel
275
455
  # @return [self]
276
456
  # For when you want to ignore the first N results, just like your unread emails.
277
457
  def skip(amount)
278
- existing_skip = @clauses.find { |c| c.is_a?(Clause::Skip) }
279
- new_skip = Clause::Skip.new(amount)
280
- if existing_skip
281
- existing_skip.replace!(new_skip)
458
+ # Use AST-based implementation
459
+ skip_node = AST::SkipNode.new(amount)
460
+ ast_clause = AST::ClauseAdapter.new(skip_node)
461
+
462
+ # Find and replace existing skip or add new one
463
+ existing_skip_index = @clauses.find_index { |c| c.is_a?(Clause::Skip) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::SkipNode)) }
464
+
465
+ if existing_skip_index
466
+ @clauses[existing_skip_index] = ast_clause
282
467
  else
283
- add_clause(new_skip)
468
+ add_clause(ast_clause)
284
469
  end
285
470
  self
286
471
  end
@@ -290,13 +475,19 @@ module Cyrel
290
475
  # @return [self]
291
476
  # Because sometimes you want boundaries, even in your queries.
292
477
  def limit(amount)
293
- existing_limit = @clauses.find { |c| c.is_a?(Clause::Limit) }
294
- new_limit = Clause::Limit.new(amount)
295
- if existing_limit
296
- existing_limit.replace!(new_limit)
478
+ # Use AST-based implementation
479
+ limit_node = AST::LimitNode.new(amount)
480
+ ast_clause = AST::ClauseAdapter.new(limit_node)
481
+
482
+ # Find and replace existing limit or add new one
483
+ existing_limit_index = @clauses.find_index { |c| c.is_a?(Clause::Limit) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::LimitNode)) }
484
+
485
+ if existing_limit_index
486
+ @clauses[existing_limit_index] = ast_clause
297
487
  else
298
- add_clause(new_limit)
488
+ add_clause(ast_clause)
299
489
  end
490
+
300
491
  self
301
492
  end
302
493
 
@@ -309,11 +500,21 @@ module Cyrel
309
500
  # @return [self]
310
501
  # For when you want to call a procedure and pretend it's not just another query.
311
502
  def call_procedure(procedure_name, arguments: [], yield_items: nil, where: nil, return_items: nil)
312
- add_clause(Clause::Call.new(procedure_name,
313
- arguments: arguments,
314
- yield_items: yield_items,
315
- where: where,
316
- return_items: return_items))
503
+ # Use AST-based implementation for simple CALL
504
+ # Note: WHERE and RETURN after YIELD are not yet supported in AST version
505
+ if where || return_items
506
+ # Fall back to clause-based for complex cases
507
+ add_clause(Clause::Call.new(procedure_name,
508
+ arguments: arguments,
509
+ yield_items: yield_items,
510
+ where: where,
511
+ return_items: return_items))
512
+ else
513
+ call_node = AST::CallNode.new(procedure_name, arguments: arguments, yield_items: yield_items)
514
+ ast_clause = AST::ClauseAdapter.new(call_node)
515
+ add_clause(ast_clause)
516
+ end
517
+ self
317
518
  end
318
519
 
319
520
  # Adds a CALL { subquery } clause.
@@ -323,15 +524,87 @@ module Cyrel
323
524
  def call_subquery
324
525
  subquery = Cyrel::Query.new
325
526
  yield subquery
326
- # Important: Parameters defined within the subquery block are currently
327
- # NOT automatically merged into the outer query's parameters by this DSL method.
328
- # This needs to be handled either by manually merging parameters after the block
329
- # or by enhancing the rendering/parameter registration logic.
330
- add_clause(Clause::CallSubquery.new(subquery))
331
- # Consider adding: merge_parameters!(subquery) here, but it might re-register params.
527
+ # Use AST-based implementation
528
+ call_subquery_node = AST::CallSubqueryNode.new(subquery)
529
+ ast_clause = AST::ClauseAdapter.new(call_subquery_node)
530
+ add_clause(ast_clause)
531
+ end
532
+
533
+ # Adds an UNWIND clause.
534
+ # @param expression [Array, Symbol, Object] The list expression to unwind
535
+ # @param variable [Symbol, String] The variable name to bind each element to
536
+ # @return [self]
537
+ # For when you want to turn one row with a list into many rows with values,
538
+ # like unpacking a suitcase but for data
539
+ # Example: query.unwind([1,2,3], :x).return_(:x)
540
+ # query.unwind(:names, :name).create(...)
541
+ def unwind(expression, variable)
542
+ # Create an AST UnwindNode wrapped in a ClauseAdapter
543
+ ast_node = AST::UnwindNode.new(expression, variable)
544
+ add_clause(AST::ClauseAdapter.new(ast_node))
332
545
  end
333
546
 
334
547
  # No longer private, needed by merge!
548
+ # Combines this query with another using UNION
549
+ # @param other_query [Cyrel::Query] The query to union with
550
+ # @return [Cyrel::Query] A new query representing the union
551
+ def union(other_query)
552
+ self.class.union_queries([self, other_query], all: false)
553
+ end
554
+
555
+ # Combines this query with another using UNION ALL
556
+ # @param other_query [Cyrel::Query] The query to union with
557
+ # @return [Cyrel::Query] A new query representing the union
558
+ def union_all(other_query)
559
+ self.class.union_queries([self, other_query], all: true)
560
+ end
561
+
562
+ # Combines multiple queries using UNION or UNION ALL
563
+ # @param queries [Array<Cyrel::Query>] The queries to combine
564
+ # @param all [Boolean] Whether to use UNION ALL (true) or UNION (false)
565
+ # @return [Cyrel::Query] A new query representing the union
566
+ def self.union_queries(queries, all: false)
567
+ raise ArgumentError, 'UNION requires at least 2 queries' if queries.size < 2
568
+
569
+ # Create a new query that represents the union
570
+ union_query = new
571
+ union_node = AST::UnionNode.new(queries, all: all)
572
+ union_query.add_clause(AST::ClauseAdapter.new(union_node))
573
+ union_query
574
+ end
575
+
576
+ # Adds a FOREACH clause for iterating over a list with update operations
577
+ # @param variable [Symbol] The iteration variable
578
+ # @param expression [Expression, Array] The list to iterate over
579
+ # @param update_clauses [Array<Clause>] The update clauses to execute for each element
580
+ # @return [self]
581
+ def foreach(variable, expression)
582
+ # If a block is given, create a sub-query context for update clauses
583
+ raise ArgumentError, 'FOREACH requires a block with update clauses' unless block_given?
584
+
585
+ sub_query = self.class.new
586
+ # Pass loop variable context to sub-query
587
+ sub_query.instance_variable_set(:@loop_variables, @loop_variables.dup)
588
+ sub_query.instance_variable_get(:@loop_variables).add(variable.to_sym)
589
+
590
+ yield sub_query
591
+ update_clauses = sub_query.clauses
592
+
593
+ foreach_node = AST::ForeachNode.new(variable, expression, update_clauses)
594
+ add_clause(AST::ClauseAdapter.new(foreach_node))
595
+ end
596
+
597
+ # Adds a LOAD CSV clause for importing CSV data
598
+ # @param url [String] The URL or file path to load CSV from
599
+ # @param variable [Symbol] The variable to bind each row to
600
+ # @param with_headers [Boolean] Whether the CSV has headers
601
+ # @param fieldterminator [String] The field delimiter (default is comma)
602
+ # @return [self]
603
+ def load_csv(from:, as:, with_headers: false, fieldterminator: nil)
604
+ load_csv_node = AST::LoadCsvNode.new(from, as, with_headers: with_headers, fieldterminator: fieldterminator)
605
+ add_clause(AST::ClauseAdapter.new(load_csv_node))
606
+ end
607
+
335
608
  # private
336
609
 
337
610
  # Merges parameters from another query, ensuring keys are unique.
@@ -353,17 +626,34 @@ module Cyrel
353
626
  # Provides a sort order for clauses during rendering. Lower numbers come first.
354
627
  # Because even your clauses need to know their place in the world.
355
628
  def clause_order(clause)
356
- case clause
357
- when Clause::Match, Clause::Create, Clause::Merge then 10 # Reading/Writing/Merging clauses
358
- when Clause::Call, Clause::CallSubquery then 15 # CALL often follows MATCH/CREATE
359
- when Clause::With then 20
360
- when Clause::Where then 30 # Filtering clauses
361
- when Clause::Set, Clause::Remove, Clause::Delete then 40 # Modifying clauses
362
- when Clause::Return then 50 # Projection
363
- when Clause::OrderBy then 60 # Ordering/Paging
364
- when Clause::Skip then 70
365
- when Clause::Limit then 80
366
- else 99 # Unknown clauses go last
629
+ # All clauses should be AST-based now
630
+ return 997 unless clause.is_a?(AST::ClauseAdapter)
631
+
632
+ # Clause ordering values - lower numbers come first
633
+ case clause.ast_node
634
+ when AST::LoadCsvNode then 2
635
+ when AST::MatchNode then 5
636
+ when AST::CallNode, AST::CallSubqueryNode then 7
637
+ when AST::WhereNode
638
+ # WHERE can come after different clauses - check what came before
639
+ # This is a simplified approach - a more sophisticated one would
640
+ # track the actual clause relationships
641
+ has_load_csv = @clauses.any? { |c| c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::LoadCsvNode) }
642
+ has_load_csv ? 3 : 11
643
+ when AST::WithNode then 13
644
+ when AST::UnwindNode then 17
645
+ when AST::CreateNode then 23
646
+ when AST::MergeNode then 23
647
+ when AST::SetNode then 29
648
+ when AST::RemoveNode then 29
649
+ when AST::DeleteNode then 29
650
+ when AST::ForeachNode then 31
651
+ when AST::ReturnNode then 37
652
+ when AST::OrderByNode then 41
653
+ when AST::SkipNode then 43
654
+ when AST::LimitNode then 47
655
+ when AST::UnionNode then 53
656
+ else 997
367
657
  end
368
658
  end
369
659
 
@@ -373,15 +663,22 @@ module Cyrel
373
663
  def defined_aliases
374
664
  aliases = {}
375
665
  @clauses.each do |clause|
376
- # Look for clauses that define patterns (Match, Create, Merge)
377
- next unless clause.respond_to?(:pattern) && clause.pattern
666
+ # Look for AST clauses that define patterns (Match, Create, Merge)
667
+ next unless clause.is_a?(AST::ClauseAdapter)
668
+
669
+ pattern = case clause.ast_node
670
+ when AST::MatchNode, AST::CreateNode, AST::MergeNode
671
+ clause.ast_node.pattern
672
+ end
673
+
674
+ next unless pattern
378
675
 
379
676
  elements_to_check = []
380
- case clause.pattern
677
+ case pattern
381
678
  when Pattern::Path
382
- elements_to_check.concat(clause.pattern.elements)
679
+ elements_to_check.concat(pattern.elements)
383
680
  when Pattern::Node, Pattern::Relationship
384
- elements_to_check << clause.pattern
681
+ elements_to_check << pattern
385
682
  end
386
683
 
387
684
  elements_to_check.each do |element|
@@ -433,41 +730,52 @@ module Cyrel
433
730
  other_clauses_to_process = other_query.clauses.dup
434
731
 
435
732
  # --- Handle Replacing Clauses (OrderBy, Skip, Limit) ---
436
- [Clause::OrderBy, Clause::Skip, Clause::Limit].each do |clause_class|
733
+ [AST::OrderByNode, AST::SkipNode, AST::LimitNode].each do |ast_class|
734
+ # Helper to check if a clause matches the type we're looking for
735
+ clause_matcher = lambda do |c|
736
+ c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(ast_class)
737
+ end
738
+
437
739
  # Find the last occurrence in the other query's clauses
438
- other_clause = other_clauses_to_process.reverse.find { |c| c.is_a?(clause_class) }
740
+ other_clause = other_clauses_to_process.reverse.find(&clause_matcher)
439
741
  next unless other_clause
440
742
 
441
743
  # Find the clause in self, if it exists
442
- self_clause = @clauses.find { |c| c.is_a?(clause_class) }
744
+ self_clause = @clauses.find(&clause_matcher)
443
745
 
444
- if self_clause.respond_to?(:replace!)
445
- # If self has the clause and it supports replace!, replace it
446
- self_clause.replace!(other_clause)
746
+ if self_clause && other_clause
747
+ # Replace the existing clause
748
+ self_clause_index = @clauses.index(self_clause)
749
+ @clauses[self_clause_index] = other_clause
447
750
  elsif !self_clause
448
751
  # If self doesn't have the clause, add the one from other_query
449
752
  add_clause(other_clause)
450
- # Else: self has the clause but doesn't support replace! - do nothing (keep self's)
451
753
  end
452
754
 
453
755
  # Remove *all* occurrences of this clause type from the list to process further
454
- other_clauses_to_process.delete_if { |c| c.is_a?(clause_class) }
756
+ other_clauses_to_process.delete_if(&clause_matcher)
455
757
  end
456
758
 
457
759
  # --- Handle Merging Clauses (Where) ---
458
- other_wheres = other_query.clauses.select { |c| c.is_a?(Clause::Where) }
760
+ other_wheres = other_query.clauses.select { |c| c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode) }
459
761
  unless other_wheres.empty?
460
- self_where = @clauses.find { |c| c.is_a?(Clause::Where) }
762
+ self_where = @clauses.find { |c| c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode) }
461
763
  if self_where
462
- other_wheres.each { |ow| self_where.merge!(ow) }
764
+ # For AST WHERE nodes, we need to merge the conditions
765
+ other_wheres.each do |ow|
766
+ # Extract conditions from both WHERE nodes and create a new merged one
767
+ self_conditions = self_where.ast_node.conditions
768
+ other_conditions = ow.ast_node.conditions
769
+ merged_where_node = AST::WhereNode.new(self_conditions + other_conditions)
770
+ self_where_index = @clauses.index(self_where)
771
+ @clauses[self_where_index] = AST::ClauseAdapter.new(merged_where_node)
772
+ end
463
773
  else
464
- # Add the first other_where and merge the rest into it
465
- first_other_where = other_wheres.shift
466
- add_clause(first_other_where)
467
- other_wheres.each { |ow| first_other_where.merge!(ow) }
774
+ # Add the first other_where
775
+ add_clause(other_wheres.first)
468
776
  end
469
777
  # Remove processed clauses
470
- other_clauses_to_process.delete_if { |c| c.is_a?(Clause::Where) }
778
+ other_clauses_to_process.delete_if { |c| c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode) }
471
779
  end
472
780
 
473
781
  # --- Handle Appending Clauses (Match, Create, Set, Remove, Delete, With, Return, Call, etc.) ---
@@ -485,9 +793,15 @@ module Cyrel
485
793
  def infer_alias
486
794
  # Find first Node alias defined in MATCH/CREATE/MERGE clauses
487
795
  @clauses.each do |clause|
488
- next unless clause.respond_to?(:pattern) && clause.pattern
796
+ next unless clause.is_a?(AST::ClauseAdapter)
797
+
798
+ pattern = case clause.ast_node
799
+ when AST::MatchNode, AST::CreateNode, AST::MergeNode
800
+ clause.ast_node.pattern
801
+ end
802
+
803
+ next unless pattern
489
804
 
490
- pattern = clause.pattern
491
805
  element = pattern.is_a?(Pattern::Path) ? pattern.elements.first : pattern
492
806
  return element.alias_name if element.is_a?(Pattern::Node) && element.alias_name
493
807
  end