cypher-dsl 0.1.0.alpha.1 → 0.1.0.alpha.2
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/CHANGELOG.md +53 -1
- data/README.md +53 -4
- data/lib/cypher_dsl/clauses/load_csv.rb +8 -2
- data/lib/cypher_dsl/clauses/match.rb +2 -2
- data/lib/cypher_dsl/clauses.rb +11 -0
- data/lib/cypher_dsl/comparison.rb +6 -0
- data/lib/cypher_dsl/count_expression.rb +7 -3
- data/lib/cypher_dsl/cypher.rb +80 -19
- data/lib/cypher_dsl/expression.rb +3 -0
- data/lib/cypher_dsl/function_invocation.rb +7 -0
- data/lib/cypher_dsl/functions.rb +11 -1
- data/lib/cypher_dsl/has_label_condition.rb +1 -1
- data/lib/cypher_dsl/hint.rb +71 -0
- data/lib/cypher_dsl/label_operation.rb +11 -3
- data/lib/cypher_dsl/labels.rb +66 -0
- data/lib/cypher_dsl/literal.rb +24 -2
- data/lib/cypher_dsl/named_path.rb +6 -3
- data/lib/cypher_dsl/node.rb +15 -4
- data/lib/cypher_dsl/property.rb +9 -2
- data/lib/cypher_dsl/property_exists_condition.rb +15 -0
- data/lib/cypher_dsl/raw_expression.rb +76 -3
- data/lib/cypher_dsl/relationship.rb +8 -1
- data/lib/cypher_dsl/relationship_chain.rb +3 -0
- data/lib/cypher_dsl/renderer/configuration.rb +61 -4
- data/lib/cypher_dsl/renderer/dialect.rb +24 -7
- data/lib/cypher_dsl/renderer/escaper.rb +10 -7
- data/lib/cypher_dsl/renderer/generated_name_resolver.rb +110 -0
- data/lib/cypher_dsl/renderer/name_resolver.rb +35 -0
- data/lib/cypher_dsl/renderer/visitor/expressions.rb +52 -15
- data/lib/cypher_dsl/renderer/visitor/label_rendering.rb +84 -0
- data/lib/cypher_dsl/renderer/visitor/patterns.rb +17 -29
- data/lib/cypher_dsl/renderer/visitor.rb +99 -27
- data/lib/cypher_dsl/renderer.rb +11 -5
- data/lib/cypher_dsl/search.rb +91 -0
- data/lib/cypher_dsl/standalone_call.rb +0 -6
- data/lib/cypher_dsl/statement.rb +4 -0
- data/lib/cypher_dsl/statement_builder.rb +67 -14
- data/lib/cypher_dsl/statement_catalog.rb +40 -6
- data/lib/cypher_dsl/subquery_with.rb +1 -0
- data/lib/cypher_dsl/symbolic_name.rb +1 -1
- data/lib/cypher_dsl/using_periodic_commit.rb +11 -0
- data/lib/cypher_dsl/vector_literal.rb +41 -0
- data/lib/cypher_dsl/version.rb +1 -1
- metadata +11 -3
- data/lib/cypher_dsl/label_expression.rb +0 -40
|
@@ -17,7 +17,7 @@ module CypherDSL
|
|
|
17
17
|
when Asterisk then '*'
|
|
18
18
|
when AliasedExpression then render_aliased_reference(expression)
|
|
19
19
|
when NamedPath then render_expression(expression.symbolic_name)
|
|
20
|
-
when RawExpression then expression
|
|
20
|
+
when RawExpression then render_raw(expression)
|
|
21
21
|
when CollectExpression
|
|
22
22
|
block('COLLECT {', '}') { render_subquery(expression.statement, expression.imports) }
|
|
23
23
|
when CountExpression then render_count(expression)
|
|
@@ -60,6 +60,7 @@ module CypherDSL
|
|
|
60
60
|
when LabelOperation then render_labels(expression.node, expression.labels)
|
|
61
61
|
when Reduction then render_reduction(expression)
|
|
62
62
|
when PatternExpression then render_pattern_expression(expression.pattern)
|
|
63
|
+
when VectorLiteral then expression.as_string
|
|
63
64
|
else render_condition_or_operation(expression)
|
|
64
65
|
end
|
|
65
66
|
end
|
|
@@ -71,7 +72,7 @@ module CypherDSL
|
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def render_labels(node, labels)
|
|
74
|
-
"#{render_expression(node)}#{labels.map { |label|
|
|
75
|
+
"#{render_expression(node)}#{labels.map { |label| render_label(label) }.join}"
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
def render_condition_or_operation(expression)
|
|
@@ -81,6 +82,7 @@ module CypherDSL
|
|
|
81
82
|
when ConstantCondition then expression.value.to_s
|
|
82
83
|
when ExpressionCondition then render_expression(expression.expression)
|
|
83
84
|
when ExistsCondition then render_exists(expression)
|
|
85
|
+
when PropertyExistsCondition then render_property_exists(expression)
|
|
84
86
|
when RelationshipPatternCondition then render_pattern_expression(expression.pattern)
|
|
85
87
|
when ListPredicate then render_list_predicate(expression)
|
|
86
88
|
when HasLabelCondition then render_labels(expression.node, expression.labels)
|
|
@@ -109,18 +111,29 @@ module CypherDSL
|
|
|
109
111
|
"exists(#{render_pattern_expression(body)})"
|
|
110
112
|
end
|
|
111
113
|
|
|
114
|
+
# A pattern body renders bare; with imports it needs a MATCH after the WITH.
|
|
112
115
|
def render_count(count)
|
|
113
116
|
body = count.body
|
|
114
|
-
return block('COUNT {', '}') { render_subquery(body, count.imports) }
|
|
117
|
+
return block('COUNT {', '}') { render_subquery(body, count.imports) } unless pattern_body?(body)
|
|
115
118
|
|
|
116
|
-
|
|
119
|
+
rendered = render_in_own_scope do
|
|
120
|
+
imports = count.imports.map { |import| render_projection_item(import) }
|
|
121
|
+
parts = [("WITH #{imports.join(', ')} MATCH" unless imports.empty?), render_pattern_element(body)]
|
|
122
|
+
parts << "WHERE #{render_expression(count.where_condition)}" if count.where_condition
|
|
123
|
+
parts.compact.join(' ')
|
|
124
|
+
end
|
|
125
|
+
"COUNT { #{rendered} }"
|
|
117
126
|
end
|
|
118
127
|
|
|
128
|
+
def pattern_body?(body) = !(body.is_a?(Statement) || body.is_a?(UnionQuery))
|
|
129
|
+
|
|
119
130
|
# Renders with the current scope, discarding names introduced inside.
|
|
120
131
|
def render_in_own_scope
|
|
121
132
|
outer_scope = @visited_names.dup
|
|
133
|
+
@names.enter_scope(:all)
|
|
122
134
|
yield
|
|
123
135
|
ensure
|
|
136
|
+
@names.leave_scope([])
|
|
124
137
|
@visited_names = outer_scope
|
|
125
138
|
end
|
|
126
139
|
|
|
@@ -166,13 +179,13 @@ module CypherDSL
|
|
|
166
179
|
# A pattern comprehension is its own scope: names it introduces do not
|
|
167
180
|
# count as visited outside of it.
|
|
168
181
|
def render_pattern_comprehension(comprehension)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
182
|
+
render_in_own_scope do
|
|
183
|
+
parts = [render_pattern_element(comprehension.pattern)]
|
|
184
|
+
parts << "WHERE #{render_expression(comprehension.where_condition)}" if comprehension.where_condition
|
|
185
|
+
projection = comprehension.projection.map { |expression| render_expression(expression) }
|
|
186
|
+
parts << "| #{projection.size == 1 ? projection.first : "[#{projection.join(', ')}]"}"
|
|
187
|
+
"[#{parts.join(' ')}]"
|
|
188
|
+
end
|
|
176
189
|
end
|
|
177
190
|
|
|
178
191
|
def render_list_comprehension(comprehension)
|
|
@@ -185,7 +198,8 @@ module CypherDSL
|
|
|
185
198
|
# Records every use for the catalog; anonymous parameters get `pcdslNN`,
|
|
186
199
|
# shared between anonymous parameters with equal values.
|
|
187
200
|
def render_parameter(parameter)
|
|
188
|
-
name = parameter.anonymous? ? anonymous_parameter_name(parameter) : parameter
|
|
201
|
+
name = parameter.anonymous? ? anonymous_parameter_name(parameter) : @names.resolve_parameter(parameter)
|
|
202
|
+
@renamed_parameters[parameter.name] = name if !parameter.anonymous? && name != parameter.name
|
|
189
203
|
(@parameter_uses[name] ||= []) << parameter.value
|
|
190
204
|
"$#{Escaper.escape_parameter_name(name)}"
|
|
191
205
|
end
|
|
@@ -206,11 +220,18 @@ module CypherDSL
|
|
|
206
220
|
"#{render_expression(property.container)}#{lookups.join}"
|
|
207
221
|
end
|
|
208
222
|
|
|
223
|
+
# Verbatim text with its embedded expressions rendered (and their parameters collected).
|
|
224
|
+
def render_raw(raw)
|
|
225
|
+
raw.parts.map { |part| part.is_a?(String) ? part : render_expression(part) }.join
|
|
226
|
+
end
|
|
227
|
+
|
|
209
228
|
def render_literal(value)
|
|
210
229
|
case value
|
|
211
|
-
when String then
|
|
230
|
+
when String then Literal.quote(value)
|
|
212
231
|
when nil then 'NULL'
|
|
213
232
|
when true, false, Numeric then value.to_s
|
|
233
|
+
when DateTime, Time then "datetime('#{value.iso8601}')"
|
|
234
|
+
when Date then "date('#{value.iso8601}')"
|
|
214
235
|
else raise ArgumentError, "Cannot render literal #{value.inspect}"
|
|
215
236
|
end
|
|
216
237
|
end
|
|
@@ -243,9 +264,21 @@ module CypherDSL
|
|
|
243
264
|
end
|
|
244
265
|
|
|
245
266
|
# Pretty printed, each further operand starts an indented line: `(a\n AND b)`.
|
|
267
|
+
# `exists(n.prop)` on Neo4j 4, `n.prop IS NOT NULL` afterwards.
|
|
268
|
+
def render_property_exists(condition, negated: false)
|
|
269
|
+
property = render_expression(condition.property)
|
|
270
|
+
return "exists(#{property})" if @configuration.legacy_functions?
|
|
271
|
+
|
|
272
|
+
"#{property} IS #{'NOT ' unless negated}NULL"
|
|
273
|
+
end
|
|
274
|
+
|
|
246
275
|
def render_compound_condition(condition)
|
|
247
276
|
if condition.negation?
|
|
248
277
|
negated = condition.conditions.first
|
|
278
|
+
if negated.is_a?(PropertyExistsCondition) && !@configuration.legacy_functions?
|
|
279
|
+
return render_property_exists(negated, negated: true)
|
|
280
|
+
end
|
|
281
|
+
|
|
249
282
|
rendered = render_expression(negated)
|
|
250
283
|
return negated.is_a?(RelationshipPatternCondition) ? "NOT #{rendered}" : "NOT (#{rendered})"
|
|
251
284
|
end
|
|
@@ -255,9 +288,13 @@ module CypherDSL
|
|
|
255
288
|
end
|
|
256
289
|
|
|
257
290
|
def render_function_invocation(invocation)
|
|
258
|
-
arguments = invocation.arguments.map
|
|
291
|
+
arguments = invocation.arguments.map do |argument|
|
|
292
|
+
rendered = render_expression(argument)
|
|
293
|
+
# Pretty printed, a map argument keeps a space after the parenthesis: `collect( {`.
|
|
294
|
+
pretty? && rendered.start_with?('{') ? " #{rendered}" : rendered
|
|
295
|
+
end
|
|
259
296
|
name = invocation.function_name
|
|
260
|
-
legacy = @configuration.
|
|
297
|
+
legacy = @configuration.legacy_functions?
|
|
261
298
|
# Neo4j 4 has no elementId(); the closest equivalent is the stringified internal id.
|
|
262
299
|
return "toString(id(#{arguments.join(', ')}))" if name == 'elementId' && legacy
|
|
263
300
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
class Renderer
|
|
5
|
+
class Visitor
|
|
6
|
+
# Rendering of node labels: plain names and {Labels} expressions, dynamic
|
|
7
|
+
# (`:$($labels)`) where the dialect allows it, static otherwise.
|
|
8
|
+
module LabelRendering
|
|
9
|
+
STATIC_MESSAGE = 'Cannot render the given Labels in a Cypher pre 5.26 compatible way'
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# One label entry including its leading colon.
|
|
14
|
+
def render_label(label)
|
|
15
|
+
label.is_a?(Labels) ? render_labels_expression(label) : ":#{escape_schema_name(label)}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render_labels_expression(labels)
|
|
19
|
+
return render_static_labels(labels) if !@configuration.dynamic_labels? && static_renderable?(labels)
|
|
20
|
+
return '' if labels.empty?
|
|
21
|
+
|
|
22
|
+
":#{render_labels_tree(labels, nil)}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def static_renderable?(labels)
|
|
26
|
+
labels.lhs.nil? && labels.rhs.nil? && %i[leaf colon_conjunction].include?(labels.type)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Pre-5.26: the label names are taken from literals and parameter values and rendered as `:A:B`.
|
|
30
|
+
def render_static_labels(labels)
|
|
31
|
+
names = labels.values.flat_map { |value| static_label_names(value.value) }
|
|
32
|
+
names.map { |name| ":#{escape_schema_name(name)}" }.join
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def static_label_names(value)
|
|
36
|
+
case value
|
|
37
|
+
when String then [value]
|
|
38
|
+
when Parameter then value.value? ? static_label_names(value.value) : raise(ArgumentError, STATIC_MESSAGE)
|
|
39
|
+
when ListExpression then value.elements.flat_map { |element| static_label_names(element) }
|
|
40
|
+
when Literal then value.value.is_a?(String) ? [value.value] : raise(ArgumentError, STATIC_MESSAGE)
|
|
41
|
+
when Array then value.flat_map { |item| static_label_names(item) }
|
|
42
|
+
else raise ArgumentError, STATIC_MESSAGE
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Mirrors the Java renderer's bracketing rules for `&`, `|` and `!`.
|
|
47
|
+
def render_labels_tree(labels, parent)
|
|
48
|
+
current = labels.type
|
|
49
|
+
close = false
|
|
50
|
+
unless labels.leaf?
|
|
51
|
+
close = (!parent.nil? || labels.negated?) && current != parent
|
|
52
|
+
close = false if close && !labels.negated? && (current == :conjunction || parent == :disjunction)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
parts = []
|
|
56
|
+
parts << '!' if labels.negated?
|
|
57
|
+
parts << '(' if close
|
|
58
|
+
parts << render_labels_tree(labels.lhs, current) if labels.lhs
|
|
59
|
+
parts << render_label_values(labels, current)
|
|
60
|
+
parts << render_labels_tree(labels.rhs, current) if labels.rhs
|
|
61
|
+
parts << ')' if close
|
|
62
|
+
parts.join
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_label_values(labels, current)
|
|
66
|
+
return labels.values.map { |value| render_label_value(value) }.join if labels.leaf?
|
|
67
|
+
if %i[colon_conjunction colon_disjunction].include?(current) && labels.values.any?
|
|
68
|
+
return labels.values.map { |value| render_label_value(value) }.join(':')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
labels.empty? ? '' : Labels::OPERATORS[current]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_label_value(value)
|
|
75
|
+
case value.modifier
|
|
76
|
+
when :static then escape_schema_name(value.value)
|
|
77
|
+
when :all then "$(#{render_expression(value.value)})"
|
|
78
|
+
when :any then "$any(#{render_expression(value.value)})"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -19,9 +19,16 @@ module CypherDSL
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# `p = SHORTEST 1 pattern`, or `p = shortestPath(pattern)` on dialects before Neo4j 5.21.
|
|
22
23
|
def render_named_path(path)
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
name = render_expression(path.symbolic_name)
|
|
25
|
+
pattern = render_pattern_element(path.pattern)
|
|
26
|
+
selector = path.selector
|
|
27
|
+
if selector&.legacy_function && @configuration.legacy_path_selectors?
|
|
28
|
+
"#{name} = #{selector.legacy_function}(#{pattern})"
|
|
29
|
+
else
|
|
30
|
+
"#{name} = #{"#{selector.keyword} " if selector}#{pattern}"
|
|
31
|
+
end
|
|
25
32
|
end
|
|
26
33
|
|
|
27
34
|
def render_quantified_path_pattern(quantified)
|
|
@@ -41,35 +48,15 @@ module CypherDSL
|
|
|
41
48
|
# Inside a pattern expression (a pattern used as a condition) no new
|
|
42
49
|
# variables may be introduced, so an unknown name is left out.
|
|
43
50
|
def render_node(node)
|
|
44
|
-
name = node.symbolic_name && resolve_name(node.symbolic_name)
|
|
45
|
-
return "(#{name})" if name && @visited_names.include?(name)
|
|
51
|
+
name = node.symbolic_name && resolve_name(node.symbolic_name, in_entity: true)
|
|
52
|
+
return "(#{Escaper.escape_if_necessary(name)})" if name && @visited_names.include?(name)
|
|
46
53
|
|
|
47
54
|
@visited_names << name if name && !@in_pattern_expression
|
|
48
55
|
name = nil if @in_pattern_expression
|
|
49
|
-
labels = node.labels.map { |label|
|
|
56
|
+
labels = node.labels.map { |label| render_label(label) }.join
|
|
50
57
|
properties = node.properties && " #{render_expression(node.properties)}"
|
|
51
58
|
where = node.where_condition && " WHERE #{render_expression(node.where_condition)}"
|
|
52
|
-
"(#{name}#{labels}#{properties}#{where})"
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def render_label(label)
|
|
56
|
-
label.is_a?(LabelExpression) ? render_label_expression(label) : escape_schema_name(label)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# `A|B`, `A&B`, `!A`; operands of another kind are parenthesised.
|
|
60
|
-
def render_label_expression(expression)
|
|
61
|
-
case expression.type
|
|
62
|
-
when :leaf then escape_schema_name(expression.value)
|
|
63
|
-
when :negation then "!#{render_label_operand(expression.operands.first, expression)}"
|
|
64
|
-
else
|
|
65
|
-
operator = expression.type == :conjunction ? '&' : '|'
|
|
66
|
-
expression.operands.map { |operand| render_label_operand(operand, expression) }.join(operator)
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def render_label_operand(operand, parent)
|
|
71
|
-
rendered = render_label_expression(operand)
|
|
72
|
-
operand.leaf? || operand.type == parent.type || operand.type == :negation ? rendered : "(#{rendered})"
|
|
59
|
+
"(#{name && Escaper.escape_if_necessary(name)}#{labels}#{properties}#{where})"
|
|
73
60
|
end
|
|
74
61
|
|
|
75
62
|
# A pattern used as a condition: known names render bare, unknown ones are dropped.
|
|
@@ -83,9 +70,9 @@ module CypherDSL
|
|
|
83
70
|
# Like nodes, a named relationship renders in full once and as `[name]` afterwards.
|
|
84
71
|
def render_relationship_details(relationship)
|
|
85
72
|
details = relationship.details
|
|
86
|
-
name = relationship.symbolic_name && resolve_name(relationship.symbolic_name)
|
|
73
|
+
name = relationship.symbolic_name && resolve_name(relationship.symbolic_name, in_entity: true)
|
|
87
74
|
inner = if name && @visited_names.include?(name)
|
|
88
|
-
"[#{name}]"
|
|
75
|
+
"[#{Escaper.escape_if_necessary(name)}]"
|
|
89
76
|
else
|
|
90
77
|
@visited_names << name if name && !@in_pattern_expression
|
|
91
78
|
content = render_details_content(relationship)
|
|
@@ -103,7 +90,8 @@ module CypherDSL
|
|
|
103
90
|
def render_details_content(relationship)
|
|
104
91
|
details = relationship.details
|
|
105
92
|
types = details.types.map { |type| escape_schema_name(type) }.join('|')
|
|
106
|
-
|
|
93
|
+
named = relationship.symbolic_name && !@in_pattern_expression
|
|
94
|
+
name = named ? Escaper.escape_if_necessary(resolve_name(relationship.symbolic_name, in_entity: true)) : nil
|
|
107
95
|
[
|
|
108
96
|
name,
|
|
109
97
|
(":#{types}" unless types.empty?),
|
|
@@ -8,28 +8,54 @@ module CypherDSL
|
|
|
8
8
|
class Visitor
|
|
9
9
|
include Patterns
|
|
10
10
|
include Expressions
|
|
11
|
+
include LabelRendering
|
|
11
12
|
|
|
12
13
|
def initialize(configuration)
|
|
13
14
|
@configuration = configuration
|
|
14
15
|
@visited_names = Set.new
|
|
15
16
|
@defined_aliases = {}
|
|
16
|
-
@
|
|
17
|
+
@names = if configuration.use_generated_names?
|
|
18
|
+
GeneratedNameResolver.new(configuration.generated_names)
|
|
19
|
+
else
|
|
20
|
+
NameResolver.new
|
|
21
|
+
end
|
|
22
|
+
@renamed_parameters = {}
|
|
17
23
|
@parameter_uses = {}
|
|
24
|
+
@depth = 0
|
|
25
|
+
@in_last_return = false
|
|
18
26
|
@anonymous_parameters = []
|
|
19
27
|
@level = 0
|
|
20
28
|
end
|
|
21
29
|
|
|
22
|
-
# The parameters seen while rendering
|
|
23
|
-
def catalog = StatementCatalog.from_uses(@parameter_uses)
|
|
30
|
+
# The parameters seen while rendering `statement`; see {Renderer#catalog}.
|
|
31
|
+
def catalog(statement = nil) = StatementCatalog.from_uses(@parameter_uses, @renamed_parameters, statement:)
|
|
32
|
+
|
|
33
|
+
# A part of a statement on its own.
|
|
34
|
+
def render_fragment(fragment)
|
|
35
|
+
case fragment
|
|
36
|
+
when Search then render_search(fragment).lstrip
|
|
37
|
+
when Node, Relationship, RelationshipChain, NamedPath, QuantifiedPathPattern
|
|
38
|
+
render_pattern_element(fragment)
|
|
39
|
+
when Statement, UnionQuery then render(fragment)
|
|
40
|
+
else Clauses.clause?(fragment) ? render_clause(fragment) : render_expression(fragment)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
24
43
|
|
|
25
44
|
def render(statement)
|
|
26
45
|
return render_union(statement) if statement.is_a?(UnionQuery)
|
|
27
46
|
|
|
47
|
+
@depth += 1
|
|
48
|
+
last = statement.clauses.size - 1
|
|
49
|
+
leading_use = statement.clauses.first.is_a?(Clauses::Use)
|
|
28
50
|
rendered = statement.clauses.each_with_index.map do |clause, index|
|
|
29
|
-
|
|
51
|
+
# A leading USE does not count: `USE g CALL () {…}` opens the statement's scope.
|
|
52
|
+
@first_clause = (index.zero? || (leading_use && index == 1)) && @visited_names.empty?
|
|
53
|
+
@in_last_return = @depth == 1 && index == last && clause.is_a?(Clauses::Return)
|
|
30
54
|
render_clause(clause)
|
|
31
55
|
end.join(break_line)
|
|
32
56
|
statement.prefix ? "#{statement.prefix} #{rendered}" : rendered
|
|
57
|
+
ensure
|
|
58
|
+
@depth -= 1
|
|
33
59
|
end
|
|
34
60
|
|
|
35
61
|
private
|
|
@@ -73,12 +99,8 @@ module CypherDSL
|
|
|
73
99
|
end.join("#{keyword}#{break_line}")
|
|
74
100
|
end
|
|
75
101
|
|
|
76
|
-
#
|
|
77
|
-
def resolve_name(symbolic_name)
|
|
78
|
-
return symbolic_name.value if symbolic_name.resolved?
|
|
79
|
-
|
|
80
|
-
@generated_names[symbolic_name] ||= "#{symbolic_name.hint}#{format('%03d', @generated_names.size + 1)}"
|
|
81
|
-
end
|
|
102
|
+
# The rendered name of a symbolic name; see {NameResolver}.
|
|
103
|
+
def resolve_name(symbolic_name, in_entity: false) = @names.resolve(symbolic_name, in_entity:)
|
|
82
104
|
|
|
83
105
|
private
|
|
84
106
|
|
|
@@ -108,8 +130,15 @@ module CypherDSL
|
|
|
108
130
|
end
|
|
109
131
|
|
|
110
132
|
def render_load_csv(clause)
|
|
111
|
-
|
|
112
|
-
|
|
133
|
+
parts = []
|
|
134
|
+
if clause.periodic_commit
|
|
135
|
+
rate = clause.periodic_commit == true ? '' : " #{clause.periodic_commit}"
|
|
136
|
+
parts << "USING PERIODIC COMMIT#{rate}"
|
|
137
|
+
end
|
|
138
|
+
parts << (clause.with_headers ? 'LOAD CSV WITH HEADERS' : 'LOAD CSV')
|
|
139
|
+
parts << "FROM #{render_literal(clause.uri)} AS #{render_expression(clause.alias_name)}"
|
|
140
|
+
parts << "FIELDTERMINATOR #{render_literal(clause.field_terminator)}" if clause.field_terminator
|
|
141
|
+
parts.join(' ')
|
|
113
142
|
end
|
|
114
143
|
|
|
115
144
|
def render_call(clause)
|
|
@@ -138,11 +167,21 @@ module CypherDSL
|
|
|
138
167
|
opening = "CALL (#{list}) {"
|
|
139
168
|
end
|
|
140
169
|
rendered = block(opening, '}', inline_padding: '') do
|
|
141
|
-
[prefix, render_in_scope(body, scope_of(imports))].compact.join(break_line)
|
|
170
|
+
[prefix, render_in_scope(body, scope_of(imports), imports)].compact.join(break_line)
|
|
142
171
|
end
|
|
143
172
|
"#{rendered}#{suffix}"
|
|
144
173
|
end
|
|
145
174
|
|
|
175
|
+
# What a subquery hands back to the outer scope: the items of its final RETURN.
|
|
176
|
+
def exports_of(body)
|
|
177
|
+
return [] unless body.is_a?(Statement) && body.clauses.last.is_a?(Clauses::Return)
|
|
178
|
+
|
|
179
|
+
items = body.clauses.last.items
|
|
180
|
+
return :all if items.any?(Asterisk)
|
|
181
|
+
|
|
182
|
+
items.select { |item| [SymbolicName, AliasedExpression, Node, Relationship].any? { |type| item.is_a?(type) } }
|
|
183
|
+
end
|
|
184
|
+
|
|
146
185
|
# An aliased import (`nodes AS x`) imports the name and renames it with a
|
|
147
186
|
# leading `WITH nodes AS x` inside the body.
|
|
148
187
|
def subquery_body_and_imports(clause)
|
|
@@ -177,37 +216,69 @@ module CypherDSL
|
|
|
177
216
|
imports.filter_map { |import| import.is_a?(SymbolicName) ? resolve_name(import) : nil }.to_set
|
|
178
217
|
end
|
|
179
218
|
|
|
180
|
-
|
|
181
|
-
|
|
219
|
+
# A CALL subquery body: only the imports are visible inside, the final RETURN is exported.
|
|
220
|
+
def render_in_scope(body, scope, imports)
|
|
221
|
+
return render_raw(body) if body.is_a?(RawExpression)
|
|
182
222
|
|
|
183
223
|
outer_scope = @visited_names
|
|
184
224
|
@visited_names = scope
|
|
225
|
+
@names.enter_scope(imports.any?(Asterisk) ? :all : imports)
|
|
185
226
|
rendered = render(body)
|
|
227
|
+
@names.leave_scope(exports_of(body))
|
|
186
228
|
@visited_names = outer_scope
|
|
187
229
|
rendered
|
|
188
230
|
end
|
|
189
231
|
|
|
190
232
|
# A subquery statement rendered within the current scope (EXISTS / COLLECT).
|
|
191
233
|
def render_subquery(statement, imports = [])
|
|
192
|
-
outer_scope = @visited_names.dup
|
|
193
234
|
prefix = imports.empty? ? '' : "WITH #{imports.map { |import| render_projection_item(import) }.join(', ')} "
|
|
194
|
-
rendered = render(statement)
|
|
195
|
-
@visited_names = outer_scope
|
|
235
|
+
rendered = render_in_own_scope { render(statement) }
|
|
196
236
|
"#{prefix}#{rendered}"
|
|
197
237
|
end
|
|
198
238
|
|
|
199
239
|
# The body of a FOREACH is its own scope: names rendered inside it do not
|
|
200
240
|
# count as visited outside.
|
|
201
241
|
def render_foreach(clause)
|
|
202
|
-
|
|
203
|
-
body = clause.clauses.map { |inner| render_clause(inner) }.join(' ')
|
|
204
|
-
@visited_names = outer_scope
|
|
242
|
+
body = render_in_own_scope { clause.clauses.map { |inner| render_clause(inner) }.join(' ') }
|
|
205
243
|
"FOREACH (#{render_expression(clause.variable)} IN #{render_expression(clause.list)} | #{body})"
|
|
206
244
|
end
|
|
207
245
|
|
|
208
246
|
def render_match(clause)
|
|
247
|
+
hints = clause.hints.map { |hint| "#{break_line}#{render_hint(hint)}" }
|
|
248
|
+
search = clause.search && render_search(clause.search)
|
|
209
249
|
[('OPTIONAL ' if clause.optional), 'MATCH ', render_pattern_elements(clause.pattern_elements),
|
|
210
|
-
render_where(clause.where)].join
|
|
250
|
+
*hints, search, render_where(clause.where)].join
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# `SEARCH name IN (VECTOR INDEX index FOR vector [WHERE …] LIMIT k) [SCORE AS score]`; pretty
|
|
254
|
+
# printed it sits indented under its MATCH with one option per line.
|
|
255
|
+
def render_search(search)
|
|
256
|
+
score = search.score_name && " SCORE AS #{render_expression(search.score_name)}"
|
|
257
|
+
name = render_expression(search.name)
|
|
258
|
+
return " SEARCH #{name} IN (#{search_options(search).join(' ')})#{score}" unless pretty?
|
|
259
|
+
|
|
260
|
+
body = nested { nested { search_options(search).join(break_line) } }
|
|
261
|
+
"#{break_line(@level + 1)}SEARCH #{name} IN (#{break_line(@level + 2)}#{body}#{break_line(@level + 1)})#{score}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def search_options(search)
|
|
265
|
+
predicate = search.predicates.inject { |all, predicate| all.and(predicate) }
|
|
266
|
+
options = ["VECTOR INDEX #{Escaper.escape_if_necessary(search.index_name)}",
|
|
267
|
+
"FOR #{render_expression(search.vector)}"]
|
|
268
|
+
options << "WHERE #{render_expression(predicate)}" if predicate
|
|
269
|
+
options << "LIMIT #{search.top_k}" if search.top_k
|
|
270
|
+
options
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def render_hint(hint)
|
|
274
|
+
case hint.type
|
|
275
|
+
when :join then "USING JOIN ON #{hint.names.map { |name| render_expression(name) }.join(', ')}"
|
|
276
|
+
when :scan then "USING SCAN #{render_expression(hint.container)}:#{escape_schema_name(hint.schema_name)}"
|
|
277
|
+
else
|
|
278
|
+
keyword = hint.type == :index_seek ? 'USING INDEX SEEK' : 'USING INDEX'
|
|
279
|
+
properties = hint.property_names.map { |name| Escaper.escape_if_necessary(name) }.join(', ')
|
|
280
|
+
"#{keyword} #{render_expression(hint.container)}:#{escape_schema_name(hint.schema_name)}(#{properties})"
|
|
281
|
+
end
|
|
211
282
|
end
|
|
212
283
|
|
|
213
284
|
def render_merge(clause)
|
|
@@ -238,7 +309,7 @@ module CypherDSL
|
|
|
238
309
|
def exported_names(items)
|
|
239
310
|
items.filter_map do |item|
|
|
240
311
|
case item
|
|
241
|
-
when AliasedExpression then
|
|
312
|
+
when AliasedExpression then @names.resolve_alias(item, in_last_return: @in_last_return)
|
|
242
313
|
when SymbolicName then resolve_name(item)
|
|
243
314
|
when Node, Relationship then item.symbolic_name && resolve_name(item.symbolic_name)
|
|
244
315
|
end
|
|
@@ -259,16 +330,17 @@ module CypherDSL
|
|
|
259
330
|
def render_projection_item(item)
|
|
260
331
|
return render_expression(item) unless item.is_a?(AliasedExpression)
|
|
261
332
|
|
|
262
|
-
alias_name =
|
|
333
|
+
alias_name = @names.resolve_alias(item, in_last_return: @in_last_return)
|
|
263
334
|
return Escaper.escape_if_necessary(alias_name) if alias_defined?(item, alias_name)
|
|
264
335
|
|
|
265
|
-
|
|
336
|
+
delegate = render_expression(item.delegate)
|
|
337
|
+
alias_name = @names.resolve_alias(item, define: true, in_last_return: @in_last_return)
|
|
266
338
|
@defined_aliases[alias_name] = item.delegate
|
|
267
|
-
|
|
339
|
+
"#{delegate} AS #{Escaper.escape_if_necessary(alias_name)}"
|
|
268
340
|
end
|
|
269
341
|
|
|
270
342
|
def render_aliased_reference(item)
|
|
271
|
-
alias_name =
|
|
343
|
+
alias_name = @names.resolve_alias(item, in_last_return: @in_last_return)
|
|
272
344
|
return Escaper.escape_if_necessary(alias_name) if alias_defined?(item, alias_name)
|
|
273
345
|
|
|
274
346
|
render_expression(item.delegate)
|
data/lib/cypher_dsl/renderer.rb
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
module CypherDSL
|
|
4
4
|
# Turns a {Statement} into Cypher text according to a {Renderer::Configuration}.
|
|
5
5
|
class Renderer
|
|
6
|
-
def self.default = @default ||= new(Configuration
|
|
6
|
+
def self.default = @default ||= new(Configuration.default)
|
|
7
|
+
|
|
8
|
+
# The default configuration without forced escaping; used by `to_s` on expressions and patterns.
|
|
9
|
+
def self.plain = @plain ||= new(Configuration.new(always_escape_names: false))
|
|
7
10
|
|
|
8
11
|
# Mirrors `Renderer.getRenderer(configuration)`.
|
|
9
12
|
def self.get_renderer(configuration) = new(configuration)
|
|
@@ -15,14 +18,17 @@ module CypherDSL
|
|
|
15
18
|
freeze
|
|
16
19
|
end
|
|
17
20
|
|
|
18
|
-
def render(statement) = Visitor.new(configuration).render(statement)
|
|
21
|
+
def render(statement) = "#{configuration.prefix}#{Visitor.new(configuration).render(statement)}"
|
|
22
|
+
|
|
23
|
+
# Renders a part of a statement on its own: an expression, a pattern, a clause or a {Search}.
|
|
24
|
+
def render_fragment(fragment) = Visitor.new(configuration).render_fragment(fragment)
|
|
19
25
|
|
|
20
|
-
# The statement's parameters as a {StatementCatalog};
|
|
21
|
-
# {ConflictingParametersException} when a name is bound inconsistently.
|
|
26
|
+
# The statement's parameters and identifiable expressions as a {StatementCatalog};
|
|
27
|
+
# raises {ConflictingParametersException} when a name is bound inconsistently.
|
|
22
28
|
def catalog(statement)
|
|
23
29
|
visitor = Visitor.new(configuration)
|
|
24
30
|
visitor.render(statement)
|
|
25
|
-
visitor.catalog
|
|
31
|
+
visitor.catalog(statement)
|
|
26
32
|
end
|
|
27
33
|
end
|
|
28
34
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# `SEARCH movie IN (VECTOR INDEX moviePlots FOR $vector WHERE … LIMIT 4) SCORE AS score`,
|
|
5
|
+
# attached to a MATCH clause. Built with
|
|
6
|
+
# `Cypher.search(:movie).in('moviePlots').for_vector(vector).where(…).limit(4).score(:s).build`.
|
|
7
|
+
class Search
|
|
8
|
+
SUPPORTED_VECTORS = [VectorLiteral, ListExpression, Parameter, Property].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :index_name, :vector, :predicates, :top_k, :score_name
|
|
11
|
+
|
|
12
|
+
def initialize(name:, index_name:, vector:, predicates:, top_k:, score_name:)
|
|
13
|
+
@name = name
|
|
14
|
+
@index_name = index_name
|
|
15
|
+
@vector = vector
|
|
16
|
+
@predicates = predicates.freeze
|
|
17
|
+
@top_k = top_k
|
|
18
|
+
@score_name = score_name
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The search rendered on its own with the default renderer.
|
|
23
|
+
def cypher = Renderer.default.render_fragment(self)
|
|
24
|
+
alias to_s cypher
|
|
25
|
+
|
|
26
|
+
# The ongoing definition; every step returns a new builder.
|
|
27
|
+
class Builder
|
|
28
|
+
def initialize(name, index_name: nil, vector: nil, predicates: [], top_k: nil, score_name: nil)
|
|
29
|
+
raise ArgumentError, 'Name is required' if name.nil?
|
|
30
|
+
|
|
31
|
+
@name = name
|
|
32
|
+
@index_name = index_name
|
|
33
|
+
@vector = vector
|
|
34
|
+
@predicates = predicates
|
|
35
|
+
@top_k = top_k
|
|
36
|
+
@score_name = score_name
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def in(index_name)
|
|
41
|
+
if index_name.nil? || index_name.to_s.strip.empty?
|
|
42
|
+
raise ArgumentError, 'The index name must not be null or empty'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
with(index_name: index_name.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def for_vector(vector)
|
|
49
|
+
raise ArgumentError, 'Vector is required' if vector.nil?
|
|
50
|
+
unless Search::SUPPORTED_VECTORS.any? { |type| vector.is_a?(type) }
|
|
51
|
+
raise ArgumentError, "Unsupported vector expression #{vector.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
with(vector:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Only comparisons; several are combined with AND.
|
|
58
|
+
def where(*predicates)
|
|
59
|
+
predicates = predicates.flatten
|
|
60
|
+
raise ArgumentError, 'A predicate is required' if predicates.empty? || predicates.any?(nil)
|
|
61
|
+
raise ArgumentError, 'Only comparison conditions are supported' unless predicates.all?(Comparison)
|
|
62
|
+
|
|
63
|
+
with(predicates:)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def limit(top_k)
|
|
67
|
+
raise ArgumentError, 'topK must be greater than or equal to 0' if top_k.negative?
|
|
68
|
+
|
|
69
|
+
with(top_k:)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def score(name) = with(score_name: SymbolicName.of(name))
|
|
73
|
+
|
|
74
|
+
def build
|
|
75
|
+
raise ArgumentError, 'The index name is required' if @index_name.nil?
|
|
76
|
+
raise ArgumentError, 'The vector is required' if @vector.nil?
|
|
77
|
+
|
|
78
|
+
Search.new(name: @name, index_name: @index_name, vector: @vector, predicates: @predicates, top_k: @top_k,
|
|
79
|
+
score_name: @score_name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def with(**changes)
|
|
85
|
+
options = { index_name: @index_name, vector: @vector, predicates: @predicates, top_k: @top_k,
|
|
86
|
+
score_name: @score_name }.merge(changes)
|
|
87
|
+
Builder.new(@name, **options)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|