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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -1
- data/README.md +66 -4
- data/lib/cypher_dsl/clauses/load_csv.rb +8 -2
- data/lib/cypher_dsl/clauses/match.rb +2 -2
- data/lib/cypher_dsl/clauses/raw.rb +9 -0
- data/lib/cypher_dsl/clauses.rb +11 -0
- data/lib/cypher_dsl/comparison.rb +6 -0
- data/lib/cypher_dsl/condition.rb +1 -1
- data/lib/cypher_dsl/count_expression.rb +7 -3
- data/lib/cypher_dsl/cypher.rb +80 -19
- data/lib/cypher_dsl/expression.rb +3 -0
- data/lib/cypher_dsl/function_invocation.rb +7 -0
- data/lib/cypher_dsl/functions.rb +11 -1
- data/lib/cypher_dsl/has_label_condition.rb +1 -1
- data/lib/cypher_dsl/hint.rb +71 -0
- data/lib/cypher_dsl/label_operation.rb +11 -3
- data/lib/cypher_dsl/labels.rb +66 -0
- data/lib/cypher_dsl/literal.rb +24 -2
- data/lib/cypher_dsl/named_path.rb +6 -3
- data/lib/cypher_dsl/node.rb +15 -4
- data/lib/cypher_dsl/property.rb +9 -2
- data/lib/cypher_dsl/property_exists_condition.rb +15 -0
- data/lib/cypher_dsl/raw_expression.rb +76 -3
- data/lib/cypher_dsl/relationship.rb +8 -1
- data/lib/cypher_dsl/relationship_chain.rb +3 -0
- data/lib/cypher_dsl/renderer/configuration.rb +61 -4
- data/lib/cypher_dsl/renderer/dialect.rb +24 -7
- data/lib/cypher_dsl/renderer/escaper.rb +10 -7
- data/lib/cypher_dsl/renderer/generated_name_resolver.rb +110 -0
- data/lib/cypher_dsl/renderer/name_resolver.rb +35 -0
- data/lib/cypher_dsl/renderer/visitor/conditions.rb +74 -0
- data/lib/cypher_dsl/renderer/visitor/expressions.rb +40 -40
- data/lib/cypher_dsl/renderer/visitor/label_rendering.rb +84 -0
- data/lib/cypher_dsl/renderer/visitor/patterns.rb +18 -29
- data/lib/cypher_dsl/renderer/visitor.rb +106 -29
- data/lib/cypher_dsl/renderer.rb +11 -5
- data/lib/cypher_dsl/search.rb +91 -0
- data/lib/cypher_dsl/standalone_call.rb +0 -6
- data/lib/cypher_dsl/statement.rb +4 -0
- data/lib/cypher_dsl/statement_builder.rb +81 -15
- data/lib/cypher_dsl/statement_catalog.rb +40 -6
- data/lib/cypher_dsl/subquery_with.rb +1 -0
- data/lib/cypher_dsl/symbolic_name.rb +1 -1
- data/lib/cypher_dsl/using_periodic_commit.rb +11 -0
- data/lib/cypher_dsl/vector_literal.rb +41 -0
- data/lib/cypher_dsl/version.rb +1 -1
- metadata +13 -3
- data/lib/cypher_dsl/label_expression.rb +0 -40
|
@@ -8,28 +8,55 @@ module CypherDSL
|
|
|
8
8
|
class Visitor
|
|
9
9
|
include Patterns
|
|
10
10
|
include Expressions
|
|
11
|
+
include Conditions
|
|
12
|
+
include LabelRendering
|
|
11
13
|
|
|
12
14
|
def initialize(configuration)
|
|
13
15
|
@configuration = configuration
|
|
14
16
|
@visited_names = Set.new
|
|
15
17
|
@defined_aliases = {}
|
|
16
|
-
@
|
|
18
|
+
@names = if configuration.use_generated_names?
|
|
19
|
+
GeneratedNameResolver.new(configuration.generated_names)
|
|
20
|
+
else
|
|
21
|
+
NameResolver.new
|
|
22
|
+
end
|
|
23
|
+
@renamed_parameters = {}
|
|
17
24
|
@parameter_uses = {}
|
|
25
|
+
@depth = 0
|
|
26
|
+
@in_last_return = false
|
|
18
27
|
@anonymous_parameters = []
|
|
19
28
|
@level = 0
|
|
20
29
|
end
|
|
21
30
|
|
|
22
|
-
# The parameters seen while rendering
|
|
23
|
-
def catalog = StatementCatalog.from_uses(@parameter_uses)
|
|
31
|
+
# The parameters seen while rendering `statement`; see {Renderer#catalog}.
|
|
32
|
+
def catalog(statement = nil) = StatementCatalog.from_uses(@parameter_uses, @renamed_parameters, statement:)
|
|
33
|
+
|
|
34
|
+
# A part of a statement on its own.
|
|
35
|
+
def render_fragment(fragment)
|
|
36
|
+
case fragment
|
|
37
|
+
when Search then render_search(fragment).lstrip
|
|
38
|
+
when Node, Relationship, RelationshipChain, NamedPath, QuantifiedPathPattern
|
|
39
|
+
render_pattern_element(fragment)
|
|
40
|
+
when Statement, UnionQuery then render(fragment)
|
|
41
|
+
else Clauses.clause?(fragment) ? render_clause(fragment) : render_expression(fragment)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
24
44
|
|
|
25
45
|
def render(statement)
|
|
26
46
|
return render_union(statement) if statement.is_a?(UnionQuery)
|
|
27
47
|
|
|
48
|
+
@depth += 1
|
|
49
|
+
last = statement.clauses.size - 1
|
|
50
|
+
leading_use = statement.clauses.first.is_a?(Clauses::Use)
|
|
28
51
|
rendered = statement.clauses.each_with_index.map do |clause, index|
|
|
29
|
-
|
|
52
|
+
# A leading USE does not count: `USE g CALL () {…}` opens the statement's scope.
|
|
53
|
+
@first_clause = (index.zero? || (leading_use && index == 1)) && @visited_names.empty?
|
|
54
|
+
@in_last_return = @depth == 1 && index == last && clause.is_a?(Clauses::Return)
|
|
30
55
|
render_clause(clause)
|
|
31
56
|
end.join(break_line)
|
|
32
57
|
statement.prefix ? "#{statement.prefix} #{rendered}" : rendered
|
|
58
|
+
ensure
|
|
59
|
+
@depth -= 1
|
|
33
60
|
end
|
|
34
61
|
|
|
35
62
|
private
|
|
@@ -73,12 +100,8 @@ module CypherDSL
|
|
|
73
100
|
end.join("#{keyword}#{break_line}")
|
|
74
101
|
end
|
|
75
102
|
|
|
76
|
-
#
|
|
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
|
|
103
|
+
# The rendered name of a symbolic name; see {NameResolver}.
|
|
104
|
+
def resolve_name(symbolic_name, in_entity: false) = @names.resolve(symbolic_name, in_entity:)
|
|
82
105
|
|
|
83
106
|
private
|
|
84
107
|
|
|
@@ -95,21 +118,32 @@ module CypherDSL
|
|
|
95
118
|
when Clauses::Set then "SET #{render_operations(clause.operations)}"
|
|
96
119
|
when Clauses::Remove then "REMOVE #{render_operations(clause.items)}"
|
|
97
120
|
when Clauses::Delete then render_delete(clause)
|
|
98
|
-
when Clauses::Unwind
|
|
99
|
-
"UNWIND #{render_expression(clause.expression)} AS #{render_expression(clause.variable)}"
|
|
121
|
+
when Clauses::Unwind then render_unwind(clause)
|
|
100
122
|
when Clauses::Finish then 'FINISH'
|
|
101
123
|
when Clauses::Foreach then render_foreach(clause)
|
|
102
124
|
when Clauses::Call then render_call(clause)
|
|
103
125
|
when Clauses::CallSubquery then render_call_subquery(clause)
|
|
104
126
|
when Clauses::LoadCsv then render_load_csv(clause)
|
|
105
127
|
when Clauses::Use then "USE #{clause.graph.is_a?(String) ? clause.graph : render_expression(clause.graph)}"
|
|
128
|
+
when Clauses::Raw then render_raw(clause.expression)
|
|
106
129
|
else raise ArgumentError, "Cannot render clause #{clause.inspect}"
|
|
107
130
|
end
|
|
108
131
|
end
|
|
109
132
|
|
|
133
|
+
def render_unwind(clause)
|
|
134
|
+
"UNWIND #{render_expression(clause.expression)} AS #{render_expression(clause.variable)}"
|
|
135
|
+
end
|
|
136
|
+
|
|
110
137
|
def render_load_csv(clause)
|
|
111
|
-
|
|
112
|
-
|
|
138
|
+
parts = []
|
|
139
|
+
if clause.periodic_commit
|
|
140
|
+
rate = clause.periodic_commit == true ? '' : " #{clause.periodic_commit}"
|
|
141
|
+
parts << "USING PERIODIC COMMIT#{rate}"
|
|
142
|
+
end
|
|
143
|
+
parts << (clause.with_headers ? 'LOAD CSV WITH HEADERS' : 'LOAD CSV')
|
|
144
|
+
parts << "FROM #{render_literal(clause.uri)} AS #{render_expression(clause.alias_name)}"
|
|
145
|
+
parts << "FIELDTERMINATOR #{render_literal(clause.field_terminator)}" if clause.field_terminator
|
|
146
|
+
parts.join(' ')
|
|
113
147
|
end
|
|
114
148
|
|
|
115
149
|
def render_call(clause)
|
|
@@ -138,11 +172,21 @@ module CypherDSL
|
|
|
138
172
|
opening = "CALL (#{list}) {"
|
|
139
173
|
end
|
|
140
174
|
rendered = block(opening, '}', inline_padding: '') do
|
|
141
|
-
[prefix, render_in_scope(body, scope_of(imports))].compact.join(break_line)
|
|
175
|
+
[prefix, render_in_scope(body, scope_of(imports), imports)].compact.join(break_line)
|
|
142
176
|
end
|
|
143
177
|
"#{rendered}#{suffix}"
|
|
144
178
|
end
|
|
145
179
|
|
|
180
|
+
# What a subquery hands back to the outer scope: the items of its final RETURN.
|
|
181
|
+
def exports_of(body)
|
|
182
|
+
return [] unless body.is_a?(Statement) && body.clauses.last.is_a?(Clauses::Return)
|
|
183
|
+
|
|
184
|
+
items = body.clauses.last.items
|
|
185
|
+
return :all if items.any?(Asterisk)
|
|
186
|
+
|
|
187
|
+
items.select { |item| [SymbolicName, AliasedExpression, Node, Relationship].any? { |type| item.is_a?(type) } }
|
|
188
|
+
end
|
|
189
|
+
|
|
146
190
|
# An aliased import (`nodes AS x`) imports the name and renames it with a
|
|
147
191
|
# leading `WITH nodes AS x` inside the body.
|
|
148
192
|
def subquery_body_and_imports(clause)
|
|
@@ -177,37 +221,69 @@ module CypherDSL
|
|
|
177
221
|
imports.filter_map { |import| import.is_a?(SymbolicName) ? resolve_name(import) : nil }.to_set
|
|
178
222
|
end
|
|
179
223
|
|
|
180
|
-
|
|
181
|
-
|
|
224
|
+
# A CALL subquery body: only the imports are visible inside, the final RETURN is exported.
|
|
225
|
+
def render_in_scope(body, scope, imports)
|
|
226
|
+
return render_raw(body) if body.is_a?(RawExpression)
|
|
182
227
|
|
|
183
228
|
outer_scope = @visited_names
|
|
184
229
|
@visited_names = scope
|
|
230
|
+
@names.enter_scope(imports.any?(Asterisk) ? :all : imports)
|
|
185
231
|
rendered = render(body)
|
|
232
|
+
@names.leave_scope(exports_of(body))
|
|
186
233
|
@visited_names = outer_scope
|
|
187
234
|
rendered
|
|
188
235
|
end
|
|
189
236
|
|
|
190
237
|
# A subquery statement rendered within the current scope (EXISTS / COLLECT).
|
|
191
238
|
def render_subquery(statement, imports = [])
|
|
192
|
-
outer_scope = @visited_names.dup
|
|
193
239
|
prefix = imports.empty? ? '' : "WITH #{imports.map { |import| render_projection_item(import) }.join(', ')} "
|
|
194
|
-
rendered = render(statement)
|
|
195
|
-
@visited_names = outer_scope
|
|
240
|
+
rendered = render_in_own_scope { render(statement) }
|
|
196
241
|
"#{prefix}#{rendered}"
|
|
197
242
|
end
|
|
198
243
|
|
|
199
244
|
# The body of a FOREACH is its own scope: names rendered inside it do not
|
|
200
245
|
# count as visited outside.
|
|
201
246
|
def render_foreach(clause)
|
|
202
|
-
|
|
203
|
-
body = clause.clauses.map { |inner| render_clause(inner) }.join(' ')
|
|
204
|
-
@visited_names = outer_scope
|
|
247
|
+
body = render_in_own_scope { clause.clauses.map { |inner| render_clause(inner) }.join(' ') }
|
|
205
248
|
"FOREACH (#{render_expression(clause.variable)} IN #{render_expression(clause.list)} | #{body})"
|
|
206
249
|
end
|
|
207
250
|
|
|
208
251
|
def render_match(clause)
|
|
252
|
+
hints = clause.hints.map { |hint| "#{break_line}#{render_hint(hint)}" }
|
|
253
|
+
search = clause.search && render_search(clause.search)
|
|
209
254
|
[('OPTIONAL ' if clause.optional), 'MATCH ', render_pattern_elements(clause.pattern_elements),
|
|
210
|
-
render_where(clause.where)].join
|
|
255
|
+
*hints, search, render_where(clause.where)].join
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# `SEARCH name IN (VECTOR INDEX index FOR vector [WHERE …] LIMIT k) [SCORE AS score]`; pretty
|
|
259
|
+
# printed it sits indented under its MATCH with one option per line.
|
|
260
|
+
def render_search(search)
|
|
261
|
+
score = search.score_name && " SCORE AS #{render_expression(search.score_name)}"
|
|
262
|
+
name = render_expression(search.name)
|
|
263
|
+
return " SEARCH #{name} IN (#{search_options(search).join(' ')})#{score}" unless pretty?
|
|
264
|
+
|
|
265
|
+
body = nested { nested { search_options(search).join(break_line) } }
|
|
266
|
+
"#{break_line(@level + 1)}SEARCH #{name} IN (#{break_line(@level + 2)}#{body}#{break_line(@level + 1)})#{score}"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def search_options(search)
|
|
270
|
+
predicate = search.predicates.inject { |all, predicate| all.and(predicate) }
|
|
271
|
+
options = ["VECTOR INDEX #{Escaper.escape_if_necessary(search.index_name)}",
|
|
272
|
+
"FOR #{render_expression(search.vector)}"]
|
|
273
|
+
options << "WHERE #{render_expression(predicate)}" if predicate
|
|
274
|
+
options << "LIMIT #{search.top_k}" if search.top_k
|
|
275
|
+
options
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def render_hint(hint)
|
|
279
|
+
case hint.type
|
|
280
|
+
when :join then "USING JOIN ON #{hint.names.map { |name| render_expression(name) }.join(', ')}"
|
|
281
|
+
when :scan then "USING SCAN #{render_expression(hint.container)}:#{escape_schema_name(hint.schema_name)}"
|
|
282
|
+
else
|
|
283
|
+
keyword = hint.type == :index_seek ? 'USING INDEX SEEK' : 'USING INDEX'
|
|
284
|
+
properties = hint.property_names.map { |name| Escaper.escape_if_necessary(name) }.join(', ')
|
|
285
|
+
"#{keyword} #{render_expression(hint.container)}:#{escape_schema_name(hint.schema_name)}(#{properties})"
|
|
286
|
+
end
|
|
211
287
|
end
|
|
212
288
|
|
|
213
289
|
def render_merge(clause)
|
|
@@ -238,7 +314,7 @@ module CypherDSL
|
|
|
238
314
|
def exported_names(items)
|
|
239
315
|
items.filter_map do |item|
|
|
240
316
|
case item
|
|
241
|
-
when AliasedExpression then
|
|
317
|
+
when AliasedExpression then @names.resolve_alias(item, in_last_return: @in_last_return)
|
|
242
318
|
when SymbolicName then resolve_name(item)
|
|
243
319
|
when Node, Relationship then item.symbolic_name && resolve_name(item.symbolic_name)
|
|
244
320
|
end
|
|
@@ -259,16 +335,17 @@ module CypherDSL
|
|
|
259
335
|
def render_projection_item(item)
|
|
260
336
|
return render_expression(item) unless item.is_a?(AliasedExpression)
|
|
261
337
|
|
|
262
|
-
alias_name =
|
|
338
|
+
alias_name = @names.resolve_alias(item, in_last_return: @in_last_return)
|
|
263
339
|
return Escaper.escape_if_necessary(alias_name) if alias_defined?(item, alias_name)
|
|
264
340
|
|
|
265
|
-
|
|
341
|
+
delegate = render_expression(item.delegate)
|
|
342
|
+
alias_name = @names.resolve_alias(item, define: true, in_last_return: @in_last_return)
|
|
266
343
|
@defined_aliases[alias_name] = item.delegate
|
|
267
|
-
|
|
344
|
+
"#{delegate} AS #{Escaper.escape_if_necessary(alias_name)}"
|
|
268
345
|
end
|
|
269
346
|
|
|
270
347
|
def render_aliased_reference(item)
|
|
271
|
-
alias_name =
|
|
348
|
+
alias_name = @names.resolve_alias(item, in_last_return: @in_last_return)
|
|
272
349
|
return Escaper.escape_if_necessary(alias_name) if alias_defined?(item, alias_name)
|
|
273
350
|
|
|
274
351
|
render_expression(item.delegate)
|
data/lib/cypher_dsl/renderer.rb
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
module CypherDSL
|
|
4
4
|
# Turns a {Statement} into Cypher text according to a {Renderer::Configuration}.
|
|
5
5
|
class Renderer
|
|
6
|
-
def self.default = @default ||= new(Configuration
|
|
6
|
+
def self.default = @default ||= new(Configuration.default)
|
|
7
|
+
|
|
8
|
+
# The default configuration without forced escaping; used by `to_s` on expressions and patterns.
|
|
9
|
+
def self.plain = @plain ||= new(Configuration.new(always_escape_names: false))
|
|
7
10
|
|
|
8
11
|
# Mirrors `Renderer.getRenderer(configuration)`.
|
|
9
12
|
def self.get_renderer(configuration) = new(configuration)
|
|
@@ -15,14 +18,17 @@ module CypherDSL
|
|
|
15
18
|
freeze
|
|
16
19
|
end
|
|
17
20
|
|
|
18
|
-
def render(statement) = Visitor.new(configuration).render(statement)
|
|
21
|
+
def render(statement) = "#{configuration.prefix}#{Visitor.new(configuration).render(statement)}"
|
|
22
|
+
|
|
23
|
+
# Renders a part of a statement on its own: an expression, a pattern, a clause or a {Search}.
|
|
24
|
+
def render_fragment(fragment) = Visitor.new(configuration).render_fragment(fragment)
|
|
19
25
|
|
|
20
|
-
# The statement's parameters as a {StatementCatalog};
|
|
21
|
-
# {ConflictingParametersException} when a name is bound inconsistently.
|
|
26
|
+
# The statement's parameters and identifiable expressions as a {StatementCatalog};
|
|
27
|
+
# raises {ConflictingParametersException} when a name is bound inconsistently.
|
|
22
28
|
def catalog(statement)
|
|
23
29
|
visitor = Visitor.new(configuration)
|
|
24
30
|
visitor.render(statement)
|
|
25
|
-
visitor.catalog
|
|
31
|
+
visitor.catalog(statement)
|
|
26
32
|
end
|
|
27
33
|
end
|
|
28
34
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# `SEARCH movie IN (VECTOR INDEX moviePlots FOR $vector WHERE … LIMIT 4) SCORE AS score`,
|
|
5
|
+
# attached to a MATCH clause. Built with
|
|
6
|
+
# `Cypher.search(:movie).in('moviePlots').for_vector(vector).where(…).limit(4).score(:s).build`.
|
|
7
|
+
class Search
|
|
8
|
+
SUPPORTED_VECTORS = [VectorLiteral, ListExpression, Parameter, Property].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :index_name, :vector, :predicates, :top_k, :score_name
|
|
11
|
+
|
|
12
|
+
def initialize(name:, index_name:, vector:, predicates:, top_k:, score_name:)
|
|
13
|
+
@name = name
|
|
14
|
+
@index_name = index_name
|
|
15
|
+
@vector = vector
|
|
16
|
+
@predicates = predicates.freeze
|
|
17
|
+
@top_k = top_k
|
|
18
|
+
@score_name = score_name
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The search rendered on its own with the default renderer.
|
|
23
|
+
def cypher = Renderer.default.render_fragment(self)
|
|
24
|
+
alias to_s cypher
|
|
25
|
+
|
|
26
|
+
# The ongoing definition; every step returns a new builder.
|
|
27
|
+
class Builder
|
|
28
|
+
def initialize(name, index_name: nil, vector: nil, predicates: [], top_k: nil, score_name: nil)
|
|
29
|
+
raise ArgumentError, 'Name is required' if name.nil?
|
|
30
|
+
|
|
31
|
+
@name = name
|
|
32
|
+
@index_name = index_name
|
|
33
|
+
@vector = vector
|
|
34
|
+
@predicates = predicates
|
|
35
|
+
@top_k = top_k
|
|
36
|
+
@score_name = score_name
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def in(index_name)
|
|
41
|
+
if index_name.nil? || index_name.to_s.strip.empty?
|
|
42
|
+
raise ArgumentError, 'The index name must not be null or empty'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
with(index_name: index_name.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def for_vector(vector)
|
|
49
|
+
raise ArgumentError, 'Vector is required' if vector.nil?
|
|
50
|
+
unless Search::SUPPORTED_VECTORS.any? { |type| vector.is_a?(type) }
|
|
51
|
+
raise ArgumentError, "Unsupported vector expression #{vector.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
with(vector:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Only comparisons; several are combined with AND.
|
|
58
|
+
def where(*predicates)
|
|
59
|
+
predicates = predicates.flatten
|
|
60
|
+
raise ArgumentError, 'A predicate is required' if predicates.empty? || predicates.any?(nil)
|
|
61
|
+
raise ArgumentError, 'Only comparison conditions are supported' unless predicates.all?(Comparison)
|
|
62
|
+
|
|
63
|
+
with(predicates:)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def limit(top_k)
|
|
67
|
+
raise ArgumentError, 'topK must be greater than or equal to 0' if top_k.negative?
|
|
68
|
+
|
|
69
|
+
with(top_k:)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def score(name) = with(score_name: SymbolicName.of(name))
|
|
73
|
+
|
|
74
|
+
def build
|
|
75
|
+
raise ArgumentError, 'The index name is required' if @index_name.nil?
|
|
76
|
+
raise ArgumentError, 'The vector is required' if @vector.nil?
|
|
77
|
+
|
|
78
|
+
Search.new(name: @name, index_name: @index_name, vector: @vector, predicates: @predicates, top_k: @top_k,
|
|
79
|
+
score_name: @score_name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def with(**changes)
|
|
85
|
+
options = { index_name: @index_name, vector: @vector, predicates: @predicates, top_k: @top_k,
|
|
86
|
+
score_name: @score_name }.merge(changes)
|
|
87
|
+
Builder.new(@name, **options)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -6,8 +6,6 @@ module CypherDSL
|
|
|
6
6
|
# `build` finishes a bare call, `as_function` turns it into a function
|
|
7
7
|
# invocation instead.
|
|
8
8
|
class StandaloneCall
|
|
9
|
-
AGGREGATES = %w[avg collect count max min percentilecont percentiledisc stdev stdevp sum].freeze
|
|
10
|
-
|
|
11
9
|
attr_reader :name_parts, :arguments
|
|
12
10
|
|
|
13
11
|
# `name_parts` as given to `Cypher.call`: strings, symbols or arrays, dotted names split.
|
|
@@ -37,10 +35,6 @@ module CypherDSL
|
|
|
37
35
|
|
|
38
36
|
# The procedure as a function invocation; `distinct` only for aggregates.
|
|
39
37
|
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
38
|
FunctionInvocation.new(procedure_name, arguments, distinct:)
|
|
45
39
|
end
|
|
46
40
|
|
data/lib/cypher_dsl/statement.rb
CHANGED
|
@@ -23,6 +23,10 @@ module CypherDSL
|
|
|
23
23
|
@cypher ||= Renderer.default.render(self).freeze
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# The same statement with `EXPLAIN` / `PROFILE` in front.
|
|
27
|
+
def explain = Statement.new(clauses, prefix: 'EXPLAIN')
|
|
28
|
+
def profile = Statement.new(clauses, prefix: 'PROFILE')
|
|
29
|
+
|
|
26
30
|
# Parameter names and bound values; raises {ConflictingParametersException}
|
|
27
31
|
# when a name is bound inconsistently. Memoised, like `cypher`.
|
|
28
32
|
def catalog
|
|
@@ -19,11 +19,32 @@ module CypherDSL
|
|
|
19
19
|
# `match(pattern, …)` or, as in Java, `match(optional, pattern, …)` with a leading flag.
|
|
20
20
|
def match(*pattern_elements)
|
|
21
21
|
optional = [true, false].include?(pattern_elements.first) ? pattern_elements.shift : false
|
|
22
|
-
|
|
22
|
+
raise ArgumentError, 'Patterns to match are required.' if pattern_elements.any?(&:nil?)
|
|
23
|
+
|
|
24
|
+
pattern_elements = pattern_elements.flatten.freeze
|
|
25
|
+
raise ArgumentError, 'At least one pattern to match is required.' if pattern_elements.empty?
|
|
26
|
+
|
|
27
|
+
append(Clauses::Match.new(optional:, pattern_elements:, hints: [].freeze, search: nil, where: nil), :condition)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A vector `SEARCH` on the preceding MATCH; see {Search}.
|
|
31
|
+
def search(search)
|
|
32
|
+
update_last(:condition) do |clause|
|
|
33
|
+
raise ArgumentError, 'SEARCH needs a preceding MATCH.' unless clause.is_a?(Clauses::Match)
|
|
34
|
+
|
|
35
|
+
clause.with(search:)
|
|
36
|
+
end
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def optional_match(*pattern_elements) = match(true, *pattern_elements)
|
|
26
40
|
|
|
41
|
+
# Planner hints on the preceding MATCH: `using_index(n.property(:name))`,
|
|
42
|
+
# `using_index_seek(…)`, `using_scan(n)`, `using_join_on(n, m)`.
|
|
43
|
+
def using_index(*properties) = add_hint(Hint.use_index_for(false, *properties))
|
|
44
|
+
def using_index_seek(*properties) = add_hint(Hint.use_index_for(true, *properties))
|
|
45
|
+
def using_scan(node) = add_hint(Hint.use_scan_for(node))
|
|
46
|
+
def using_join_on(*nodes_or_names) = add_hint(Hint.use_join_on(*nodes_or_names))
|
|
47
|
+
|
|
27
48
|
def where(condition)
|
|
28
49
|
if @clauses.last.is_a?(Clauses::CallSubquery)
|
|
29
50
|
raise ArgumentError, 'A CALL{} clause requires to WITH before you can add further conditions'
|
|
@@ -52,15 +73,19 @@ module CypherDSL
|
|
|
52
73
|
def returning_raw(*expressions) = append(Clauses::RawReturn.new(items: coerce_items(expressions)), :projection)
|
|
53
74
|
|
|
54
75
|
def with(*expressions)
|
|
55
|
-
append(Clauses::With.new(distinct: false, items:
|
|
56
|
-
where: nil), :condition)
|
|
76
|
+
append(Clauses::With.new(distinct: false, items: projection_items(expressions), order: nil, skip: nil,
|
|
77
|
+
limit: nil, where: nil), :condition)
|
|
57
78
|
end
|
|
58
79
|
|
|
59
80
|
def with_distinct(*expressions)
|
|
60
|
-
append(Clauses::With.new(distinct: true, items:
|
|
61
|
-
where: nil), :condition)
|
|
81
|
+
append(Clauses::With.new(distinct: true, items: projection_items(expressions), order: nil, skip: nil,
|
|
82
|
+
limit: nil, where: nil), :condition)
|
|
62
83
|
end
|
|
63
84
|
|
|
85
|
+
# The names and expressions in scope after the last WITH or RETURN, as
|
|
86
|
+
# `getIdentifiableExpressions()` on the Java builder states.
|
|
87
|
+
def identifiable_expressions = StatementCatalog.identifiable_expressions_of(build)
|
|
88
|
+
|
|
64
89
|
def order_by(*sort_items)
|
|
65
90
|
update_last(:order) { |clause| clause.with(order: Clauses::Order.new(items: coerce_sort_items(sort_items))) }
|
|
66
91
|
end
|
|
@@ -136,11 +161,34 @@ module CypherDSL
|
|
|
136
161
|
end
|
|
137
162
|
|
|
138
163
|
# `load_csv('file:///x.csv').as(:line)`.
|
|
139
|
-
def load_csv(uri,
|
|
164
|
+
def load_csv(uri, positional_headers = false, with_headers: positional_headers, periodic_commit: nil) # rubocop:disable Style/OptionalBooleanParameter
|
|
165
|
+
OngoingLoadCsv.new(self, uri.to_s, with_headers, periodic_commit)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# `FIELDTERMINATOR ';'` on the preceding LOAD CSV.
|
|
169
|
+
def with_field_terminator(terminator)
|
|
170
|
+
update_last(nil) do |clause|
|
|
171
|
+
raise ArgumentError, 'FIELDTERMINATOR needs a preceding LOAD CSV.' unless clause.is_a?(Clauses::LoadCsv)
|
|
172
|
+
|
|
173
|
+
clause.with_field_terminator(terminator&.to_s)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
140
176
|
|
|
141
177
|
# The statement built so far as an `EXISTS { … }` condition.
|
|
142
178
|
def as_condition = ExistsCondition.new(build)
|
|
143
179
|
|
|
180
|
+
# --- escape hatch ----------------------------------------------------
|
|
181
|
+
|
|
182
|
+
# A clause rendered verbatim, for Cypher the builder does not model, e.g.
|
|
183
|
+
# `raw_clause('CALL db.labels() YIELD label')`. Takes the arguments of
|
|
184
|
+
# `Cypher.raw` (a format with `$E` placeholders) or a ready {RawExpression}.
|
|
185
|
+
# The next clause starts afresh: a raw clause cannot be refined with WHERE,
|
|
186
|
+
# ORDER BY, SKIP or LIMIT.
|
|
187
|
+
def raw_clause(format, *)
|
|
188
|
+
expression = format.is_a?(RawExpression) ? format : RawExpression.create(format, *)
|
|
189
|
+
append(Clauses::Raw.new(expression:), nil)
|
|
190
|
+
end
|
|
191
|
+
|
|
144
192
|
# --- finishing -------------------------------------------------------
|
|
145
193
|
|
|
146
194
|
# Continues with the clauses of another statement, e.g. after a `CALL … YIELD`.
|
|
@@ -176,23 +224,22 @@ module CypherDSL
|
|
|
176
224
|
|
|
177
225
|
def in(list) = OngoingForeach.new(@builder, @variable, Expression.coerce(list))
|
|
178
226
|
|
|
179
|
-
def apply(*clauses)
|
|
180
|
-
foreach = Clauses::Foreach.new(variable: @variable, list: @list, clauses: clauses.flatten.freeze)
|
|
181
|
-
@builder.send(:append, foreach, nil)
|
|
182
|
-
end
|
|
227
|
+
def apply(*clauses) = @builder.send(:append, Clauses.for_each(@variable, @list, clauses.flatten), nil)
|
|
183
228
|
end
|
|
184
229
|
|
|
185
230
|
# Intermediate state of `load_csv(uri)` waiting for `as(alias)`.
|
|
186
231
|
class OngoingLoadCsv
|
|
187
|
-
def initialize(builder, uri, with_headers)
|
|
232
|
+
def initialize(builder, uri, with_headers, periodic_commit)
|
|
188
233
|
@builder = builder
|
|
189
234
|
@uri = uri
|
|
190
235
|
@with_headers = with_headers
|
|
236
|
+
@periodic_commit = periodic_commit
|
|
191
237
|
freeze
|
|
192
238
|
end
|
|
193
239
|
|
|
194
240
|
def as(alias_name)
|
|
195
|
-
clause = Clauses::LoadCsv.new(uri: @uri, with_headers: @with_headers, alias_name: SymbolicName.of(alias_name)
|
|
241
|
+
clause = Clauses::LoadCsv.new(uri: @uri, with_headers: @with_headers, alias_name: SymbolicName.of(alias_name),
|
|
242
|
+
field_terminator: nil, periodic_commit: @periodic_commit)
|
|
196
243
|
@builder.send(:append, clause, nil)
|
|
197
244
|
end
|
|
198
245
|
end
|
|
@@ -220,6 +267,14 @@ module CypherDSL
|
|
|
220
267
|
Clauses::CallSubquery.new(body: statement, imports: coerce_items(imports), in_transactions:, rows:)
|
|
221
268
|
end
|
|
222
269
|
|
|
270
|
+
def add_hint(hint)
|
|
271
|
+
update_last(:condition) do |clause|
|
|
272
|
+
raise ArgumentError, 'Hints need a preceding MATCH.' unless clause.is_a?(Clauses::Match)
|
|
273
|
+
|
|
274
|
+
clause.with(hints: clause.hints + [hint])
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
223
278
|
def add_merge_action(type, operations)
|
|
224
279
|
update_last(nil) do |clause|
|
|
225
280
|
raise ArgumentError, 'ON CREATE / ON MATCH need a preceding MERGE.' unless clause.is_a?(Clauses::Merge)
|
|
@@ -240,11 +295,12 @@ module CypherDSL
|
|
|
240
295
|
arguments.each_slice(2).map { |target, value| Operation.new(target, '=', Expression.coerce(value)) }.freeze
|
|
241
296
|
end
|
|
242
297
|
|
|
243
|
-
|
|
298
|
+
# A raw fragment such as `Cypher.raw("n.name = 'x'")` counts as a complete operation.
|
|
299
|
+
def operation?(argument) = [Operation, LabelOperation, RawExpression].any? { |type| argument.is_a?(type) }
|
|
244
300
|
|
|
245
301
|
def label_operation?(arguments)
|
|
246
302
|
arguments.size > 1 && arguments.first.is_a?(Node) &&
|
|
247
|
-
arguments.drop(1).all? { |argument|
|
|
303
|
+
arguments.drop(1).all? { |argument| [String, Symbol, Labels].any? { |type| argument.is_a?(type) } }
|
|
248
304
|
end
|
|
249
305
|
|
|
250
306
|
def optional_expression(value) = value.nil? ? nil : Expression.coerce(value)
|
|
@@ -256,7 +312,17 @@ module CypherDSL
|
|
|
256
312
|
end
|
|
257
313
|
|
|
258
314
|
def return_clause(distinct, expressions)
|
|
259
|
-
Clauses::Return.new(distinct:, items:
|
|
315
|
+
Clauses::Return.new(distinct:, items: projection_items(expressions), order: nil, skip: nil, limit: nil)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# RETURN/WITH items with the upstream preconditions.
|
|
319
|
+
def projection_items(expressions)
|
|
320
|
+
raise ArgumentError, 'Expressions to return are required.' if expressions.any?(&:nil?)
|
|
321
|
+
|
|
322
|
+
items = coerce_items(expressions)
|
|
323
|
+
raise ArgumentError, 'At least one expressions to return is required.' if items.empty?
|
|
324
|
+
|
|
325
|
+
items
|
|
260
326
|
end
|
|
261
327
|
|
|
262
328
|
def add_sort_item(expression)
|
|
@@ -1,24 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CypherDSL
|
|
4
|
-
# What a statement uses: its parameter names,
|
|
5
|
-
#
|
|
4
|
+
# What a statement uses: its parameter names, the values of the parameters
|
|
5
|
+
# that carry one, and the expressions it hands back. Built from a rendering pass.
|
|
6
6
|
class StatementCatalog
|
|
7
|
-
attr_reader :parameters, :parameter_names
|
|
7
|
+
attr_reader :parameters, :parameter_names, :renamed_parameters, :identifiable_expressions
|
|
8
8
|
|
|
9
9
|
# `uses` maps each parameter name to every value it was given, in render
|
|
10
10
|
# order, with {Parameter::NO_VALUE} for unbound uses.
|
|
11
|
-
def self.from_uses(uses)
|
|
11
|
+
def self.from_uses(uses, renamed_parameters = {}, statement: nil)
|
|
12
12
|
conflicts = uses.filter_map { |name, values| [name, values.uniq.to_set] if values.uniq.size > 1 }.to_h
|
|
13
13
|
raise ConflictingParametersException, conflicts unless conflicts.empty?
|
|
14
14
|
|
|
15
15
|
bound = uses.filter_map { |name, values| [name, values.first] unless values.first.equal?(Parameter::NO_VALUE) }
|
|
16
|
-
new(parameters: bound.to_h, parameter_names: uses.keys
|
|
16
|
+
new(parameters: bound.to_h, parameter_names: uses.keys, renamed_parameters:,
|
|
17
|
+
identifiable_expressions: identifiable_expressions_of(statement))
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
# The expressions a statement hands back to its caller: the identifiable items of
|
|
21
|
+
# its final RETURN or WITH (names for aliased items and named entities, properties
|
|
22
|
+
# as they are), or the names a bare procedure call yields. Empty otherwise.
|
|
23
|
+
def self.identifiable_expressions_of(statement)
|
|
24
|
+
case statement
|
|
25
|
+
when UnionQuery then statement.statements.flat_map { |part| identifiable_expressions_of(part) }.uniq
|
|
26
|
+
when Statement then result_items_of(statement.clauses.last).filter_map { |item| identifiable_expression(item) }
|
|
27
|
+
else []
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.result_items_of(clause)
|
|
32
|
+
case clause
|
|
33
|
+
when Clauses::Return, Clauses::With then clause.items
|
|
34
|
+
when Clauses::Call then clause.yield_items
|
|
35
|
+
else []
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
private_class_method :result_items_of
|
|
39
|
+
|
|
40
|
+
# Only names, aliases, named entities and properties identify something; a literal does not.
|
|
41
|
+
def self.identifiable_expression(item)
|
|
42
|
+
case item
|
|
43
|
+
when AliasedExpression then item.alias_name
|
|
44
|
+
when Node, Relationship then item.symbolic_name
|
|
45
|
+
when SymbolicName, Property then item
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_class_method :identifiable_expression
|
|
49
|
+
|
|
50
|
+
# `renamed_parameters` maps original names to generated ones when the renderer rewrites them.
|
|
51
|
+
def initialize(parameters:, parameter_names:, renamed_parameters: {}, identifiable_expressions: [])
|
|
20
52
|
@parameters = parameters.freeze
|
|
21
53
|
@parameter_names = parameter_names.freeze
|
|
54
|
+
@renamed_parameters = renamed_parameters.freeze
|
|
55
|
+
@identifiable_expressions = identifiable_expressions.freeze
|
|
22
56
|
freeze
|
|
23
57
|
end
|
|
24
58
|
end
|
|
@@ -10,6 +10,7 @@ module CypherDSL
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def collect(statement) = CollectExpression.new(statement, @imports)
|
|
13
|
+
def count(statement_or_pattern) = CountExpression.new(statement_or_pattern, @imports)
|
|
13
14
|
def exists(statement_or_pattern) = ExistsCondition.new(statement_or_pattern, @imports)
|
|
14
15
|
end
|
|
15
16
|
end
|
|
@@ -15,7 +15,7 @@ module CypherDSL
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# A name to be generated at render time; `hint` seeds the generated name (letters only).
|
|
18
|
-
def self.unresolved(hint) = new(nil, hint: hint.to_s.gsub(/[^a-zA-Z]/, ''))
|
|
18
|
+
def self.unresolved(hint = '') = new(nil, hint: hint.to_s.gsub(/[^a-zA-Z]/, ''))
|
|
19
19
|
|
|
20
20
|
def initialize(value, hint: nil)
|
|
21
21
|
raise ArgumentError, 'A symbolic name must not be empty.' if value && value.empty?
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# `Cypher.using_periodic_commit(500).load_csv(uri).as(:line)`: the legacy
|
|
5
|
+
# `USING PERIODIC COMMIT [rate]` prefix of a LOAD CSV statement.
|
|
6
|
+
UsingPeriodicCommit = Data.define(:rate) do
|
|
7
|
+
def load_csv(uri, positional_headers = false, with_headers: positional_headers) # rubocop:disable Style/OptionalBooleanParameter
|
|
8
|
+
StatementBuilder.new.load_csv(uri, with_headers:, periodic_commit: rate || true)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|