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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/README.md +66 -4
  4. data/lib/cypher_dsl/clauses/load_csv.rb +8 -2
  5. data/lib/cypher_dsl/clauses/match.rb +2 -2
  6. data/lib/cypher_dsl/clauses/raw.rb +9 -0
  7. data/lib/cypher_dsl/clauses.rb +11 -0
  8. data/lib/cypher_dsl/comparison.rb +6 -0
  9. data/lib/cypher_dsl/condition.rb +1 -1
  10. data/lib/cypher_dsl/count_expression.rb +7 -3
  11. data/lib/cypher_dsl/cypher.rb +80 -19
  12. data/lib/cypher_dsl/expression.rb +3 -0
  13. data/lib/cypher_dsl/function_invocation.rb +7 -0
  14. data/lib/cypher_dsl/functions.rb +11 -1
  15. data/lib/cypher_dsl/has_label_condition.rb +1 -1
  16. data/lib/cypher_dsl/hint.rb +71 -0
  17. data/lib/cypher_dsl/label_operation.rb +11 -3
  18. data/lib/cypher_dsl/labels.rb +66 -0
  19. data/lib/cypher_dsl/literal.rb +24 -2
  20. data/lib/cypher_dsl/named_path.rb +6 -3
  21. data/lib/cypher_dsl/node.rb +15 -4
  22. data/lib/cypher_dsl/property.rb +9 -2
  23. data/lib/cypher_dsl/property_exists_condition.rb +15 -0
  24. data/lib/cypher_dsl/raw_expression.rb +76 -3
  25. data/lib/cypher_dsl/relationship.rb +8 -1
  26. data/lib/cypher_dsl/relationship_chain.rb +3 -0
  27. data/lib/cypher_dsl/renderer/configuration.rb +61 -4
  28. data/lib/cypher_dsl/renderer/dialect.rb +24 -7
  29. data/lib/cypher_dsl/renderer/escaper.rb +10 -7
  30. data/lib/cypher_dsl/renderer/generated_name_resolver.rb +110 -0
  31. data/lib/cypher_dsl/renderer/name_resolver.rb +35 -0
  32. data/lib/cypher_dsl/renderer/visitor/conditions.rb +74 -0
  33. data/lib/cypher_dsl/renderer/visitor/expressions.rb +40 -40
  34. data/lib/cypher_dsl/renderer/visitor/label_rendering.rb +84 -0
  35. data/lib/cypher_dsl/renderer/visitor/patterns.rb +18 -29
  36. data/lib/cypher_dsl/renderer/visitor.rb +106 -29
  37. data/lib/cypher_dsl/renderer.rb +11 -5
  38. data/lib/cypher_dsl/search.rb +91 -0
  39. data/lib/cypher_dsl/standalone_call.rb +0 -6
  40. data/lib/cypher_dsl/statement.rb +4 -0
  41. data/lib/cypher_dsl/statement_builder.rb +81 -15
  42. data/lib/cypher_dsl/statement_catalog.rb +40 -6
  43. data/lib/cypher_dsl/subquery_with.rb +1 -0
  44. data/lib/cypher_dsl/symbolic_name.rb +1 -1
  45. data/lib/cypher_dsl/using_periodic_commit.rb +11 -0
  46. data/lib/cypher_dsl/vector_literal.rb +41 -0
  47. data/lib/cypher_dsl/version.rb +1 -1
  48. metadata +13 -3
  49. data/lib/cypher_dsl/label_expression.rb +0 -40
@@ -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
- @generated_names = {}.compare_by_identity
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; see {Renderer#catalog}.
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
- @first_clause = index.zero? && @visited_names.empty?
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
- # 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
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
- headers = clause.with_headers ? ' WITH HEADERS' : ''
112
- "LOAD CSV#{headers} FROM #{render_literal(clause.uri)} AS #{render_expression(clause.alias_name)}"
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
- def render_in_scope(body, scope)
181
- return body.content if body.is_a?(RawExpression)
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
- outer_scope = @visited_names.dup
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 resolve_name(item.alias_name)
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 = resolve_name(item.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
- rendered = "#{render_expression(item.delegate)} AS #{render_expression(item.alias_name)}"
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
- rendered
344
+ "#{delegate} AS #{Escaper.escape_if_necessary(alias_name)}"
268
345
  end
269
346
 
270
347
  def render_aliased_reference(item)
271
- alias_name = resolve_name(item.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)
@@ -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::DEFAULT)
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}; raises
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
 
@@ -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
- append(Clauses::Match.new(optional:, pattern_elements: pattern_elements.flatten.freeze, where: nil), :condition)
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: coerce_items(expressions), order: nil, skip: nil, limit: nil,
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: coerce_items(expressions), order: nil, skip: nil, limit: nil,
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, with_headers: false) = OngoingLoadCsv.new(self, uri.to_s, with_headers)
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
- def operation?(argument) = argument.is_a?(Operation) || argument.is_a?(LabelOperation)
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| argument.is_a?(String) || argument.is_a?(Symbol) }
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: coerce_items(expressions), order: nil, skip: nil, limit: nil)
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, and the values of the
5
- # parameters that carry one. Built from a rendering pass.
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
- def initialize(parameters:, parameter_names:)
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