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.
- checksums.yaml +4 -4
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +10 -1
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +1 -1
- data/lib/active_cypher/connection_adapters/persistence_methods.rb +31 -14
- data/lib/active_cypher/relation.rb +1 -1
- data/lib/active_cypher/version.rb +1 -1
- data/lib/activecypher.rb +3 -1
- data/lib/cyrel/ast/call_node.rb +39 -0
- data/lib/cyrel/ast/clause_adapter.rb +38 -0
- data/lib/cyrel/ast/clause_node.rb +10 -0
- data/lib/cyrel/ast/compiler.rb +609 -0
- data/lib/cyrel/ast/create_node.rb +21 -0
- data/lib/cyrel/ast/delete_node.rb +22 -0
- data/lib/cyrel/ast/expression_node.rb +10 -0
- data/lib/cyrel/ast/foreach_node.rb +23 -0
- data/lib/cyrel/ast/limit_node.rb +21 -0
- data/lib/cyrel/ast/literal_node.rb +39 -0
- data/lib/cyrel/ast/load_csv_node.rb +24 -0
- data/lib/cyrel/ast/match_node.rb +23 -0
- data/lib/cyrel/ast/merge_node.rb +23 -0
- data/lib/cyrel/ast/node.rb +36 -0
- data/lib/cyrel/ast/optimized_nodes.rb +117 -0
- data/lib/cyrel/ast/order_by_node.rb +21 -0
- data/lib/cyrel/ast/pattern_node.rb +10 -0
- data/lib/cyrel/ast/query_integrated_compiler.rb +27 -0
- data/lib/cyrel/ast/remove_node.rb +21 -0
- data/lib/cyrel/ast/return_node.rb +21 -0
- data/lib/cyrel/ast/set_node.rb +20 -0
- data/lib/cyrel/ast/simple_cache.rb +50 -0
- data/lib/cyrel/ast/skip_node.rb +19 -0
- data/lib/cyrel/ast/union_node.rb +22 -0
- data/lib/cyrel/ast/unwind_node.rb +20 -0
- data/lib/cyrel/ast/where_node.rb +20 -0
- data/lib/cyrel/ast/with_node.rb +23 -0
- data/lib/cyrel/clause/unwind.rb +71 -0
- data/lib/cyrel/expression/literal.rb +9 -2
- data/lib/cyrel/expression/property_access.rb +1 -1
- data/lib/cyrel/pattern/node.rb +11 -1
- data/lib/cyrel/pattern/relationship.rb +21 -13
- data/lib/cyrel/query.rb +405 -91
- data/lib/cyrel.rb +132 -2
- 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
|
-
#
|
102
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
150
|
-
|
151
|
-
|
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,
|
190
|
+
@clauses.insert(insertion_index, ast_clause)
|
167
191
|
else
|
168
|
-
@clauses <<
|
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
|
-
#
|
180
|
-
|
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
|
-
#
|
189
|
-
|
190
|
-
|
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
|
-
#
|
199
|
-
|
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
|
-
#
|
208
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
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
|
-
#
|
248
|
-
#
|
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
|
-
#
|
251
|
-
|
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
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
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(
|
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
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
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(
|
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
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
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(
|
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
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
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
|
-
#
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
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
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
when
|
362
|
-
when
|
363
|
-
when
|
364
|
-
when
|
365
|
-
|
366
|
-
|
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.
|
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
|
677
|
+
case pattern
|
381
678
|
when Pattern::Path
|
382
|
-
elements_to_check.concat(
|
679
|
+
elements_to_check.concat(pattern.elements)
|
383
680
|
when Pattern::Node, Pattern::Relationship
|
384
|
-
elements_to_check <<
|
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
|
-
[
|
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
|
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
|
744
|
+
self_clause = @clauses.find(&clause_matcher)
|
443
745
|
|
444
|
-
if self_clause
|
445
|
-
#
|
446
|
-
|
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
|
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?(
|
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?(
|
762
|
+
self_where = @clauses.find { |c| c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode) }
|
461
763
|
if self_where
|
462
|
-
|
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
|
465
|
-
|
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?(
|
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.
|
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
|