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
@@ -0,0 +1,609 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stringio'
|
4
|
+
|
5
|
+
module Cyrel
|
6
|
+
module AST
|
7
|
+
# Compiles AST nodes into Cypher queries with optional thread-safety
|
8
|
+
# It's like Google Translate, but for graph databases and with more reliable results
|
9
|
+
class Compiler
|
10
|
+
attr_reader :output, :parameters
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@output = StringIO.new
|
14
|
+
if defined?(Concurrent)
|
15
|
+
@parameters = Concurrent::Hash.new
|
16
|
+
@param_counter = Concurrent::AtomicFixnum.new(0)
|
17
|
+
else
|
18
|
+
@parameters = {}
|
19
|
+
@param_counter = 0
|
20
|
+
end
|
21
|
+
@first_clause = true
|
22
|
+
@loop_variables = Set.new # Track loop variables that shouldn't be parameterized
|
23
|
+
end
|
24
|
+
|
25
|
+
# Compile an AST node or array of nodes
|
26
|
+
# Returns [cypher_string, parameters_hash]
|
27
|
+
def compile(node_or_nodes)
|
28
|
+
nodes = Array(node_or_nodes)
|
29
|
+
|
30
|
+
nodes.each do |node|
|
31
|
+
add_clause_separator unless @first_clause
|
32
|
+
node.accept(self)
|
33
|
+
@first_clause = false
|
34
|
+
end
|
35
|
+
|
36
|
+
[@output.string, @parameters]
|
37
|
+
end
|
38
|
+
|
39
|
+
# Visit a LIMIT node
|
40
|
+
# Because sometimes less is more, except in this comment
|
41
|
+
def visit_limit_node(node)
|
42
|
+
@output << 'LIMIT '
|
43
|
+
render_expression(node.expression)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Visit a SKIP node
|
47
|
+
# For when you want to jump ahead in your results
|
48
|
+
def visit_skip_node(node)
|
49
|
+
@output << 'SKIP '
|
50
|
+
render_expression(node.amount)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Visit an ORDER BY node
|
54
|
+
# Because even chaos needs some structure sometimes
|
55
|
+
def visit_order_by_node(node)
|
56
|
+
@output << 'ORDER BY '
|
57
|
+
@in_order_by = true
|
58
|
+
|
59
|
+
node.items.each_with_index do |(expr, direction), index|
|
60
|
+
@output << ', ' if index.positive?
|
61
|
+
render_expression(expr)
|
62
|
+
@output << " #{direction.to_s.upcase}" if direction && direction != :asc
|
63
|
+
end
|
64
|
+
|
65
|
+
@in_order_by = false
|
66
|
+
end
|
67
|
+
|
68
|
+
# Visit a WHERE node
|
69
|
+
# Because sometimes you need to be selective about your relationships
|
70
|
+
def visit_where_node(node)
|
71
|
+
return if node.conditions.empty?
|
72
|
+
|
73
|
+
@output << 'WHERE '
|
74
|
+
|
75
|
+
if node.conditions.length == 1
|
76
|
+
render_expression(node.conditions.first)
|
77
|
+
else
|
78
|
+
# Combine multiple conditions with AND
|
79
|
+
node.conditions.each_with_index do |condition, index|
|
80
|
+
@output << ' AND ' if index.positive?
|
81
|
+
render_expression(condition)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Visit a RETURN node
|
87
|
+
# Where your data comes home to roost
|
88
|
+
def visit_return_node(node)
|
89
|
+
@output << 'RETURN '
|
90
|
+
@output << 'DISTINCT ' if node.distinct
|
91
|
+
|
92
|
+
node.items.each_with_index do |item, index|
|
93
|
+
@output << ', ' if index.positive?
|
94
|
+
render_expression(item)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Visit a SET node
|
99
|
+
# Where change happens, one property at a time
|
100
|
+
def visit_set_node(node)
|
101
|
+
return if node.assignments.empty?
|
102
|
+
|
103
|
+
@output << 'SET '
|
104
|
+
|
105
|
+
node.assignments.each_with_index do |assignment, index|
|
106
|
+
@output << ', ' if index.positive?
|
107
|
+
render_assignment(assignment)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Visit a WITH node
|
112
|
+
# Because sometimes you need to pass data along for the next part of your journey
|
113
|
+
def visit_with_node(node)
|
114
|
+
@output << 'WITH '
|
115
|
+
@output << 'DISTINCT ' if node.distinct
|
116
|
+
|
117
|
+
node.items.each_with_index do |item, index|
|
118
|
+
@output << ', ' if index.positive?
|
119
|
+
render_expression(item)
|
120
|
+
end
|
121
|
+
|
122
|
+
# Add WHERE clause if present
|
123
|
+
return unless node.where_conditions && !node.where_conditions.empty?
|
124
|
+
|
125
|
+
@output << "\nWHERE "
|
126
|
+
|
127
|
+
if node.where_conditions.length == 1
|
128
|
+
render_expression(node.where_conditions.first)
|
129
|
+
else
|
130
|
+
# Combine multiple conditions with AND
|
131
|
+
node.where_conditions.each_with_index do |condition, index|
|
132
|
+
@output << ' AND ' if index.positive?
|
133
|
+
render_expression(condition)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# Visit an UNWIND node
|
139
|
+
# Unpacking arrays like unwrapping presents
|
140
|
+
def visit_unwind_node(node)
|
141
|
+
@output << 'UNWIND '
|
142
|
+
|
143
|
+
# Render the expression to unwind
|
144
|
+
if node.expression.is_a?(Array)
|
145
|
+
# Array literal
|
146
|
+
@output << format_array_literal(node.expression)
|
147
|
+
elsif node.expression.is_a?(Symbol)
|
148
|
+
# Parameter reference
|
149
|
+
param_key = register_parameter(node.expression)
|
150
|
+
@output << "$#{param_key}"
|
151
|
+
else
|
152
|
+
# Other expressions
|
153
|
+
render_expression(node.expression)
|
154
|
+
end
|
155
|
+
|
156
|
+
@output << " AS #{node.alias_name}"
|
157
|
+
end
|
158
|
+
|
159
|
+
# Visit a MATCH clause node
|
160
|
+
# Finding nodes in the graph, one pattern at a time
|
161
|
+
def visit_match_node(node)
|
162
|
+
@output << (node.optional ? 'OPTIONAL MATCH ' : 'MATCH ')
|
163
|
+
|
164
|
+
# Handle path variable assignment if present
|
165
|
+
@output << "#{node.path_variable} = " if node.path_variable
|
166
|
+
|
167
|
+
# Render the pattern
|
168
|
+
render_pattern(node.pattern)
|
169
|
+
end
|
170
|
+
|
171
|
+
# Visit a CREATE clause node
|
172
|
+
# Making nodes and relationships appear out of thin air
|
173
|
+
def visit_create_node(node)
|
174
|
+
@output << 'CREATE '
|
175
|
+
render_pattern(node.pattern)
|
176
|
+
end
|
177
|
+
|
178
|
+
# Visit a MERGE clause node
|
179
|
+
# Finding or creating, because commitment issues
|
180
|
+
def visit_merge_node(node)
|
181
|
+
@output << 'MERGE '
|
182
|
+
render_pattern(node.pattern)
|
183
|
+
|
184
|
+
# Handle ON CREATE SET
|
185
|
+
if node.on_create
|
186
|
+
@output << "\nON CREATE SET "
|
187
|
+
render_merge_assignments(node.on_create)
|
188
|
+
end
|
189
|
+
|
190
|
+
# Handle ON MATCH SET
|
191
|
+
return unless node.on_match
|
192
|
+
|
193
|
+
@output << "\nON MATCH SET "
|
194
|
+
render_merge_assignments(node.on_match)
|
195
|
+
end
|
196
|
+
|
197
|
+
# Visit a DELETE clause node
|
198
|
+
# Making nodes disappear, potentially with their relationships
|
199
|
+
def visit_delete_node(node)
|
200
|
+
@output << 'DETACH ' if node.detach
|
201
|
+
@output << 'DELETE '
|
202
|
+
|
203
|
+
node.variables.each_with_index do |var, i|
|
204
|
+
@output << ', ' if i.positive?
|
205
|
+
@output << var.to_s
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
# Visit a REMOVE clause node
|
210
|
+
# Tidying up properties and labels
|
211
|
+
def visit_remove_node(node)
|
212
|
+
@output << 'REMOVE '
|
213
|
+
|
214
|
+
node.items.each_with_index do |item, i|
|
215
|
+
@output << ', ' if i.positive?
|
216
|
+
|
217
|
+
case item
|
218
|
+
when Expression::PropertyAccess
|
219
|
+
# Remove property
|
220
|
+
render_expression(item)
|
221
|
+
when Array
|
222
|
+
# Remove label [variable, label]
|
223
|
+
variable, label = item
|
224
|
+
@output << "#{variable}:#{label}"
|
225
|
+
else
|
226
|
+
raise "Unknown REMOVE item type: #{item.class}"
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
# Visit a CALL clause node (procedure call)
|
232
|
+
# Invoking the stored procedures of the graph database
|
233
|
+
def visit_call_node(node)
|
234
|
+
@output << "CALL #{node.procedure_name}"
|
235
|
+
|
236
|
+
if node.arguments.any?
|
237
|
+
@output << '('
|
238
|
+
node.arguments.each_with_index do |arg, i|
|
239
|
+
@output << ', ' if i.positive?
|
240
|
+
render_expression(Expression.coerce(arg))
|
241
|
+
end
|
242
|
+
@output << ')'
|
243
|
+
end
|
244
|
+
|
245
|
+
return unless node.yield_items
|
246
|
+
|
247
|
+
@output << ' YIELD '
|
248
|
+
if node.yield_items.is_a?(Hash)
|
249
|
+
# YIELD items with aliases
|
250
|
+
node.yield_items.each_with_index do |(item, alias_name), i|
|
251
|
+
@output << ', ' if i.positive?
|
252
|
+
@output << item.to_s
|
253
|
+
@output << " AS #{alias_name}" if alias_name && alias_name != item
|
254
|
+
end
|
255
|
+
else
|
256
|
+
# Simple yield list
|
257
|
+
Array(node.yield_items).each_with_index do |item, i|
|
258
|
+
@output << ', ' if i.positive?
|
259
|
+
@output << item.to_s
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
# Visit a CALL subquery node
|
265
|
+
# Queries all the way down
|
266
|
+
def visit_call_subquery_node(node)
|
267
|
+
@output << "CALL {\n"
|
268
|
+
|
269
|
+
# Render subquery clauses directly without going through the cache
|
270
|
+
# to avoid recursive locking issues
|
271
|
+
subquery = node.subquery
|
272
|
+
subquery_clauses = subquery.clauses.sort_by { |clause| subquery.send(:clause_order, clause) }
|
273
|
+
|
274
|
+
subquery_clauses.each_with_index do |clause, index|
|
275
|
+
@output << "\n" if index.positive? # Add newline between clauses
|
276
|
+
|
277
|
+
if clause.is_a?(AST::ClauseAdapter)
|
278
|
+
# For AST-based clauses, compile directly without cache
|
279
|
+
# Create a proxy object that forwards parameter registration to this compiler
|
280
|
+
parameter_proxy = Object.new
|
281
|
+
parent_compiler = self
|
282
|
+
parameter_proxy.define_singleton_method(:register_parameter) do |value|
|
283
|
+
parent_compiler.send(:register_parameter, value)
|
284
|
+
end
|
285
|
+
|
286
|
+
subquery_compiler = QueryIntegratedCompiler.new(parameter_proxy)
|
287
|
+
clause_cypher, = subquery_compiler.compile(clause.ast_node)
|
288
|
+
@output << clause_cypher.split("\n").map { |line| " #{line}" }.join("\n")
|
289
|
+
else
|
290
|
+
# For legacy clauses, render normally
|
291
|
+
clause_output = clause.render(subquery)
|
292
|
+
@output << clause_output.split("\n").map { |line| " #{line}" }.join("\n") unless clause_output.blank?
|
293
|
+
|
294
|
+
# Merge subquery parameters
|
295
|
+
subquery.parameters.each_value do |value|
|
296
|
+
register_parameter(value)
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
@output << "\n}"
|
302
|
+
end
|
303
|
+
|
304
|
+
# Visit a UNION node
|
305
|
+
# Combining queries like a Cypher mixologist
|
306
|
+
def visit_union_node(node)
|
307
|
+
# UNION is special - it combines complete queries
|
308
|
+
# We need to render each query's clauses directly to avoid recursive locking
|
309
|
+
node.queries.each_with_index do |query, index|
|
310
|
+
if index.positive?
|
311
|
+
@output << "\n"
|
312
|
+
@output << 'UNION'
|
313
|
+
@output << ' ALL' if node.all
|
314
|
+
@output << "\n"
|
315
|
+
end
|
316
|
+
|
317
|
+
# Render query clauses directly without going through cache
|
318
|
+
query_clauses = query.clauses.sort_by { |clause| query.send(:clause_order, clause) }
|
319
|
+
|
320
|
+
query_clauses.each_with_index do |clause, clause_index|
|
321
|
+
@output << "\n" if clause_index.positive?
|
322
|
+
|
323
|
+
if clause.is_a?(AST::ClauseAdapter)
|
324
|
+
# For AST-based clauses, compile directly without cache
|
325
|
+
# Create a proxy object that forwards parameter registration to this compiler
|
326
|
+
parameter_proxy = Object.new
|
327
|
+
parent_compiler = self
|
328
|
+
parameter_proxy.define_singleton_method(:register_parameter) do |value|
|
329
|
+
parent_compiler.send(:register_parameter, value)
|
330
|
+
end
|
331
|
+
|
332
|
+
clause_compiler = QueryIntegratedCompiler.new(parameter_proxy)
|
333
|
+
clause_cypher, = clause_compiler.compile(clause.ast_node)
|
334
|
+
@output << clause_cypher
|
335
|
+
else
|
336
|
+
# For legacy clauses, render normally
|
337
|
+
clause_output = clause.render(query)
|
338
|
+
@output << clause_output unless clause_output.blank?
|
339
|
+
|
340
|
+
# Merge query parameters
|
341
|
+
query.parameters.each_value do |value|
|
342
|
+
register_parameter(value)
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
347
|
+
end
|
348
|
+
|
349
|
+
# Visit a FOREACH node
|
350
|
+
# Iterating through lists like a database therapist
|
351
|
+
def visit_foreach_node(node)
|
352
|
+
@output << "FOREACH (#{node.variable} IN "
|
353
|
+
|
354
|
+
# Handle the expression - could be an array literal or an expression
|
355
|
+
if node.expression.is_a?(Array)
|
356
|
+
# Array literal - convert to parameter
|
357
|
+
param_key = register_parameter(node.expression)
|
358
|
+
@output << "$#{param_key}"
|
359
|
+
elsif node.expression.is_a?(Symbol)
|
360
|
+
# Symbol reference to parameter
|
361
|
+
param_key = register_parameter(node.expression)
|
362
|
+
@output << "$#{param_key}"
|
363
|
+
else
|
364
|
+
# Other expressions
|
365
|
+
render_expression(node.expression)
|
366
|
+
end
|
367
|
+
|
368
|
+
@output << ' | '
|
369
|
+
|
370
|
+
# Track the loop variable so it doesn't get parameterized in inner clauses
|
371
|
+
old_loop_variables = @loop_variables.dup
|
372
|
+
@loop_variables.add(node.variable.to_sym)
|
373
|
+
|
374
|
+
# Render update clauses without duplication
|
375
|
+
node.update_clauses.each_with_index do |clause, index|
|
376
|
+
@output << ' ' if index.positive?
|
377
|
+
|
378
|
+
raise "Unexpected clause type in FOREACH: #{clause.class}" unless clause.is_a?(AST::ClauseAdapter)
|
379
|
+
|
380
|
+
# For AST-based clauses, compile just the inner content
|
381
|
+
# Create a proxy object that forwards parameter registration to this compiler
|
382
|
+
# and inherits the loop variable context
|
383
|
+
parameter_proxy = Object.new
|
384
|
+
parent_compiler = self
|
385
|
+
current_loop_variables = @loop_variables.dup
|
386
|
+
parameter_proxy.define_singleton_method(:register_parameter) do |value|
|
387
|
+
# Check if this is a loop variable that shouldn't be parameterized
|
388
|
+
if value.is_a?(Symbol) && current_loop_variables.include?(value)
|
389
|
+
value # Return the symbol itself, not a parameter key
|
390
|
+
else
|
391
|
+
parent_compiler.send(:register_parameter, value)
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
395
|
+
inner_compiler = QueryIntegratedCompiler.new(parameter_proxy)
|
396
|
+
# Pass the loop variables context to the inner compiler
|
397
|
+
inner_compiler.instance_variable_set(:@loop_variables, @loop_variables.dup)
|
398
|
+
clause_cypher, = inner_compiler.compile([clause.ast_node])
|
399
|
+
@output << clause_cypher
|
400
|
+
|
401
|
+
# For other clause types, render directly
|
402
|
+
end
|
403
|
+
|
404
|
+
# Restore previous loop variables context
|
405
|
+
@loop_variables = old_loop_variables
|
406
|
+
|
407
|
+
@output << ')'
|
408
|
+
end
|
409
|
+
|
410
|
+
# Visit a LOAD CSV node
|
411
|
+
# Reading CSV files like it's 1999
|
412
|
+
def visit_load_csv_node(node)
|
413
|
+
@output << 'LOAD CSV'
|
414
|
+
@output << ' WITH HEADERS' if node.with_headers
|
415
|
+
@output << ' FROM '
|
416
|
+
|
417
|
+
# URL can be a string or expression
|
418
|
+
if node.url.is_a?(String)
|
419
|
+
param_key = register_parameter(node.url)
|
420
|
+
@output << "$#{param_key}"
|
421
|
+
else
|
422
|
+
render_expression(node.url)
|
423
|
+
end
|
424
|
+
|
425
|
+
@output << " AS #{node.variable}"
|
426
|
+
|
427
|
+
return unless node.fieldterminator
|
428
|
+
|
429
|
+
@output << ' FIELDTERMINATOR '
|
430
|
+
param_key = register_parameter(node.fieldterminator)
|
431
|
+
@output << "$#{param_key}"
|
432
|
+
end
|
433
|
+
|
434
|
+
# Visit a literal value node
|
435
|
+
# The most honest node in the entire tree
|
436
|
+
def visit_literal_node(node)
|
437
|
+
if node.value.is_a?(Symbol)
|
438
|
+
# Check if this symbol is a loop variable
|
439
|
+
@output << if @loop_variables.include?(node.value)
|
440
|
+
# Loop variables are rendered as-is, not as parameters
|
441
|
+
node.value.to_s
|
442
|
+
else
|
443
|
+
# Symbols are parameter references, not values to be parameterized
|
444
|
+
"$#{node.value}"
|
445
|
+
end
|
446
|
+
else
|
447
|
+
# All other literals become parameters for consistency with existing behavior
|
448
|
+
param_key = register_parameter(node.value)
|
449
|
+
@output << "$#{param_key}"
|
450
|
+
end
|
451
|
+
end
|
452
|
+
|
453
|
+
private
|
454
|
+
|
455
|
+
def add_clause_separator
|
456
|
+
@output << "\n"
|
457
|
+
end
|
458
|
+
|
459
|
+
# Render a pattern (node, relationship, or path)
|
460
|
+
def render_pattern(pattern)
|
461
|
+
# Patterns have their own render methods
|
462
|
+
raise "Don't know how to render pattern: #{pattern.inspect}" unless pattern.respond_to?(:render)
|
463
|
+
|
464
|
+
@output << pattern.render(@query || self)
|
465
|
+
end
|
466
|
+
|
467
|
+
# Render a SET assignment based on its type
|
468
|
+
def render_assignment(assignment)
|
469
|
+
type, *args = assignment
|
470
|
+
|
471
|
+
case type
|
472
|
+
when :property
|
473
|
+
# SET n.prop = value
|
474
|
+
prop_access, value = args
|
475
|
+
render_expression(prop_access)
|
476
|
+
@output << ' = '
|
477
|
+
render_expression(value)
|
478
|
+
when :variable_properties
|
479
|
+
# SET n = {props} or SET n += {props}
|
480
|
+
variable, value, operator = args
|
481
|
+
@output << variable.to_s
|
482
|
+
@output << (operator == :merge ? ' += ' : ' = ')
|
483
|
+
render_expression(value)
|
484
|
+
when :label
|
485
|
+
# SET n:Label
|
486
|
+
variable, label = args
|
487
|
+
@output << "#{variable}:#{label}"
|
488
|
+
else
|
489
|
+
raise "Unknown assignment type: #{type}"
|
490
|
+
end
|
491
|
+
end
|
492
|
+
|
493
|
+
# Render merge assignments (ON CREATE/MATCH SET)
|
494
|
+
def render_merge_assignments(assignments)
|
495
|
+
case assignments
|
496
|
+
when Array
|
497
|
+
assignments.each_with_index do |item, i|
|
498
|
+
@output << ', ' if i.positive?
|
499
|
+
render_set_item(item)
|
500
|
+
end
|
501
|
+
when Hash
|
502
|
+
assignments.each_with_index do |(key, value), i|
|
503
|
+
@output << ', ' if i.positive?
|
504
|
+
render_expression(key)
|
505
|
+
@output << ' = '
|
506
|
+
render_expression(Expression.coerce(value))
|
507
|
+
end
|
508
|
+
else
|
509
|
+
raise "Unknown assignments type: #{assignments.class}"
|
510
|
+
end
|
511
|
+
end
|
512
|
+
|
513
|
+
# Render a single SET item (for MERGE ON CREATE/MATCH SET)
|
514
|
+
def render_set_item(item)
|
515
|
+
case item
|
516
|
+
when Array
|
517
|
+
# It's a property assignment like [:n, :name, "value"]
|
518
|
+
variable, property, value = item
|
519
|
+
@output << "#{variable}.#{property} = "
|
520
|
+
render_expression(Expression.coerce(value))
|
521
|
+
else
|
522
|
+
raise "Unknown SET item type: #{item.class}"
|
523
|
+
end
|
524
|
+
end
|
525
|
+
|
526
|
+
# Render an expression (could be a literal, parameter, property access, etc.)
|
527
|
+
def render_expression(expr)
|
528
|
+
case expr
|
529
|
+
in Node
|
530
|
+
# It's already an AST node
|
531
|
+
expr.accept(self)
|
532
|
+
in Clause::Return::RawIdentifier
|
533
|
+
# Raw identifiers (variable names) are rendered as-is
|
534
|
+
@output << expr.identifier
|
535
|
+
in Symbol
|
536
|
+
# Symbols in ORDER BY context are identifiers, not parameters
|
537
|
+
# Loop variables should also not be parameterized
|
538
|
+
if @in_order_by || @loop_variables.include?(expr)
|
539
|
+
@output << expr.to_s
|
540
|
+
else
|
541
|
+
# Parameterize the symbol
|
542
|
+
param_key = register_parameter(expr)
|
543
|
+
@output << "$#{param_key}"
|
544
|
+
end
|
545
|
+
in Numeric | true | false | nil
|
546
|
+
# Wrap in literal node and visit
|
547
|
+
LiteralNode.new(expr).accept(self)
|
548
|
+
in String
|
549
|
+
# Strings in ORDER BY context are column names/aliases, not literals
|
550
|
+
if @in_order_by
|
551
|
+
@output << expr
|
552
|
+
else
|
553
|
+
# Strings should be parameterized
|
554
|
+
LiteralNode.new(expr).accept(self)
|
555
|
+
end
|
556
|
+
else
|
557
|
+
# Try common methods
|
558
|
+
if expr.respond_to?(:render)
|
559
|
+
# Has a render method (like PropertyAccess, FunctionCall, etc.)
|
560
|
+
@output << expr.render(@query || self)
|
561
|
+
elsif expr.respond_to?(:to_ast)
|
562
|
+
# Has a to_ast method
|
563
|
+
expr.to_ast.accept(self)
|
564
|
+
else
|
565
|
+
raise "Don't know how to render expression: #{expr.inspect}"
|
566
|
+
end
|
567
|
+
end
|
568
|
+
end
|
569
|
+
|
570
|
+
# Register a parameter and return its key (thread-safe if Concurrent is available)
|
571
|
+
# Because $p1, $p2, $p3 is the naming convention we deserve
|
572
|
+
def register_parameter(value)
|
573
|
+
existing_key = @parameters.key(value)
|
574
|
+
return existing_key if existing_key
|
575
|
+
|
576
|
+
if defined?(Concurrent) && @parameters.is_a?(Concurrent::Hash)
|
577
|
+
# Thread-safe parameter registration
|
578
|
+
|
579
|
+
counter = @param_counter.increment
|
580
|
+
key = :"p#{counter}"
|
581
|
+
else
|
582
|
+
# Non-concurrent version
|
583
|
+
|
584
|
+
@param_counter += 1
|
585
|
+
key = :"p#{@param_counter}"
|
586
|
+
end
|
587
|
+
@parameters[key] = value
|
588
|
+
key
|
589
|
+
end
|
590
|
+
|
591
|
+
# Format an array literal for Cypher output
|
592
|
+
def format_array_literal(array)
|
593
|
+
elements = array.map do |element|
|
594
|
+
case element
|
595
|
+
when String
|
596
|
+
"'#{element}'"
|
597
|
+
when Symbol
|
598
|
+
"'#{element}'"
|
599
|
+
when Array
|
600
|
+
format_array_literal(element)
|
601
|
+
else
|
602
|
+
element.to_s
|
603
|
+
end
|
604
|
+
end
|
605
|
+
"[#{elements.join(', ')}]"
|
606
|
+
end
|
607
|
+
end
|
608
|
+
end
|
609
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module AST
|
5
|
+
# AST node for CREATE clauses
|
6
|
+
# Because sometimes you need to make things exist
|
7
|
+
class CreateNode < ClauseNode
|
8
|
+
attr_reader :pattern
|
9
|
+
|
10
|
+
def initialize(pattern)
|
11
|
+
@pattern = pattern
|
12
|
+
end
|
13
|
+
|
14
|
+
protected
|
15
|
+
|
16
|
+
def state
|
17
|
+
[@pattern]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module AST
|
5
|
+
# AST node for DELETE and DETACH DELETE clauses
|
6
|
+
# Because sometimes things need to disappear, with or without their relationships
|
7
|
+
class DeleteNode < ClauseNode
|
8
|
+
attr_reader :variables, :detach
|
9
|
+
|
10
|
+
def initialize(variables, detach: false)
|
11
|
+
@variables = variables
|
12
|
+
@detach = detach
|
13
|
+
end
|
14
|
+
|
15
|
+
protected
|
16
|
+
|
17
|
+
def state
|
18
|
+
[@variables, @detach]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module AST
|
5
|
+
# AST node for FOREACH clause (extension)
|
6
|
+
# For when you need to iterate through a list and make changes
|
7
|
+
class ForeachNode < ClauseNode
|
8
|
+
attr_reader :variable, :expression, :update_clauses
|
9
|
+
|
10
|
+
def initialize(variable, expression, update_clauses)
|
11
|
+
@variable = variable
|
12
|
+
@expression = expression
|
13
|
+
@update_clauses = update_clauses
|
14
|
+
end
|
15
|
+
|
16
|
+
protected
|
17
|
+
|
18
|
+
def state
|
19
|
+
[@variable, @expression, @update_clauses]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module AST
|
5
|
+
# AST node for LIMIT clause
|
6
|
+
# For when you want boundaries, even in your queries
|
7
|
+
class LimitNode < ClauseNode
|
8
|
+
attr_reader :expression
|
9
|
+
|
10
|
+
def initialize(expression)
|
11
|
+
@expression = expression
|
12
|
+
end
|
13
|
+
|
14
|
+
protected
|
15
|
+
|
16
|
+
def state
|
17
|
+
[@expression]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|