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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/NOTICE +14 -0
- data/README.md +87 -0
- data/lib/cypher_dsl/aliased_expression.rb +16 -0
- data/lib/cypher_dsl/asterisk.rb +10 -0
- data/lib/cypher_dsl/case_expression.rb +37 -0
- data/lib/cypher_dsl/clauses/call.rb +8 -0
- data/lib/cypher_dsl/clauses/call_subquery.rb +9 -0
- data/lib/cypher_dsl/clauses/create.rb +8 -0
- data/lib/cypher_dsl/clauses/delete.rb +8 -0
- data/lib/cypher_dsl/clauses/finish.rb +8 -0
- data/lib/cypher_dsl/clauses/foreach.rb +8 -0
- data/lib/cypher_dsl/clauses/load_csv.rb +8 -0
- data/lib/cypher_dsl/clauses/match.rb +8 -0
- data/lib/cypher_dsl/clauses/merge.rb +8 -0
- data/lib/cypher_dsl/clauses/order.rb +8 -0
- data/lib/cypher_dsl/clauses/raw_return.rb +8 -0
- data/lib/cypher_dsl/clauses/remove.rb +8 -0
- data/lib/cypher_dsl/clauses/return.rb +8 -0
- data/lib/cypher_dsl/clauses/set.rb +8 -0
- data/lib/cypher_dsl/clauses/unwind.rb +8 -0
- data/lib/cypher_dsl/clauses/use.rb +8 -0
- data/lib/cypher_dsl/clauses/with.rb +8 -0
- data/lib/cypher_dsl/clauses.rb +25 -0
- data/lib/cypher_dsl/collect_expression.rb +28 -0
- data/lib/cypher_dsl/comparison.rb +27 -0
- data/lib/cypher_dsl/compound_condition.rb +34 -0
- data/lib/cypher_dsl/condition.rb +35 -0
- data/lib/cypher_dsl/conflicting_parameters_exception.rb +15 -0
- data/lib/cypher_dsl/constant_condition.rb +17 -0
- data/lib/cypher_dsl/count_expression.rb +16 -0
- data/lib/cypher_dsl/cypher.rb +210 -0
- data/lib/cypher_dsl/exists_condition.rb +15 -0
- data/lib/cypher_dsl/expression.rb +69 -0
- data/lib/cypher_dsl/expression_condition.rb +14 -0
- data/lib/cypher_dsl/function_invocation.rb +17 -0
- data/lib/cypher_dsl/functions.rb +182 -0
- data/lib/cypher_dsl/has_label_condition.rb +15 -0
- data/lib/cypher_dsl/label_expression.rb +40 -0
- data/lib/cypher_dsl/label_operation.rb +18 -0
- data/lib/cypher_dsl/list_comprehension.rb +40 -0
- data/lib/cypher_dsl/list_expression.rb +17 -0
- data/lib/cypher_dsl/list_operator.rb +24 -0
- data/lib/cypher_dsl/list_predicate.rb +32 -0
- data/lib/cypher_dsl/literal.rb +29 -0
- data/lib/cypher_dsl/map_expression.rb +32 -0
- data/lib/cypher_dsl/map_projection.rb +58 -0
- data/lib/cypher_dsl/merge_action.rb +11 -0
- data/lib/cypher_dsl/named_path.rb +59 -0
- data/lib/cypher_dsl/no_condition.rb +14 -0
- data/lib/cypher_dsl/node.rb +83 -0
- data/lib/cypher_dsl/operation.rb +22 -0
- data/lib/cypher_dsl/parameter.rb +40 -0
- data/lib/cypher_dsl/pattern_comprehension.rb +31 -0
- data/lib/cypher_dsl/pattern_expression.rb +16 -0
- data/lib/cypher_dsl/property.rb +24 -0
- data/lib/cypher_dsl/quantified_path_pattern.rb +37 -0
- data/lib/cypher_dsl/raw_expression.rb +15 -0
- data/lib/cypher_dsl/reduction.rb +33 -0
- data/lib/cypher_dsl/relationship.rb +97 -0
- data/lib/cypher_dsl/relationship_chain.rb +44 -0
- data/lib/cypher_dsl/relationship_pattern_condition.rb +14 -0
- data/lib/cypher_dsl/renderer/configuration.rb +66 -0
- data/lib/cypher_dsl/renderer/dialect.rb +20 -0
- data/lib/cypher_dsl/renderer/escaper.rb +31 -0
- data/lib/cypher_dsl/renderer/visitor/expressions.rb +274 -0
- data/lib/cypher_dsl/renderer/visitor/patterns.rb +125 -0
- data/lib/cypher_dsl/renderer/visitor.rb +298 -0
- data/lib/cypher_dsl/renderer.rb +28 -0
- data/lib/cypher_dsl/sort_item.rb +17 -0
- data/lib/cypher_dsl/standalone_call.rb +61 -0
- data/lib/cypher_dsl/statement.rb +56 -0
- data/lib/cypher_dsl/statement_builder.rb +294 -0
- data/lib/cypher_dsl/statement_catalog.rb +25 -0
- data/lib/cypher_dsl/subquery_with.rb +15 -0
- data/lib/cypher_dsl/symbolic_name.rb +48 -0
- data/lib/cypher_dsl/unary_operation.rb +16 -0
- data/lib/cypher_dsl/union_query.rb +29 -0
- data/lib/cypher_dsl/version.rb +5 -0
- data/lib/cypher_dsl.rb +13 -0
- 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
|