cypher-dsl 0.1.0.alpha.1

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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/NOTICE +14 -0
  5. data/README.md +87 -0
  6. data/lib/cypher_dsl/aliased_expression.rb +16 -0
  7. data/lib/cypher_dsl/asterisk.rb +10 -0
  8. data/lib/cypher_dsl/case_expression.rb +37 -0
  9. data/lib/cypher_dsl/clauses/call.rb +8 -0
  10. data/lib/cypher_dsl/clauses/call_subquery.rb +9 -0
  11. data/lib/cypher_dsl/clauses/create.rb +8 -0
  12. data/lib/cypher_dsl/clauses/delete.rb +8 -0
  13. data/lib/cypher_dsl/clauses/finish.rb +8 -0
  14. data/lib/cypher_dsl/clauses/foreach.rb +8 -0
  15. data/lib/cypher_dsl/clauses/load_csv.rb +8 -0
  16. data/lib/cypher_dsl/clauses/match.rb +8 -0
  17. data/lib/cypher_dsl/clauses/merge.rb +8 -0
  18. data/lib/cypher_dsl/clauses/order.rb +8 -0
  19. data/lib/cypher_dsl/clauses/raw_return.rb +8 -0
  20. data/lib/cypher_dsl/clauses/remove.rb +8 -0
  21. data/lib/cypher_dsl/clauses/return.rb +8 -0
  22. data/lib/cypher_dsl/clauses/set.rb +8 -0
  23. data/lib/cypher_dsl/clauses/unwind.rb +8 -0
  24. data/lib/cypher_dsl/clauses/use.rb +8 -0
  25. data/lib/cypher_dsl/clauses/with.rb +8 -0
  26. data/lib/cypher_dsl/clauses.rb +25 -0
  27. data/lib/cypher_dsl/collect_expression.rb +28 -0
  28. data/lib/cypher_dsl/comparison.rb +27 -0
  29. data/lib/cypher_dsl/compound_condition.rb +34 -0
  30. data/lib/cypher_dsl/condition.rb +35 -0
  31. data/lib/cypher_dsl/conflicting_parameters_exception.rb +15 -0
  32. data/lib/cypher_dsl/constant_condition.rb +17 -0
  33. data/lib/cypher_dsl/count_expression.rb +16 -0
  34. data/lib/cypher_dsl/cypher.rb +210 -0
  35. data/lib/cypher_dsl/exists_condition.rb +15 -0
  36. data/lib/cypher_dsl/expression.rb +69 -0
  37. data/lib/cypher_dsl/expression_condition.rb +14 -0
  38. data/lib/cypher_dsl/function_invocation.rb +17 -0
  39. data/lib/cypher_dsl/functions.rb +182 -0
  40. data/lib/cypher_dsl/has_label_condition.rb +15 -0
  41. data/lib/cypher_dsl/label_expression.rb +40 -0
  42. data/lib/cypher_dsl/label_operation.rb +18 -0
  43. data/lib/cypher_dsl/list_comprehension.rb +40 -0
  44. data/lib/cypher_dsl/list_expression.rb +17 -0
  45. data/lib/cypher_dsl/list_operator.rb +24 -0
  46. data/lib/cypher_dsl/list_predicate.rb +32 -0
  47. data/lib/cypher_dsl/literal.rb +29 -0
  48. data/lib/cypher_dsl/map_expression.rb +32 -0
  49. data/lib/cypher_dsl/map_projection.rb +58 -0
  50. data/lib/cypher_dsl/merge_action.rb +11 -0
  51. data/lib/cypher_dsl/named_path.rb +59 -0
  52. data/lib/cypher_dsl/no_condition.rb +14 -0
  53. data/lib/cypher_dsl/node.rb +83 -0
  54. data/lib/cypher_dsl/operation.rb +22 -0
  55. data/lib/cypher_dsl/parameter.rb +40 -0
  56. data/lib/cypher_dsl/pattern_comprehension.rb +31 -0
  57. data/lib/cypher_dsl/pattern_expression.rb +16 -0
  58. data/lib/cypher_dsl/property.rb +24 -0
  59. data/lib/cypher_dsl/quantified_path_pattern.rb +37 -0
  60. data/lib/cypher_dsl/raw_expression.rb +15 -0
  61. data/lib/cypher_dsl/reduction.rb +33 -0
  62. data/lib/cypher_dsl/relationship.rb +97 -0
  63. data/lib/cypher_dsl/relationship_chain.rb +44 -0
  64. data/lib/cypher_dsl/relationship_pattern_condition.rb +14 -0
  65. data/lib/cypher_dsl/renderer/configuration.rb +66 -0
  66. data/lib/cypher_dsl/renderer/dialect.rb +20 -0
  67. data/lib/cypher_dsl/renderer/escaper.rb +31 -0
  68. data/lib/cypher_dsl/renderer/visitor/expressions.rb +274 -0
  69. data/lib/cypher_dsl/renderer/visitor/patterns.rb +125 -0
  70. data/lib/cypher_dsl/renderer/visitor.rb +298 -0
  71. data/lib/cypher_dsl/renderer.rb +28 -0
  72. data/lib/cypher_dsl/sort_item.rb +17 -0
  73. data/lib/cypher_dsl/standalone_call.rb +61 -0
  74. data/lib/cypher_dsl/statement.rb +56 -0
  75. data/lib/cypher_dsl/statement_builder.rb +294 -0
  76. data/lib/cypher_dsl/statement_catalog.rb +25 -0
  77. data/lib/cypher_dsl/subquery_with.rb +15 -0
  78. data/lib/cypher_dsl/symbolic_name.rb +48 -0
  79. data/lib/cypher_dsl/unary_operation.rb +16 -0
  80. data/lib/cypher_dsl/union_query.rb +29 -0
  81. data/lib/cypher_dsl/version.rb +5 -0
  82. data/lib/cypher_dsl.rb +13 -0
  83. metadata +140 -0
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # A chain of relationships sharing nodes, `(a)-[]->(b)-[]->(c)`. Modifiers
5
+ # such as `named`, `min` or `properties` apply to the last relationship.
6
+ class RelationshipChain
7
+ attr_reader :relationships
8
+
9
+ def initialize(relationships)
10
+ @relationships = relationships.freeze
11
+ freeze
12
+ end
13
+
14
+ def relationship_to(other, *types) = add(:ltr, other, types)
15
+ def relationship_from(other, *types) = add(:rtl, other, types)
16
+ def relationship_between(other, *types) = add(:uni, other, types)
17
+
18
+ def named(name) = update_last { |relationship| relationship.named(name) }
19
+ def with_properties(*keys_and_values) = update_last { it.with_properties(*keys_and_values) }
20
+ alias properties with_properties
21
+ def min(minimum) = update_last { |relationship| relationship.min(minimum) }
22
+ def max(maximum) = update_last { |relationship| relationship.max(maximum) }
23
+ def length(minimum, maximum) = update_last { |relationship| relationship.length(minimum, maximum) }
24
+ def unbounded = update_last(&:unbounded)
25
+ def where(condition) = update_last { |relationship| relationship.where(condition) }
26
+ def quantify_relationship(quantifier) = update_last { it.quantify_relationship(quantifier) }
27
+
28
+ # `((a)-[]->(b)-[]->(c)){1,3}`: a quantifier on the whole chain.
29
+ def quantify(quantifier) = QuantifiedPathPattern.new(self, quantifier)
30
+
31
+ # The chain as a predicate.
32
+ def as_condition = RelationshipPatternCondition.new(self)
33
+
34
+ private
35
+
36
+ def add(direction, other, types)
37
+ RelationshipChain.new(relationships + [Relationship.create(relationships.last.right, direction, other, types)])
38
+ end
39
+
40
+ def update_last
41
+ RelationshipChain.new(relationships[0...-1] + [yield(relationships.last)])
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # A relationship pattern used as a predicate, e.g. `WHERE (a)-->(b)`.
5
+ class RelationshipPatternCondition < Condition
6
+ attr_reader :pattern
7
+
8
+ def initialize(pattern)
9
+ super()
10
+ @pattern = pattern
11
+ freeze
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ class Renderer
5
+ # Rendering options.
6
+ #
7
+ # - `always_escape_names`: backtick-escape labels and relationship types even
8
+ # when they are plain identifiers, as the Java default renderer does.
9
+ # - `dialect`: one of {Dialect::ALL}.
10
+ # - `pretty_print`: one clause per line, indented subqueries, conditions and maps.
11
+ # - `indent_size` / `indent_style` (`:space` or `:tab`): the indentation unit.
12
+ class Configuration
13
+ attr_reader :always_escape_names, :dialect, :pretty_print, :indent_size, :indent_style
14
+
15
+ # `always_escape_names` defaults to true, except when pretty printing, where
16
+ # names are escaped only when necessary (as upstream does).
17
+ def initialize(always_escape_names: nil, dialect: Dialect::NEO4J_5_23, pretty_print: false, indent_size: 2,
18
+ indent_style: :space)
19
+ raise ArgumentError, "Unknown dialect #{dialect.inspect}" unless Dialect::ALL.include?(dialect)
20
+ raise ArgumentError, "Unknown indent style #{indent_style.inspect}" unless %i[space tab].include?(indent_style)
21
+
22
+ @always_escape_names = always_escape_names.nil? ? !pretty_print : always_escape_names
23
+ @dialect = dialect
24
+ @pretty_print = pretty_print
25
+ @indent_size = indent_size
26
+ @indent_style = indent_style
27
+ freeze
28
+ end
29
+
30
+ DEFAULT = new
31
+
32
+ # Mirrors `Configuration.newConfig()…build()`.
33
+ def self.new_config = Builder.new
34
+
35
+ # Mirrors `Configuration.prettyPrinting()`: pretty printed, names escaped only when needed.
36
+ def self.pretty_printing = new(pretty_print: true)
37
+
38
+ # `CALL { WITH imports … }` (older dialects) rather than `CALL (imports) { … }`.
39
+ def legacy_subquery_syntax? = Dialect::LEGACY_SUBQUERY_SYNTAX.include?(dialect)
40
+
41
+ def indent_unit = indent_style == :tab ? "\t" : ' ' * indent_size
42
+
43
+ # Fluent builder; each setter returns the builder.
44
+ class Builder
45
+ def initialize
46
+ @options = {}
47
+ end
48
+
49
+ def always_escape_names(value) = option(:always_escape_names, value)
50
+ def with_dialect(dialect) = option(:dialect, dialect)
51
+ def with_pretty_print(value) = option(:pretty_print, value)
52
+ def with_indent_size(size) = option(:indent_size, size)
53
+ def with_indent_style(style) = option(:indent_style, style)
54
+
55
+ def build = Configuration.new(**@options)
56
+
57
+ private
58
+
59
+ def option(name, value)
60
+ @options[name] = value
61
+ self
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ class Renderer
5
+ # Target Cypher dialects.
6
+ #
7
+ # - NEO4J_4: `CALL { WITH imports … }` subqueries, `elementId(x)` rendered as `toString(id(x))`.
8
+ # - NEO4J_5: `CALL { WITH imports … }` subqueries, native `elementId`.
9
+ # - NEO4J_5_23 (default, Neo4j 5.23+): `CALL (imports) { … }` subqueries.
10
+ module Dialect
11
+ NEO4J_4 = :neo4j4
12
+ NEO4J_5 = :neo4j5
13
+ NEO4J_5_23 = :neo4j5_23 # rubocop:disable Naming/VariableNumber
14
+ NEO4J_5_DEFAULT_CYPHER = NEO4J_5_23
15
+
16
+ ALL = [NEO4J_4, NEO4J_5, NEO4J_5_23].freeze
17
+ LEGACY_SUBQUERY_SYNTAX = [NEO4J_4, NEO4J_5].freeze
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ class Renderer
5
+ # Backtick escaping of schema names and identifiers.
6
+ module Escaper
7
+ IDENTIFIER = /\A[\p{L}_][\p{L}\p{N}_]*\z/
8
+ # Parameter names may be dotted (`$point.distance`) without escaping.
9
+ PARAMETER_NAME = /\A[\p{L}_][\p{L}\p{N}_.]*\z/
10
+ ESCAPED = /\A`.*`\z/m
11
+
12
+ module_function
13
+
14
+ # Always escapes, unless the name already is.
15
+ def escape(name)
16
+ return name if ESCAPED.match?(name)
17
+
18
+ "`#{name.gsub('`', '``')}`"
19
+ end
20
+
21
+ # Escapes only names that are not plain identifiers.
22
+ def escape_if_necessary(name)
23
+ IDENTIFIER.match?(name) ? name : escape(name)
24
+ end
25
+
26
+ def escape_parameter_name(name)
27
+ PARAMETER_NAME.match?(name) ? name : escape(name)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ class Renderer
5
+ class Visitor
6
+ # Rendering of expressions, conditions, operations, functions and subquery expressions.
7
+ module Expressions
8
+ private
9
+
10
+ def render_expression(expression)
11
+ case expression
12
+ when SymbolicName then Escaper.escape_if_necessary(resolve_name(expression))
13
+ when Node, Relationship then render_expression(expression.required_symbolic_name)
14
+ when Property then render_property(expression)
15
+ when Literal then render_literal(expression.value)
16
+ when Parameter then render_parameter(expression)
17
+ when Asterisk then '*'
18
+ when AliasedExpression then render_aliased_reference(expression)
19
+ when NamedPath then render_expression(expression.symbolic_name)
20
+ when RawExpression then expression.content
21
+ when CollectExpression
22
+ block('COLLECT {', '}') { render_subquery(expression.statement, expression.imports) }
23
+ when CountExpression then render_count(expression)
24
+ when FunctionInvocation then render_function_invocation(expression)
25
+ when SortItem then render_sort_item(expression)
26
+ when CaseExpression then render_case(expression)
27
+ when UnaryOperation then "#{expression.operator}#{render_expression(expression.expression)}"
28
+ when ListOperator then render_list_operator(expression)
29
+ else render_container_expression(expression)
30
+ end
31
+ end
32
+
33
+ def render_case(case_expression)
34
+ parts = ['CASE']
35
+ parts << render_expression(case_expression.subject) if case_expression.subject
36
+ case_expression.whens.each do |condition, value|
37
+ parts << "WHEN #{render_expression(condition)} THEN #{render_expression(value)}"
38
+ end
39
+ parts << "ELSE #{render_expression(case_expression.default)}" if case_expression.default
40
+ parts << 'END'
41
+ parts.join(' ')
42
+ end
43
+
44
+ def render_list_operator(operator)
45
+ list = render_expression(operator.list)
46
+ return "#{list}[#{render_expression(operator.start_index)}]" unless operator.slice
47
+
48
+ from = operator.start_index && render_expression(operator.start_index)
49
+ to = operator.end_index && render_expression(operator.end_index)
50
+ "#{list}[#{from}..#{to}]"
51
+ end
52
+
53
+ def render_container_expression(expression)
54
+ case expression
55
+ when MapExpression then render_map(expression)
56
+ when ListExpression then "[#{expression.elements.map { |element| render_expression(element) }.join(', ')}]"
57
+ when PatternComprehension then render_pattern_comprehension(expression)
58
+ when ListComprehension then render_list_comprehension(expression)
59
+ when MapProjection then render_map_projection(expression)
60
+ when LabelOperation then render_labels(expression.node, expression.labels)
61
+ when Reduction then render_reduction(expression)
62
+ when PatternExpression then render_pattern_expression(expression.pattern)
63
+ else render_condition_or_operation(expression)
64
+ end
65
+ end
66
+
67
+ def render_reduction(reduction)
68
+ accumulator = "#{render_expression(reduction.accumulator)} = #{render_expression(reduction.initial_value)}"
69
+ "reduce(#{accumulator}, #{render_expression(reduction.variable)} IN #{render_expression(reduction.list)} " \
70
+ "| #{render_expression(reduction.mapper)})"
71
+ end
72
+
73
+ def render_labels(node, labels)
74
+ "#{render_expression(node)}#{labels.map { |label| ":#{escape_schema_name(label)}" }.join}"
75
+ end
76
+
77
+ def render_condition_or_operation(expression)
78
+ case expression
79
+ when Comparison then render_comparison(expression)
80
+ when CompoundCondition then render_compound_condition(expression)
81
+ when ConstantCondition then expression.value.to_s
82
+ when ExpressionCondition then render_expression(expression.expression)
83
+ when ExistsCondition then render_exists(expression)
84
+ when RelationshipPatternCondition then render_pattern_expression(expression.pattern)
85
+ when ListPredicate then render_list_predicate(expression)
86
+ when HasLabelCondition then render_labels(expression.node, expression.labels)
87
+ when Operation then render_operation(expression)
88
+ else raise ArgumentError, "Cannot render expression #{expression.inspect}"
89
+ end
90
+ end
91
+
92
+ def render_operation(operation)
93
+ left = render_expression(operation.left)
94
+ right = render_expression(operation.right)
95
+ return "#{left}^#{right}" if operation.operator == '^'
96
+
97
+ rendered = "#{left} #{operation.operator} #{right}"
98
+ operation.assignment? ? rendered : "(#{rendered})"
99
+ end
100
+
101
+ # A plain relationship pattern uses the `exists()` function; quantified
102
+ # patterns and statements need the `EXISTS { }` subquery form.
103
+ def render_exists(condition)
104
+ body = condition.body
105
+ return block('EXISTS {', '}') { render_subquery(body, condition.imports) } if body.is_a?(Statement)
106
+ return "EXISTS { #{render_in_own_scope { render_pattern_elements(body) }} }" if body.is_a?(Array)
107
+ return "EXISTS { #{render_pattern_element(body)} }" if quantified?(body)
108
+
109
+ "exists(#{render_pattern_expression(body)})"
110
+ end
111
+
112
+ def render_count(count)
113
+ body = count.body
114
+ return block('COUNT {', '}') { render_subquery(body, count.imports) } if body.is_a?(Statement)
115
+
116
+ "COUNT { #{render_in_own_scope { render_pattern_element(body) }} }"
117
+ end
118
+
119
+ # Renders with the current scope, discarding names introduced inside.
120
+ def render_in_own_scope
121
+ outer_scope = @visited_names.dup
122
+ yield
123
+ ensure
124
+ @visited_names = outer_scope
125
+ end
126
+
127
+ def quantified?(pattern)
128
+ case pattern
129
+ when QuantifiedPathPattern then true
130
+ when Relationship then !pattern.details.quantifier.nil?
131
+ when RelationshipChain then pattern.relationships.any? { |relationship| quantified?(relationship) }
132
+ else false
133
+ end
134
+ end
135
+
136
+ # The predicate's variable is in scope inside its WHERE.
137
+ def render_list_predicate(predicate)
138
+ variable = render_expression(predicate.variable)
139
+ condition = with_names_in_scope([variable]) { render_expression(predicate.condition) }
140
+ "#{predicate.predicate}(#{variable} IN #{render_expression(predicate.list)} WHERE #{condition})"
141
+ end
142
+
143
+ def with_names_in_scope(names)
144
+ added = names.reject { |name| @visited_names.include?(name) }
145
+ @visited_names.merge(added)
146
+ yield
147
+ ensure
148
+ @visited_names.subtract(added)
149
+ end
150
+
151
+ def render_map_projection(projection)
152
+ subject = render_expression(projection.subject)
153
+ render_entries("#{subject}#{' ' if pretty?}{", '}') do
154
+ projection.entries.map do |kind, *args|
155
+ case kind
156
+ when :all then '.*'
157
+ when :lookup then ".#{Escaper.escape_if_necessary(args.first)}"
158
+ when :property then ".#{Escaper.escape_if_necessary(args.first.names.first)}"
159
+ when :pair then "#{Escaper.escape_if_necessary(args.first)}: #{render_expression(args.last)}"
160
+ else render_expression(args.first)
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ # A pattern comprehension is its own scope: names it introduces do not
167
+ # count as visited outside of it.
168
+ 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(' ')}]"
176
+ end
177
+
178
+ def render_list_comprehension(comprehension)
179
+ parts = ["#{render_expression(comprehension.variable)} IN #{render_expression(comprehension.list)}"]
180
+ parts << "WHERE #{render_expression(comprehension.where_condition)}" if comprehension.where_condition
181
+ parts << "| #{render_expression(comprehension.projection)}" if comprehension.projection
182
+ "[#{parts.join(' ')}]"
183
+ end
184
+
185
+ # Records every use for the catalog; anonymous parameters get `pcdslNN`,
186
+ # shared between anonymous parameters with equal values.
187
+ def render_parameter(parameter)
188
+ name = parameter.anonymous? ? anonymous_parameter_name(parameter) : parameter.name
189
+ (@parameter_uses[name] ||= []) << parameter.value
190
+ "$#{Escaper.escape_parameter_name(name)}"
191
+ end
192
+
193
+ def anonymous_parameter_name(parameter)
194
+ index = @anonymous_parameters.index(parameter)
195
+ unless index
196
+ @anonymous_parameters << parameter
197
+ index = @anonymous_parameters.size - 1
198
+ end
199
+ format('pcdsl%02d', index + 1)
200
+ end
201
+
202
+ def render_property(property)
203
+ lookups = property.names.map do |name|
204
+ name.is_a?(Expression) ? "[#{render_expression(name)}]" : ".#{Escaper.escape_if_necessary(name)}"
205
+ end
206
+ "#{render_expression(property.container)}#{lookups.join}"
207
+ end
208
+
209
+ def render_literal(value)
210
+ case value
211
+ when String then "'#{value.gsub('\\') { '\\\\' }.gsub("'") { "\\'" }}'"
212
+ when nil then 'NULL'
213
+ when true, false, Numeric then value.to_s
214
+ else raise ArgumentError, "Cannot render literal #{value.inspect}"
215
+ end
216
+ end
217
+
218
+ def render_map(map)
219
+ render_entries('{', '}') do
220
+ map.entries.map { |key, value| "#{Escaper.escape_if_necessary(key)}: #{render_expression(value)}" }
221
+ end
222
+ end
223
+
224
+ # Comma separated entries, one per indented line when pretty printing.
225
+ def render_entries(opening, closing, &entries)
226
+ entries = nested(&entries)
227
+ return "#{opening}#{entries.join(', ')}#{closing}" unless pretty?
228
+
229
+ "#{opening}#{break_line(@level + 1)}#{entries.join(",#{break_line(@level + 1)}")}#{break_line}#{closing}"
230
+ end
231
+
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
+ def render_function_invocation(invocation)
258
+ arguments = invocation.arguments.map { |argument| render_expression(argument) }
259
+ name = invocation.function_name
260
+ legacy = @configuration.dialect == Dialect::NEO4J_4
261
+ # Neo4j 4 has no elementId(); the closest equivalent is the stringified internal id.
262
+ return "toString(id(#{arguments.join(', ')}))" if name == 'elementId' && legacy
263
+
264
+ name = 'point.distance' if name == 'distance' && !legacy
265
+ "#{name}(#{'DISTINCT ' if invocation.distinct}#{arguments.join(', ')})"
266
+ end
267
+
268
+ def escape_schema_name(name)
269
+ @configuration.always_escape_names ? Escaper.escape(name) : Escaper.escape_if_necessary(name)
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ class Renderer
5
+ class Visitor
6
+ # Rendering of nodes, relationships, chains, named paths and quantified path patterns.
7
+ module Patterns
8
+ private
9
+
10
+ def render_pattern_element(element)
11
+ case element
12
+ when Node then render_node(element)
13
+ when Relationship
14
+ render_node(element.left) + render_relationship_details(element) + render_node(element.right)
15
+ when RelationshipChain then render_chain(element)
16
+ when NamedPath then render_named_path(element)
17
+ when QuantifiedPathPattern then render_quantified_path_pattern(element)
18
+ else raise ArgumentError, "Cannot render pattern element #{element.inspect}"
19
+ end
20
+ end
21
+
22
+ 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
+ end
26
+
27
+ def render_quantified_path_pattern(quantified)
28
+ inner = render_pattern_element(quantified.pattern)
29
+ inner += " WHERE #{render_expression(quantified.where_condition)}" if quantified.where_condition
30
+ "(#{inner})#{quantified.quantifier.to_cypher}"
31
+ end
32
+
33
+ def render_chain(chain)
34
+ chain.relationships.each_with_index.map do |relationship, index|
35
+ left = index.zero? ? render_node(relationship.left) : ''
36
+ left + render_relationship_details(relationship) + render_node(relationship.right)
37
+ end.join
38
+ end
39
+
40
+ # A named node renders in full once per scope and as `(name)` afterwards.
41
+ # Inside a pattern expression (a pattern used as a condition) no new
42
+ # variables may be introduced, so an unknown name is left out.
43
+ def render_node(node)
44
+ name = node.symbolic_name && resolve_name(node.symbolic_name)
45
+ return "(#{name})" if name && @visited_names.include?(name)
46
+
47
+ @visited_names << name if name && !@in_pattern_expression
48
+ name = nil if @in_pattern_expression
49
+ labels = node.labels.map { |label| ":#{render_label(label)}" }.join
50
+ properties = node.properties && " #{render_expression(node.properties)}"
51
+ 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})"
73
+ end
74
+
75
+ # A pattern used as a condition: known names render bare, unknown ones are dropped.
76
+ def render_pattern_expression(pattern)
77
+ @in_pattern_expression = true
78
+ render_pattern_element(pattern)
79
+ ensure
80
+ @in_pattern_expression = false
81
+ end
82
+
83
+ # Like nodes, a named relationship renders in full once and as `[name]` afterwards.
84
+ def render_relationship_details(relationship)
85
+ details = relationship.details
86
+ name = relationship.symbolic_name && resolve_name(relationship.symbolic_name)
87
+ inner = if name && @visited_names.include?(name)
88
+ "[#{name}]"
89
+ else
90
+ @visited_names << name if name && !@in_pattern_expression
91
+ content = render_details_content(relationship)
92
+ # A name dropped from a pattern expression leaves its brackets behind: `-[]->`.
93
+ content.empty? && !(name && @in_pattern_expression) ? '' : "[#{content}]"
94
+ end
95
+ quantifier = details.quantifier&.to_cypher
96
+ case relationship.direction
97
+ when :ltr then "-#{inner}->#{quantifier}"
98
+ when :rtl then "<-#{inner}-#{quantifier}"
99
+ else "-#{inner}-#{quantifier}"
100
+ end
101
+ end
102
+
103
+ def render_details_content(relationship)
104
+ details = relationship.details
105
+ types = details.types.map { |type| escape_schema_name(type) }.join('|')
106
+ name = relationship.symbolic_name && !@in_pattern_expression ? resolve_name(relationship.symbolic_name) : nil
107
+ [
108
+ name,
109
+ (":#{types}" unless types.empty?),
110
+ render_length(details),
111
+ details.properties && " #{render_expression(details.properties)}",
112
+ details.where && " WHERE #{render_expression(details.where)}"
113
+ ].join
114
+ end
115
+
116
+ def render_length(details)
117
+ return nil unless details.length?
118
+ return '*' if details.unbounded && details.min.nil? && details.max.nil?
119
+
120
+ "*#{details.min}..#{details.max}"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end