cypher-dsl 0.1.0.alpha.1 → 0.1.0.alpha.3

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/README.md +66 -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/raw.rb +9 -0
  7. data/lib/cypher_dsl/clauses.rb +11 -0
  8. data/lib/cypher_dsl/comparison.rb +6 -0
  9. data/lib/cypher_dsl/condition.rb +1 -1
  10. data/lib/cypher_dsl/count_expression.rb +7 -3
  11. data/lib/cypher_dsl/cypher.rb +80 -19
  12. data/lib/cypher_dsl/expression.rb +3 -0
  13. data/lib/cypher_dsl/function_invocation.rb +7 -0
  14. data/lib/cypher_dsl/functions.rb +11 -1
  15. data/lib/cypher_dsl/has_label_condition.rb +1 -1
  16. data/lib/cypher_dsl/hint.rb +71 -0
  17. data/lib/cypher_dsl/label_operation.rb +11 -3
  18. data/lib/cypher_dsl/labels.rb +66 -0
  19. data/lib/cypher_dsl/literal.rb +24 -2
  20. data/lib/cypher_dsl/named_path.rb +6 -3
  21. data/lib/cypher_dsl/node.rb +15 -4
  22. data/lib/cypher_dsl/property.rb +9 -2
  23. data/lib/cypher_dsl/property_exists_condition.rb +15 -0
  24. data/lib/cypher_dsl/raw_expression.rb +76 -3
  25. data/lib/cypher_dsl/relationship.rb +8 -1
  26. data/lib/cypher_dsl/relationship_chain.rb +3 -0
  27. data/lib/cypher_dsl/renderer/configuration.rb +61 -4
  28. data/lib/cypher_dsl/renderer/dialect.rb +24 -7
  29. data/lib/cypher_dsl/renderer/escaper.rb +10 -7
  30. data/lib/cypher_dsl/renderer/generated_name_resolver.rb +110 -0
  31. data/lib/cypher_dsl/renderer/name_resolver.rb +35 -0
  32. data/lib/cypher_dsl/renderer/visitor/conditions.rb +74 -0
  33. data/lib/cypher_dsl/renderer/visitor/expressions.rb +40 -40
  34. data/lib/cypher_dsl/renderer/visitor/label_rendering.rb +84 -0
  35. data/lib/cypher_dsl/renderer/visitor/patterns.rb +18 -29
  36. data/lib/cypher_dsl/renderer/visitor.rb +106 -29
  37. data/lib/cypher_dsl/renderer.rb +11 -5
  38. data/lib/cypher_dsl/search.rb +91 -0
  39. data/lib/cypher_dsl/standalone_call.rb +0 -6
  40. data/lib/cypher_dsl/statement.rb +4 -0
  41. data/lib/cypher_dsl/statement_builder.rb +81 -15
  42. data/lib/cypher_dsl/statement_catalog.rb +40 -6
  43. data/lib/cypher_dsl/subquery_with.rb +1 -0
  44. data/lib/cypher_dsl/symbolic_name.rb +1 -1
  45. data/lib/cypher_dsl/using_periodic_commit.rb +11 -0
  46. data/lib/cypher_dsl/vector_literal.rb +41 -0
  47. data/lib/cypher_dsl/version.rb +1 -1
  48. metadata +13 -3
  49. data/lib/cypher_dsl/label_expression.rb +0 -40
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ class Renderer
5
+ # Resolves symbolic names, aliases and parameter names while rendering.
6
+ # The base strategy keeps the given names and invents `hint001`-style names
7
+ # for elements that were referenced without ever being named.
8
+ class NameResolver
9
+ def initialize
10
+ @context_names = {}.compare_by_identity
11
+ end
12
+
13
+ def resolve(symbolic_name, in_entity: false) # rubocop:disable Lint/UnusedMethodArgument
14
+ context_name(symbolic_name)
15
+ end
16
+
17
+ def resolve_alias(aliased, define: false, in_last_return: false) # rubocop:disable Lint/UnusedMethodArgument
18
+ context_name(aliased.alias_name)
19
+ end
20
+
21
+ def resolve_parameter(parameter) = parameter.name
22
+
23
+ def enter_scope(imports); end
24
+ def leave_scope(exports); end
25
+
26
+ private
27
+
28
+ def context_name(symbolic_name)
29
+ return symbolic_name.value if symbolic_name.resolved?
30
+
31
+ @context_names[symbolic_name] ||= "#{symbolic_name.hint}#{format('%03d', @context_names.size + 1)}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ class Renderer
5
+ class Visitor
6
+ # Rendering of comparisons and compound conditions.
7
+ module Conditions
8
+ private
9
+
10
+ def render_comparison(comparison)
11
+ left = render_operand(comparison.left)
12
+ return "#{left} #{comparison.operator}" if comparison.unary?
13
+
14
+ "#{left} #{comparison.operator} #{render_operand(comparison.right)}"
15
+ end
16
+
17
+ # A comparison nested inside another comparison is parenthesised: `(1 > 2) = true`.
18
+ def render_operand(expression)
19
+ rendered = render_expression(expression)
20
+ expression.is_a?(Comparison) ? "(#{rendered})" : rendered
21
+ end
22
+
23
+ # `exists(n.prop)` on Neo4j 4, `n.prop IS NOT NULL` afterwards.
24
+ def render_property_exists(condition, negated: false)
25
+ property = render_expression(condition.property)
26
+ return "exists(#{property})" if @configuration.legacy_functions?
27
+
28
+ "#{property} IS #{'NOT ' unless negated}NULL"
29
+ end
30
+
31
+ # Pretty printed, each further operand starts an indented line: `(a\n AND b)`.
32
+ def render_compound_condition(condition)
33
+ if condition.negation?
34
+ negated = condition.conditions.first
35
+ if negated.is_a?(PropertyExistsCondition) && !@configuration.legacy_functions?
36
+ return render_property_exists(negated, negated: true)
37
+ end
38
+
39
+ rendered = render_expression(negated)
40
+ return negated.is_a?(RelationshipPatternCondition) ? "NOT #{rendered}" : "NOT (#{rendered})"
41
+ end
42
+
43
+ operands = nested { condition.conditions.map { |inner| render_compound_operand(inner) } }
44
+ "(#{operands.join("#{break_line(@level + 1)}#{condition.operator} ")})"
45
+ end
46
+
47
+ # A raw operand is parenthesised unless it already is one group, so that
48
+ # `Cypher.raw('a OR b').as_condition.and(c)` keeps its precedence. (Ruby addition; the
49
+ # Java renderer trusts raw text.)
50
+ def render_compound_operand(condition)
51
+ rendered = render_expression(condition)
52
+ raw_condition?(condition) && !parenthesised?(rendered) ? "(#{rendered})" : rendered
53
+ end
54
+
55
+ def raw_condition?(condition)
56
+ condition.is_a?(ExpressionCondition) && condition.expression.is_a?(RawExpression)
57
+ end
58
+
59
+ # True when the text is one parenthesised group: `(a OR b)` but not `(a) OR (b)`.
60
+ def parenthesised?(text)
61
+ return false unless text.start_with?('(') && text.end_with?(')')
62
+
63
+ depth = 0
64
+ text.each_char.with_index do |char, index|
65
+ depth += 1 if char == '('
66
+ depth -= 1 if char == ')'
67
+ return false if depth.zero? && index < text.size - 1
68
+ end
69
+ true
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -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
@@ -229,35 +250,14 @@ module CypherDSL
229
250
  "#{opening}#{break_line(@level + 1)}#{entries.join(",#{break_line(@level + 1)}")}#{break_line}#{closing}"
230
251
  end
231
252
 
232
- def render_comparison(comparison)
233
- left = render_operand(comparison.left)
234
- return "#{left} #{comparison.operator}" if comparison.unary?
235
-
236
- "#{left} #{comparison.operator} #{render_operand(comparison.right)}"
237
- end
238
-
239
- # A comparison nested inside another comparison is parenthesised: `(1 > 2) = true`.
240
- def render_operand(expression)
241
- rendered = render_expression(expression)
242
- expression.is_a?(Comparison) ? "(#{rendered})" : rendered
243
- end
244
-
245
- # Pretty printed, each further operand starts an indented line: `(a\n AND b)`.
246
- def render_compound_condition(condition)
247
- if condition.negation?
248
- negated = condition.conditions.first
249
- rendered = render_expression(negated)
250
- return negated.is_a?(RelationshipPatternCondition) ? "NOT #{rendered}" : "NOT (#{rendered})"
251
- end
252
-
253
- operands = nested { condition.conditions.map { |inner| render_expression(inner) } }
254
- "(#{operands.join("#{break_line(@level + 1)}#{condition.operator} ")})"
255
- end
256
-
257
253
  def render_function_invocation(invocation)
258
- arguments = invocation.arguments.map { |argument| render_expression(argument) }
254
+ arguments = invocation.arguments.map do |argument|
255
+ rendered = render_expression(argument)
256
+ # Pretty printed, a map argument keeps a space after the parenthesis: `collect( {`.
257
+ pretty? && rendered.start_with?('{') ? " #{rendered}" : rendered
258
+ end
259
259
  name = invocation.function_name
260
- legacy = @configuration.dialect == Dialect::NEO4J_4
260
+ legacy = @configuration.legacy_functions?
261
261
  # Neo4j 4 has no elementId(); the closest equivalent is the stringified internal id.
262
262
  return "toString(id(#{arguments.join(', ')}))" if name == 'elementId' && legacy
263
263
 
@@ -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
@@ -15,13 +15,21 @@ module CypherDSL
15
15
  when RelationshipChain then render_chain(element)
16
16
  when NamedPath then render_named_path(element)
17
17
  when QuantifiedPathPattern then render_quantified_path_pattern(element)
18
+ when RawExpression then render_raw(element) # `Cypher.raw('(n)-->(m)')` as a pattern
18
19
  else raise ArgumentError, "Cannot render pattern element #{element.inspect}"
19
20
  end
20
21
  end
21
22
 
23
+ # `p = SHORTEST 1 pattern`, or `p = shortestPath(pattern)` on dialects before Neo4j 5.21.
22
24
  def render_named_path(path)
23
- selector = path.selector && "#{path.selector.keyword} "
24
- "#{render_expression(path.symbolic_name)} = #{selector}#{render_pattern_element(path.pattern)}"
25
+ name = render_expression(path.symbolic_name)
26
+ pattern = render_pattern_element(path.pattern)
27
+ selector = path.selector
28
+ if selector&.legacy_function && @configuration.legacy_path_selectors?
29
+ "#{name} = #{selector.legacy_function}(#{pattern})"
30
+ else
31
+ "#{name} = #{"#{selector.keyword} " if selector}#{pattern}"
32
+ end
25
33
  end
26
34
 
27
35
  def render_quantified_path_pattern(quantified)
@@ -41,35 +49,15 @@ module CypherDSL
41
49
  # Inside a pattern expression (a pattern used as a condition) no new
42
50
  # variables may be introduced, so an unknown name is left out.
43
51
  def render_node(node)
44
- name = node.symbolic_name && resolve_name(node.symbolic_name)
45
- return "(#{name})" if name && @visited_names.include?(name)
52
+ name = node.symbolic_name && resolve_name(node.symbolic_name, in_entity: true)
53
+ return "(#{Escaper.escape_if_necessary(name)})" if name && @visited_names.include?(name)
46
54
 
47
55
  @visited_names << name if name && !@in_pattern_expression
48
56
  name = nil if @in_pattern_expression
49
- labels = node.labels.map { |label| ":#{render_label(label)}" }.join
57
+ labels = node.labels.map { |label| render_label(label) }.join
50
58
  properties = node.properties && " #{render_expression(node.properties)}"
51
59
  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})"
60
+ "(#{name && Escaper.escape_if_necessary(name)}#{labels}#{properties}#{where})"
73
61
  end
74
62
 
75
63
  # A pattern used as a condition: known names render bare, unknown ones are dropped.
@@ -83,9 +71,9 @@ module CypherDSL
83
71
  # Like nodes, a named relationship renders in full once and as `[name]` afterwards.
84
72
  def render_relationship_details(relationship)
85
73
  details = relationship.details
86
- name = relationship.symbolic_name && resolve_name(relationship.symbolic_name)
74
+ name = relationship.symbolic_name && resolve_name(relationship.symbolic_name, in_entity: true)
87
75
  inner = if name && @visited_names.include?(name)
88
- "[#{name}]"
76
+ "[#{Escaper.escape_if_necessary(name)}]"
89
77
  else
90
78
  @visited_names << name if name && !@in_pattern_expression
91
79
  content = render_details_content(relationship)
@@ -103,7 +91,8 @@ module CypherDSL
103
91
  def render_details_content(relationship)
104
92
  details = relationship.details
105
93
  types = details.types.map { |type| escape_schema_name(type) }.join('|')
106
- name = relationship.symbolic_name && !@in_pattern_expression ? resolve_name(relationship.symbolic_name) : nil
94
+ named = relationship.symbolic_name && !@in_pattern_expression
95
+ name = named ? Escaper.escape_if_necessary(resolve_name(relationship.symbolic_name, in_entity: true)) : nil
107
96
  [
108
97
  name,
109
98
  (":#{types}" unless types.empty?),