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,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ class Renderer
5
+ # Walks a statement and produces Cypher text. One instance per render call:
6
+ # it remembers which named nodes have already been rendered with their
7
+ # content, so later occurrences render as `(name)` only.
8
+ class Visitor
9
+ include Patterns
10
+ include Expressions
11
+
12
+ def initialize(configuration)
13
+ @configuration = configuration
14
+ @visited_names = Set.new
15
+ @defined_aliases = {}
16
+ @generated_names = {}.compare_by_identity
17
+ @parameter_uses = {}
18
+ @anonymous_parameters = []
19
+ @level = 0
20
+ end
21
+
22
+ # The parameters seen while rendering; see {Renderer#catalog}.
23
+ def catalog = StatementCatalog.from_uses(@parameter_uses)
24
+
25
+ def render(statement)
26
+ return render_union(statement) if statement.is_a?(UnionQuery)
27
+
28
+ rendered = statement.clauses.each_with_index.map do |clause, index|
29
+ @first_clause = index.zero? && @visited_names.empty?
30
+ render_clause(clause)
31
+ end.join(break_line)
32
+ statement.prefix ? "#{statement.prefix} #{rendered}" : rendered
33
+ end
34
+
35
+ private
36
+
37
+ # --- pretty printing ------------------------------------------------
38
+
39
+ def pretty? = @configuration.pretty_print
40
+
41
+ # A clause or entry separator: a newline plus indentation when pretty printing, a space otherwise.
42
+ def break_line(level = @level)
43
+ pretty? ? "\n#{@configuration.indent_unit * level}" : ' '
44
+ end
45
+
46
+ # Renders the block one level deeper, e.g. the body of a subquery.
47
+ def nested
48
+ @level += 1
49
+ yield
50
+ ensure
51
+ @level -= 1
52
+ end
53
+
54
+ # `opening` + body on its own indented lines + `closing`, or inline when not pretty printing.
55
+ def block(opening, closing, inline_padding: ' ', &body)
56
+ body = nested(&body)
57
+ return "#{opening}#{inline_padding}#{body}#{inline_padding}#{closing}" unless pretty?
58
+
59
+ "#{opening}#{break_line(@level + 1)}#{body}#{break_line}#{closing}"
60
+ end
61
+
62
+ public
63
+
64
+ # Each statement of a union starts from the scope the union was entered with.
65
+ def render_union(union)
66
+ entry_names = @visited_names.dup
67
+ entry_aliases = @defined_aliases.dup
68
+ keyword = union.all ? ' UNION ALL' : ' UNION'
69
+ union.statements.map do |statement|
70
+ @visited_names = entry_names.dup
71
+ @defined_aliases = entry_aliases.dup
72
+ render(statement)
73
+ end.join("#{keyword}#{break_line}")
74
+ end
75
+
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
82
+
83
+ private
84
+
85
+ # --- clauses ---------------------------------------------------------
86
+
87
+ def render_clause(clause)
88
+ case clause
89
+ when Clauses::Match then render_match(clause)
90
+ when Clauses::With then render_with(clause)
91
+ when Clauses::Return then render_projection('RETURN', clause, nil)
92
+ when Clauses::RawReturn then clause.items.map { |item| render_projection_item(item) }.join(', ')
93
+ when Clauses::Create then "CREATE #{render_pattern_elements(clause.pattern_elements)}"
94
+ when Clauses::Merge then render_merge(clause)
95
+ when Clauses::Set then "SET #{render_operations(clause.operations)}"
96
+ when Clauses::Remove then "REMOVE #{render_operations(clause.items)}"
97
+ when Clauses::Delete then render_delete(clause)
98
+ when Clauses::Unwind
99
+ "UNWIND #{render_expression(clause.expression)} AS #{render_expression(clause.variable)}"
100
+ when Clauses::Finish then 'FINISH'
101
+ when Clauses::Foreach then render_foreach(clause)
102
+ when Clauses::Call then render_call(clause)
103
+ when Clauses::CallSubquery then render_call_subquery(clause)
104
+ when Clauses::LoadCsv then render_load_csv(clause)
105
+ when Clauses::Use then "USE #{clause.graph.is_a?(String) ? clause.graph : render_expression(clause.graph)}"
106
+ else raise ArgumentError, "Cannot render clause #{clause.inspect}"
107
+ end
108
+ end
109
+
110
+ 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)}"
113
+ end
114
+
115
+ def render_call(clause)
116
+ name = clause.name_parts.map { |part| Escaper.escape_if_necessary(part) }.join('.')
117
+ arguments = clause.arguments.map { |argument| render_expression(argument) }.join(', ')
118
+ parts = ["CALL #{name}(#{arguments})"]
119
+ yields = clause.yield_items.map { |item| render_projection_item(item) }
120
+ parts << "YIELD #{yields.join(', ')}" unless yields.empty?
121
+ parts << "WHERE #{render_expression(clause.where)}" if clause.where && !clause.where.is_a?(NoCondition)
122
+ parts.join(' ')
123
+ end
124
+
125
+ # `CALL (imports) { body }` on Neo4j 5.23+, `CALL { WITH imports body }` before.
126
+ # A subquery is its own scope: only imported names count as visited inside.
127
+ # A leading WITH of plain names in the body is an import list itself.
128
+ def render_call_subquery(clause)
129
+ body, imports = subquery_body_and_imports(clause)
130
+ suffix = clause.in_transactions ? " IN TRANSACTIONS#{" OF #{clause.rows} ROWS" if clause.rows}" : ''
131
+ if @configuration.legacy_subquery_syntax?
132
+ prefix = imports.empty? ? nil : "WITH #{imports.map { |import| render_expression(import) }.join(', ')}"
133
+ opening = 'CALL {'
134
+ else
135
+ list = imports.map { |import| render_expression(import) }.join(', ')
136
+ list = @first_clause ? '' : '*' if imports.empty?
137
+ prefix = nil
138
+ opening = "CALL (#{list}) {"
139
+ end
140
+ rendered = block(opening, '}', inline_padding: '') do
141
+ [prefix, render_in_scope(body, scope_of(imports))].compact.join(break_line)
142
+ end
143
+ "#{rendered}#{suffix}"
144
+ end
145
+
146
+ # An aliased import (`nodes AS x`) imports the name and renames it with a
147
+ # leading `WITH nodes AS x` inside the body.
148
+ def subquery_body_and_imports(clause)
149
+ body = clause.body
150
+ return [body, clause.imports] if clause.imports.any? && clause.imports.none?(AliasedExpression)
151
+ return renamed_imports(body, clause.imports) if clause.imports.any?
152
+ return [body, []] if body.is_a?(RawExpression) || body.is_a?(UnionQuery)
153
+
154
+ first = body.clauses.first
155
+ return [body, []] unless importing_with?(first)
156
+
157
+ legacy = @configuration.legacy_subquery_syntax?
158
+ [legacy ? body : Statement.new(body.clauses.drop(1), prefix: body.prefix), legacy ? [] : first.items]
159
+ end
160
+
161
+ def renamed_imports(body, imports)
162
+ names = imports.map { |import| import.is_a?(AliasedExpression) ? import.delegate : import }
163
+ rename = Clauses::With.new(distinct: false, items: imports, order: nil, skip: nil, limit: nil, where: nil)
164
+ [Statement.new([rename] + body.clauses, prefix: body.prefix), names]
165
+ end
166
+
167
+ def importing_with?(clause)
168
+ clause.is_a?(Clauses::With) && !clause.distinct && clause.order.nil? && clause.skip.nil? &&
169
+ clause.limit.nil? && clause.where.nil? &&
170
+ clause.items.all? { |item| item.is_a?(SymbolicName) || item.is_a?(Asterisk) }
171
+ end
172
+
173
+ # Imported names form the subquery's scope; `*` imports the whole current scope.
174
+ def scope_of(imports)
175
+ return @visited_names.dup if imports.any?(Asterisk)
176
+
177
+ imports.filter_map { |import| import.is_a?(SymbolicName) ? resolve_name(import) : nil }.to_set
178
+ end
179
+
180
+ def render_in_scope(body, scope)
181
+ return body.content if body.is_a?(RawExpression)
182
+
183
+ outer_scope = @visited_names
184
+ @visited_names = scope
185
+ rendered = render(body)
186
+ @visited_names = outer_scope
187
+ rendered
188
+ end
189
+
190
+ # A subquery statement rendered within the current scope (EXISTS / COLLECT).
191
+ def render_subquery(statement, imports = [])
192
+ outer_scope = @visited_names.dup
193
+ prefix = imports.empty? ? '' : "WITH #{imports.map { |import| render_projection_item(import) }.join(', ')} "
194
+ rendered = render(statement)
195
+ @visited_names = outer_scope
196
+ "#{prefix}#{rendered}"
197
+ end
198
+
199
+ # The body of a FOREACH is its own scope: names rendered inside it do not
200
+ # count as visited outside.
201
+ 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
205
+ "FOREACH (#{render_expression(clause.variable)} IN #{render_expression(clause.list)} | #{body})"
206
+ end
207
+
208
+ def render_match(clause)
209
+ [('OPTIONAL ' if clause.optional), 'MATCH ', render_pattern_elements(clause.pattern_elements),
210
+ render_where(clause.where)].join
211
+ end
212
+
213
+ def render_merge(clause)
214
+ actions = clause.actions.map do |action|
215
+ separator = pretty? ? break_line(@level + 1) : ' '
216
+ "#{separator}ON #{action.type == :on_match ? 'MATCH' : 'CREATE'} SET #{render_operations(action.operations)}"
217
+ end
218
+ "MERGE #{render_pattern_elements(clause.pattern_elements)}#{actions.join}"
219
+ end
220
+
221
+ def render_operations(operations)
222
+ operations.map { |operation| render_expression(operation) }.join(', ')
223
+ end
224
+
225
+ def render_delete(clause)
226
+ expressions = clause.expressions.map { |expression| render_expression(expression) }
227
+ "#{'DETACH ' if clause.detach}DELETE #{expressions.join(', ')}"
228
+ end
229
+
230
+ # WITH ends a query part: only the names it projects stay in scope, so a
231
+ # node that is not carried over renders with its labels again afterwards.
232
+ def render_with(clause)
233
+ rendered = render_projection('WITH', clause, clause.where)
234
+ @visited_names = clause.items.include?(Asterisk::INSTANCE) ? @visited_names : exported_names(clause.items)
235
+ rendered
236
+ end
237
+
238
+ def exported_names(items)
239
+ items.filter_map do |item|
240
+ case item
241
+ when AliasedExpression then resolve_name(item.alias_name)
242
+ when SymbolicName then resolve_name(item)
243
+ when Node, Relationship then item.symbolic_name && resolve_name(item.symbolic_name)
244
+ end
245
+ end.to_set
246
+ end
247
+
248
+ def render_projection(keyword, clause, where)
249
+ items = clause.items.map { |item| render_projection_item(item) }.join(', ')
250
+ parts = ["#{keyword} #{'DISTINCT ' if clause.distinct}#{items}"]
251
+ parts << "ORDER BY #{clause.order.items.map { |item| render_sort_item(item) }.join(', ')}" if clause.order
252
+ parts << "SKIP #{render_expression(clause.skip)}" if clause.skip
253
+ parts << "LIMIT #{render_expression(clause.limit)}" if clause.limit
254
+ "#{parts.join(' ')}#{render_where(where)}"
255
+ end
256
+
257
+ # In RETURN/WITH an aliased expression is defined once (`expr AS alias`);
258
+ # afterwards, and anywhere else, it is a reference and renders as the alias.
259
+ def render_projection_item(item)
260
+ return render_expression(item) unless item.is_a?(AliasedExpression)
261
+
262
+ alias_name = resolve_name(item.alias_name)
263
+ return Escaper.escape_if_necessary(alias_name) if alias_defined?(item, alias_name)
264
+
265
+ rendered = "#{render_expression(item.delegate)} AS #{render_expression(item.alias_name)}"
266
+ @defined_aliases[alias_name] = item.delegate
267
+ rendered
268
+ end
269
+
270
+ def render_aliased_reference(item)
271
+ alias_name = resolve_name(item.alias_name)
272
+ return Escaper.escape_if_necessary(alias_name) if alias_defined?(item, alias_name)
273
+
274
+ render_expression(item.delegate)
275
+ end
276
+
277
+ # The same alias may be given to another expression later (`WITH a AS x RETURN count(*) AS x`).
278
+ def alias_defined?(item, alias_name)
279
+ @defined_aliases.key?(alias_name) && @defined_aliases[alias_name] == item.delegate
280
+ end
281
+
282
+ def render_pattern_elements(elements)
283
+ elements.map { |element| render_pattern_element(element) }.join(', ')
284
+ end
285
+
286
+ # WHERE goes on its own line when pretty printing.
287
+ def render_where(condition)
288
+ return nil if condition.nil? || condition.is_a?(NoCondition)
289
+
290
+ "#{break_line}WHERE #{render_expression(condition)}"
291
+ end
292
+
293
+ def render_sort_item(item)
294
+ [render_expression(item.expression), item.direction&.to_s&.upcase].compact.join(' ')
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # Turns a {Statement} into Cypher text according to a {Renderer::Configuration}.
5
+ class Renderer
6
+ def self.default = @default ||= new(Configuration::DEFAULT)
7
+
8
+ # Mirrors `Renderer.getRenderer(configuration)`.
9
+ def self.get_renderer(configuration) = new(configuration)
10
+
11
+ attr_reader :configuration
12
+
13
+ def initialize(configuration)
14
+ @configuration = configuration
15
+ freeze
16
+ end
17
+
18
+ def render(statement) = Visitor.new(configuration).render(statement)
19
+
20
+ # The statement's parameters as a {StatementCatalog}; raises
21
+ # {ConflictingParametersException} when a name is bound inconsistently.
22
+ def catalog(statement)
23
+ visitor = Visitor.new(configuration)
24
+ visitor.render(statement)
25
+ visitor.catalog
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # One item of an ORDER BY: an expression with an optional direction.
5
+ class SortItem
6
+ attr_reader :expression, :direction
7
+
8
+ def initialize(expression, direction)
9
+ @expression = expression
10
+ @direction = direction
11
+ freeze
12
+ end
13
+
14
+ def ascending = SortItem.new(expression, :asc)
15
+ def descending = SortItem.new(expression, :desc)
16
+ end
17
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # `Cypher.call('db.labels')`: a procedure call being built. `with_args` adds
5
+ # arguments, `yield` selects outputs and continues as a statement builder,
6
+ # `build` finishes a bare call, `as_function` turns it into a function
7
+ # invocation instead.
8
+ class StandaloneCall
9
+ AGGREGATES = %w[avg collect count max min percentilecont percentiledisc stdev stdevp sum].freeze
10
+
11
+ attr_reader :name_parts, :arguments
12
+
13
+ # `name_parts` as given to `Cypher.call`: strings, symbols or arrays, dotted names split.
14
+ def initialize(name_parts, arguments: [], builder: nil)
15
+ @name_parts = name_parts.flatten.flat_map { |part| part.to_s.split('.') }.freeze
16
+ @arguments = arguments.freeze
17
+ @builder = builder
18
+ freeze
19
+ end
20
+
21
+ def procedure_name = name_parts.join('.')
22
+
23
+ def with_args(*expressions)
24
+ StandaloneCall.new(name_parts, arguments: expressions.flatten.map { |e| Expression.coerce(e) }, builder: @builder)
25
+ end
26
+
27
+ # `YIELD item, item AS alias, …`; continues as a statement builder.
28
+ def yield(*items)
29
+ yield_items = items.flatten.map { |item| yield_item(item) }
30
+ (@builder || StatementBuilder.new).send(:append, clause(yield_items), :condition)
31
+ end
32
+
33
+ def build = Statement.new((@builder&.send(:clauses) || []) + [clause([])])
34
+
35
+ # The call without YIELD, continuing as a statement builder (for `VOID` procedures).
36
+ def without_results = (@builder || StatementBuilder.new).send(:append, clause([]), nil)
37
+
38
+ # The procedure as a function invocation; `distinct` only for aggregates.
39
+ def as_function(distinct = false) # rubocop:disable Style/OptionalBooleanParameter
40
+ if distinct && !AGGREGATES.include?(procedure_name.downcase)
41
+ raise ArgumentError, 'The distinct operator can only be applied within aggregate functions.'
42
+ end
43
+
44
+ FunctionInvocation.new(procedure_name, arguments, distinct:)
45
+ end
46
+
47
+ private
48
+
49
+ def clause(yield_items)
50
+ Clauses::Call.new(name_parts:, arguments:, yield_items: yield_items.freeze, where: nil)
51
+ end
52
+
53
+ def yield_item(item)
54
+ case item
55
+ when String, Symbol then SymbolicName.of(item)
56
+ when SymbolicName, AliasedExpression, Asterisk then item
57
+ else raise ArgumentError, "Cannot yield #{item.inspect}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # A built, immutable statement: an ordered list of clauses. Rendering with the
5
+ # default renderer is memoised, as in the Java `Statement#getCypher`.
6
+ class Statement
7
+ attr_reader :clauses, :prefix
8
+
9
+ # An empty builder, as `Statement.builder()` in Java.
10
+ def self.builder = StatementBuilder.new
11
+
12
+ # A statement from a list of clause objects, as `Statement.of(clauses)` in Java.
13
+ def self.of(clauses) = new(clauses)
14
+
15
+ # `prefix` is `EXPLAIN` or `PROFILE` for the corresponding statement kinds.
16
+ def initialize(clauses, prefix: nil)
17
+ @clauses = clauses.freeze
18
+ @prefix = prefix
19
+ @cypher = nil
20
+ end
21
+
22
+ def cypher
23
+ @cypher ||= Renderer.default.render(self).freeze
24
+ end
25
+
26
+ # Parameter names and bound values; raises {ConflictingParametersException}
27
+ # when a name is bound inconsistently. Memoised, like `cypher`.
28
+ def catalog
29
+ @catalog ||= Renderer.default.catalog(self)
30
+ end
31
+
32
+ # `name => value` for every parameter that carries a value.
33
+ def parameters = catalog.parameters
34
+
35
+ # Every parameter name used, bound or not.
36
+ def parameter_names = catalog.parameter_names
37
+
38
+ # True when the statement produces rows: it ends with RETURN, or is a
39
+ # procedure call with YIELD.
40
+ def result_statement?
41
+ last = clauses.last
42
+ last.is_a?(Clauses::Return) || last.is_a?(Clauses::RawReturn) ||
43
+ (last.is_a?(Clauses::Call) && !last.yield_items.empty?)
44
+ end
45
+
46
+ # True when the statement is a bare procedure call, with or without YIELD.
47
+ def procedure_call? = clauses.size == 1 && clauses.first.is_a?(Clauses::Call)
48
+
49
+ # True when the statement contains no WITH, i.e. has a single query part.
50
+ def single_query? = !multi_part_query?
51
+
52
+ def multi_part_query? = clauses.any?(Clauses::With)
53
+
54
+ def to_s = cypher
55
+ end
56
+ end