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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +53 -4
  4. data/lib/cypher_dsl/clauses/load_csv.rb +8 -2
  5. data/lib/cypher_dsl/clauses/match.rb +2 -2
  6. data/lib/cypher_dsl/clauses.rb +11 -0
  7. data/lib/cypher_dsl/comparison.rb +6 -0
  8. data/lib/cypher_dsl/count_expression.rb +7 -3
  9. data/lib/cypher_dsl/cypher.rb +80 -19
  10. data/lib/cypher_dsl/expression.rb +3 -0
  11. data/lib/cypher_dsl/function_invocation.rb +7 -0
  12. data/lib/cypher_dsl/functions.rb +11 -1
  13. data/lib/cypher_dsl/has_label_condition.rb +1 -1
  14. data/lib/cypher_dsl/hint.rb +71 -0
  15. data/lib/cypher_dsl/label_operation.rb +11 -3
  16. data/lib/cypher_dsl/labels.rb +66 -0
  17. data/lib/cypher_dsl/literal.rb +24 -2
  18. data/lib/cypher_dsl/named_path.rb +6 -3
  19. data/lib/cypher_dsl/node.rb +15 -4
  20. data/lib/cypher_dsl/property.rb +9 -2
  21. data/lib/cypher_dsl/property_exists_condition.rb +15 -0
  22. data/lib/cypher_dsl/raw_expression.rb +76 -3
  23. data/lib/cypher_dsl/relationship.rb +8 -1
  24. data/lib/cypher_dsl/relationship_chain.rb +3 -0
  25. data/lib/cypher_dsl/renderer/configuration.rb +61 -4
  26. data/lib/cypher_dsl/renderer/dialect.rb +24 -7
  27. data/lib/cypher_dsl/renderer/escaper.rb +10 -7
  28. data/lib/cypher_dsl/renderer/generated_name_resolver.rb +110 -0
  29. data/lib/cypher_dsl/renderer/name_resolver.rb +35 -0
  30. data/lib/cypher_dsl/renderer/visitor/expressions.rb +52 -15
  31. data/lib/cypher_dsl/renderer/visitor/label_rendering.rb +84 -0
  32. data/lib/cypher_dsl/renderer/visitor/patterns.rb +17 -29
  33. data/lib/cypher_dsl/renderer/visitor.rb +99 -27
  34. data/lib/cypher_dsl/renderer.rb +11 -5
  35. data/lib/cypher_dsl/search.rb +91 -0
  36. data/lib/cypher_dsl/standalone_call.rb +0 -6
  37. data/lib/cypher_dsl/statement.rb +4 -0
  38. data/lib/cypher_dsl/statement_builder.rb +67 -14
  39. data/lib/cypher_dsl/statement_catalog.rb +40 -6
  40. data/lib/cypher_dsl/subquery_with.rb +1 -0
  41. data/lib/cypher_dsl/symbolic_name.rb +1 -1
  42. data/lib/cypher_dsl/using_periodic_commit.rb +11 -0
  43. data/lib/cypher_dsl/vector_literal.rb +41 -0
  44. data/lib/cypher_dsl/version.rb +1 -1
  45. metadata +11 -3
  46. 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.content
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| ":#{escape_schema_name(label)}" }.join}"
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) } if body.is_a?(Statement)
117
+ return block('COUNT {', '}') { render_subquery(body, count.imports) } unless pattern_body?(body)
115
118
 
116
- "COUNT { #{render_in_own_scope { render_pattern_element(body) }} }"
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
- outer_scope = @visited_names.dup
170
- parts = [render_pattern_element(comprehension.pattern)]
171
- parts << "WHERE #{render_expression(comprehension.where_condition)}" if comprehension.where_condition
172
- projection = comprehension.projection.map { |expression| render_expression(expression) }
173
- parts << "| #{projection.size == 1 ? projection.first : "[#{projection.join(', ')}]"}"
174
- @visited_names = outer_scope
175
- "[#{parts.join(' ')}]"
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.name
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 "'#{value.gsub('\\') { '\\\\' }.gsub("'") { "\\'" }}'"
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 { |argument| render_expression(argument) }
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.dialect == Dialect::NEO4J_4
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
- selector = path.selector && "#{path.selector.keyword} "
24
- "#{render_expression(path.symbolic_name)} = #{selector}#{render_pattern_element(path.pattern)}"
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| ":#{render_label(label)}" }.join
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
- name = relationship.symbolic_name && !@in_pattern_expression ? resolve_name(relationship.symbolic_name) : nil
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
- @generated_names = {}.compare_by_identity
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; see {Renderer#catalog}.
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
- @first_clause = index.zero? && @visited_names.empty?
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
- # Unresolved symbolic names get a generated, render-stable name.
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
- headers = clause.with_headers ? ' WITH HEADERS' : ''
112
- "LOAD CSV#{headers} FROM #{render_literal(clause.uri)} AS #{render_expression(clause.alias_name)}"
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
- def render_in_scope(body, scope)
181
- return body.content if body.is_a?(RawExpression)
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
- outer_scope = @visited_names.dup
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 resolve_name(item.alias_name)
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 = resolve_name(item.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
- rendered = "#{render_expression(item.delegate)} AS #{render_expression(item.alias_name)}"
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
- rendered
339
+ "#{delegate} AS #{Escaper.escape_if_necessary(alias_name)}"
268
340
  end
269
341
 
270
342
  def render_aliased_reference(item)
271
- alias_name = resolve_name(item.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)
@@ -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::DEFAULT)
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}; raises
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