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

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +53 -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.rb +11 -0
  7. data/lib/cypher_dsl/comparison.rb +6 -0
  8. data/lib/cypher_dsl/count_expression.rb +7 -3
  9. data/lib/cypher_dsl/cypher.rb +80 -19
  10. data/lib/cypher_dsl/expression.rb +3 -0
  11. data/lib/cypher_dsl/function_invocation.rb +7 -0
  12. data/lib/cypher_dsl/functions.rb +11 -1
  13. data/lib/cypher_dsl/has_label_condition.rb +1 -1
  14. data/lib/cypher_dsl/hint.rb +71 -0
  15. data/lib/cypher_dsl/label_operation.rb +11 -3
  16. data/lib/cypher_dsl/labels.rb +66 -0
  17. data/lib/cypher_dsl/literal.rb +24 -2
  18. data/lib/cypher_dsl/named_path.rb +6 -3
  19. data/lib/cypher_dsl/node.rb +15 -4
  20. data/lib/cypher_dsl/property.rb +9 -2
  21. data/lib/cypher_dsl/property_exists_condition.rb +15 -0
  22. data/lib/cypher_dsl/raw_expression.rb +76 -3
  23. data/lib/cypher_dsl/relationship.rb +8 -1
  24. data/lib/cypher_dsl/relationship_chain.rb +3 -0
  25. data/lib/cypher_dsl/renderer/configuration.rb +61 -4
  26. data/lib/cypher_dsl/renderer/dialect.rb +24 -7
  27. data/lib/cypher_dsl/renderer/escaper.rb +10 -7
  28. data/lib/cypher_dsl/renderer/generated_name_resolver.rb +110 -0
  29. data/lib/cypher_dsl/renderer/name_resolver.rb +35 -0
  30. data/lib/cypher_dsl/renderer/visitor/expressions.rb +52 -15
  31. data/lib/cypher_dsl/renderer/visitor/label_rendering.rb +84 -0
  32. data/lib/cypher_dsl/renderer/visitor/patterns.rb +17 -29
  33. data/lib/cypher_dsl/renderer/visitor.rb +99 -27
  34. data/lib/cypher_dsl/renderer.rb +11 -5
  35. data/lib/cypher_dsl/search.rb +91 -0
  36. data/lib/cypher_dsl/standalone_call.rb +0 -6
  37. data/lib/cypher_dsl/statement.rb +4 -0
  38. data/lib/cypher_dsl/statement_builder.rb +67 -14
  39. data/lib/cypher_dsl/statement_catalog.rb +40 -6
  40. data/lib/cypher_dsl/subquery_with.rb +1 -0
  41. data/lib/cypher_dsl/symbolic_name.rb +1 -1
  42. data/lib/cypher_dsl/using_periodic_commit.rb +11 -0
  43. data/lib/cypher_dsl/vector_literal.rb +41 -0
  44. data/lib/cypher_dsl/version.rb +1 -1
  45. metadata +11 -3
  46. data/lib/cypher_dsl/label_expression.rb +0 -40
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aae0b1907cab6d377de663093abb2aca70b7925b10dafc1596a50ccfc6f8a97f
4
- data.tar.gz: 21ad9bce707aabfb247196a75bdfb7de97ffa215e53b210aaa2179dcf0a7b313
3
+ metadata.gz: 5430e442ecb75b449cefb44e0b4d1e06ccdc80ca8a4dc10a7f3da2366a8cb049
4
+ data.tar.gz: 393b61210b500da83e53ea17706bc8e8e691ed3f813f69c1557c85a6be86e182
5
5
  SHA512:
6
- metadata.gz: 69811594f30155665847bc68fc4c66471de5e0f8ae1b1a83e39d08fd6d8426720e4206aa260f20358c395a87ba2ff2a530eafe518f7621235267d97831a05754
7
- data.tar.gz: bc7823802abd14746151e12ae00050be8070be48b0fbed3ef593ef6654bdd0e6613c5c8ebdd78089d3ffbb7303830f06560cb637bb402d893ec279c57f111e43
6
+ metadata.gz: 15d7b9bedab4f4d8072928d4d8dd207b3ab3fa4e7b4dc8c98422af314806cc5b00441a478b672640f626e4e2b0320202323990c710f0f6a1eac3c9abb52c8c8f
7
+ data.tar.gz: d0634d9fc01f6375253637d9bdd4e94a1357dce52ab0be19188c8aed5fcecadd56932e0d88a60ce65a6b7e5f721be3161ce0e7c91b9f4ce7d3b529bfa6a94ea7
data/CHANGELOG.md CHANGED
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.0.alpha.2] - 2026-11-01
11
+
12
+ Second pre-release. The whole upstream core test suite that applies to Ruby is now
13
+ translated (1067 examples): all remaining integration tests (HintsIT, LoadCSVIT, DialectIT,
14
+ SubqueriesGQLIT, UseIT, ExpressionsIT, IdentifiableExpressionsIT, Cypher5IT, labels, search
15
+ and vector tests) and the unit tests for literals, escaping, preconditions and `toString`.
16
+ Not translated: `DurationLiteralTests` (no duration type in Ruby's standard library), the
17
+ Java-only reflection, package-structure and foreign-adapter tests, and the QueryDSL adapter.
18
+
19
+ ### Added
20
+
21
+ - Planner hints: `using_index`, `using_index_seek`, `using_scan`, `using_join_on` (HintsIT).
22
+ - LOAD CSV: `with_field_terminator`, `Cypher.using_periodic_commit`, `linenumber()`, `file()`.
23
+ - All Java dialects including `NEO4J_5_26`, `NEO4J_5_CYPHER_5`, `NEO4J_5_CYPHER_25` with their
24
+ `CYPHER 5` / `CYPHER 25` prefixes; `NEO4J_5_DEFAULT_CYPHER` is the default.
25
+ - Label expressions (`Labels.exactly/all/any`, `and`, `or`, `negate`, `conjunction_with`),
26
+ dynamic labels `n:$($labels)` on Neo4j 5.26+, `disable_dynamic_labels`.
27
+ - Vector `SEARCH` clauses (`Cypher.search`) and `Cypher.vector` literals.
28
+ - Generated names (`with_generated_names`) and `StatementCatalog#renamed_parameters`.
29
+ - `Cypher.exists(property)` renders `exists()` on Neo4j 4 and `IS NOT NULL` afterwards;
30
+ `shortestPath()` / `allShortestPaths()` on Neo4j 4 and 5.
31
+ - `StatementCatalog#identifiable_expressions`: the items of the final `RETURN` or `YIELD`
32
+ (SubqueriesGQLIT); also on the builder after a `WITH`/`RETURN` (IdentifiableExpressionsIT).
33
+ - `Cypher.raw(format, *args)` with `$E` placeholders and `\$E` escapes (RawLiteralTests).
34
+ - `COUNT { pattern WHERE … }` and `Cypher.subquery_with(…).count(…)` (ExpressionsIT).
35
+ - `Cypher.use` validates nested `USE` and `EXPLAIN`/`PROFILE` inner statements and wraps
36
+ names, literals and parameters in `graph.byName(…)`; `Statement#explain` / `#profile` (UseIT).
37
+ - `Date`, `Time` and `DateTime` literals render as `date(…)` / `datetime(…)`.
38
+ - `Cypher.format`, `Cypher.quote`, `Cypher.has_labels_or_type`, `Cypher.sort(expr, direction)`,
39
+ `Property#name` / `#referenced_as`, `Configuration#==`, `to_s` on expressions and patterns
40
+ (the plain Cypher, as the Java `toString`).
41
+ - Upstream preconditions: empty labels, unnamed nodes in `SET`/`+=`, missing patterns or
42
+ return items, non-updating clauses inside `FOREACH`, `DISTINCT` on non-aggregates,
43
+ unsupported literal types.
44
+
45
+ ### Fixed
46
+
47
+ - Node and relationship names that need quoting (e.g. containing a backtick) are now
48
+ escaped inside patterns, not only where they are used as expressions.
49
+ - A `CALL { }` following a leading `USE` renders as `CALL () { }`.
50
+ - A `nil` additional label in `Cypher.node` raises `An empty label is not allowed.` instead of being
51
+ dropped silently; symbols and strings are validated alike.
52
+
53
+ ### Changed
54
+
55
+ - The default dialect is `NEO4J_5_DEFAULT_CYPHER` (was `NEO4J_5_23`); the only difference
56
+ is that dynamic label expressions render as such.
57
+ - Name escaping follows upstream exactly: ``` stands for a backtick, doubled
58
+ backticks are collapsed before escaping, and an already quoted name is quoted again.
59
+ String literals escape double quotes as well as single quotes and backslashes.
60
+
10
61
  ## [0.1.0.alpha.1] - 2026-10-31
11
62
 
12
63
  First pre-release, reserving the gem name. A Ruby reimplementation of the design of
@@ -28,5 +79,6 @@ collection, IssueRelatedIT, FunctionsIT).
28
79
  - Renderer with dialects (Neo4j 4, 5, 5.23+), pretty printing, identifier escaping,
29
80
  parameter collection (`Statement#catalog`).
30
81
 
31
- [Unreleased]: https://github.com/neo4jrb/cypher-dsl/compare/v0.1.0.alpha.1...HEAD
82
+ [Unreleased]: https://github.com/neo4jrb/cypher-dsl/compare/v0.1.0.alpha.2...HEAD
83
+ [0.1.0.alpha.2]: https://github.com/neo4jrb/cypher-dsl/compare/v0.1.0.alpha.1...v0.1.0.alpha.2
32
84
  [0.1.0.alpha.1]: https://github.com/neo4jrb/cypher-dsl/releases/tag/v0.1.0.alpha.1
data/README.md CHANGED
@@ -57,26 +57,75 @@ statement.parameter_names # => ["name"]
57
57
  `Cypher.anon_parameter(value)` lets the renderer pick the name (`$pcdsl01`, …).
58
58
  Binding one name to two different values raises `ConflictingParametersException`.
59
59
 
60
+ `statement.catalog` also lists the expressions a statement hands back, i.e. the
61
+ names and expressions of its final `RETURN` or `YIELD`:
62
+
63
+ ```ruby
64
+ statement.catalog.identifiable_expressions # => [Cypher.name(:u)]
65
+ ```
66
+
67
+ ## Raw Cypher
68
+
69
+ `Cypher.raw` embeds Cypher text verbatim. `$E` placeholders take the arguments in
70
+ order, expressions as they are and anything else as a literal, while a `$name` that
71
+ matches a named parameter among the arguments binds that parameter; `\$E` is a literal `$E`:
72
+
73
+ ```ruby
74
+ Cypher.returning(Cypher.raw('($E + $E) * $factor', Cypher.parameter(:a), 2, Cypher.parameter(:factor, 3)).as(:r))
75
+ .build.cypher # => "RETURN ($a + 2) * $factor AS r"
76
+ ```
77
+
78
+ Every expression and pattern answers `to_s` with its plain Cypher, e.g.
79
+ `Cypher.node('Person').named(:n).to_s # => "(n:Person)"`.
80
+
60
81
  ## Rendering options
61
82
 
62
83
  ```ruby
63
84
  config = Renderer::Configuration.new_config
64
- .with_dialect(Renderer::Dialect::NEO4J_4) # default: NEO4J_5_23
85
+ .with_dialect(Renderer::Dialect::NEO4J_4) # default: NEO4J_5_DEFAULT_CYPHER
65
86
  .always_escape_names(false) # default: true, false when pretty printing
66
87
  .with_pretty_print(true) # one clause per line, indented blocks
67
88
  .with_indent_style(:tab) # or .with_indent_size(4)
89
+ .disable_dynamic_labels # render n:$($labels) statically
68
90
  .build
69
91
  Renderer.get_renderer(config).render(statement)
70
92
  Renderer.get_renderer(Renderer::Configuration.pretty_printing).render(statement)
71
93
  ```
72
94
 
73
- The dialect decides how `CALL { }` subqueries import variables (`CALL (u) { … }`
74
- from Neo4j 5.23 on, `CALL { WITH u … }` before) and whether `elementId()` exists.
95
+ The dialects mirror the Java enum: `NEO4J_4`, `NEO4J_5`, `NEO4J_5_23`, `NEO4J_5_26`,
96
+ `NEO4J_5_DEFAULT_CYPHER`, `NEO4J_5_CYPHER_5`, `NEO4J_5_CYPHER_25` and `NEO4J_2025`.
97
+ The dialect decides how `CALL { }` subqueries import variables (`CALL (u) { … }` from
98
+ Neo4j 5.23 on, `CALL { WITH u … }` before), whether paths use `shortestPath()` or
99
+ `SHORTEST 1`, `exists(n.prop)` or `n.prop IS NOT NULL`, `distance()` or
100
+ `point.distance()`, whether `elementId()` exists, whether label expressions may be
101
+ dynamic (`n:$($labels)`, Neo4j 5.26+) and whether the statement carries a `CYPHER 5`
102
+ or `CYPHER 25` prefix.
103
+
104
+ ### Generated names
105
+
106
+ `with_generated_names(true)` rewrites every variable, alias and parameter name while
107
+ rendering (`v0`, `v1`, … and `p0`, `p1`, …), scope by scope, so that statements that
108
+ differ only in their names render identically, which is useful as a query-cache key.
109
+ A subset of the Java `GeneratedNames` flags narrows what gets rewritten:
110
+
111
+ ```ruby
112
+ config = Renderer::Configuration.new_config
113
+ .with_generated_names(%i[entity_names parameter_names]) # or true for all flags
114
+ .build
115
+ renderer = Renderer.get_renderer(config)
116
+ renderer.render(statement) # => "MATCH (v0:`Movie`) WHERE v0.title = $p0 RETURN v0"
117
+ renderer.catalog(statement).renamed_parameters # => {"title" => "p0"}
118
+ ```
119
+
120
+ The flags are `entity_names`, `parameter_names`, `all_aliases`, `internal_aliases_only`
121
+ (all aliases except those of the final `RETURN`) and `reuse_aliases`.
75
122
 
76
123
  ## Installation
77
124
 
125
+ Pre-releases are not picked up by a bare requirement, so name the version:
126
+
78
127
  ```ruby
79
- gem 'cypher-dsl'
128
+ gem 'cypher-dsl', '0.1.0.alpha.2'
80
129
  ```
81
130
 
82
131
  Requires Ruby 3.4 or newer.
@@ -2,7 +2,13 @@
2
2
 
3
3
  module CypherDSL
4
4
  module Clauses
5
- # `LOAD CSV [WITH HEADERS] FROM 'uri' AS alias`.
6
- LoadCsv = Data.define(:uri, :with_headers, :alias_name)
5
+ # `[USING PERIODIC COMMIT [rate]] LOAD CSV [WITH HEADERS] FROM 'uri' AS alias [FIELDTERMINATOR ';']`.
6
+ # `periodic_commit` is nil, `true` or the commit rate.
7
+ LoadCsv = Data.define(:uri, :with_headers, :alias_name, :field_terminator, :periodic_commit) do
8
+ # A blank terminator leaves the clause as it is.
9
+ def with_field_terminator(terminator)
10
+ terminator.nil? || terminator.strip.empty? ? self : with(field_terminator: terminator)
11
+ end
12
+ end
7
13
  end
8
14
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module CypherDSL
4
4
  module Clauses
5
- # `[OPTIONAL] MATCH pattern [WHERE condition]`.
6
- Match = Data.define(:optional, :pattern_elements, :where)
5
+ # `[OPTIONAL] MATCH pattern [USING hint …] [SEARCH …] [WHERE condition]`.
6
+ Match = Data.define(:optional, :pattern_elements, :hints, :search, :where)
7
7
  end
8
8
  end
@@ -5,9 +5,20 @@ module CypherDSL
5
5
  # code goes through the {StatementBuilder}; these exist for building clause
6
6
  # lists by hand, e.g. the body of a FOREACH.
7
7
  module Clauses
8
+ UPDATING_MESSAGE = 'Only updating clauses SET, REMOVE, CREATE, MERGE, DELETE, and FOREACH are allowed as ' \
9
+ 'clauses applied inside FOREACH.'
10
+
11
+ # True for any clause object.
12
+ def self.clause?(object) = constants.any? { |name| (type = const_get(name)).is_a?(Class) && object.is_a?(type) }
13
+
14
+ # True for the clauses allowed inside FOREACH.
15
+ def self.updating?(clause) = [Set, Remove, Create, Merge, Delete, Foreach].any? { |type| clause.is_a?(type) }
16
+
8
17
  module_function
9
18
 
10
19
  def for_each(variable, list, clauses)
20
+ raise ArgumentError, UPDATING_MESSAGE unless clauses.all? { |clause| Clauses.updating?(clause) }
21
+
11
22
  Foreach.new(variable: SymbolicName.of(variable), list: Expression.coerce(list), clauses: clauses.freeze)
12
23
  end
13
24
 
@@ -3,10 +3,16 @@
3
3
  module CypherDSL
4
4
  # A binary comparison (`a = b`, `a =~ b`, …) or a unary one (`a IS NULL`).
5
5
  class Comparison < Condition
6
+ UNARY_OPERATORS = ['IS NULL', 'IS NOT NULL'].freeze
7
+
6
8
  attr_reader :left, :operator, :right
7
9
 
8
10
  def initialize(left, operator, right)
9
11
  super()
12
+ raise ArgumentError, 'Left expression must not be null.' if left.nil?
13
+ raise ArgumentError, 'Operator must not be empty.' if operator.nil? || operator.to_s.strip.empty?
14
+ raise ArgumentError, 'Right expression must not be null.' if right.nil? && !UNARY_OPERATORS.include?(operator)
15
+
10
16
  @left = left
11
17
  @operator = operator
12
18
  @right = right
@@ -1,16 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CypherDSL
4
- # `COUNT { pattern }` / `COUNT { statement }`.
4
+ # `COUNT { pattern [WHERE …] }` / `COUNT { [WITH imports] statement }`.
5
5
  class CountExpression
6
6
  include Expression
7
7
 
8
- attr_reader :body, :imports
8
+ attr_reader :body, :imports, :where_condition
9
9
 
10
- def initialize(body, imports = [])
10
+ def initialize(body, imports = [], where_condition: nil)
11
11
  @body = body
12
12
  @imports = imports.freeze
13
+ @where_condition = where_condition
13
14
  freeze
14
15
  end
16
+
17
+ # `COUNT { pattern WHERE condition }`.
18
+ def where(condition) = CountExpression.new(body, imports, where_condition: Condition.coerce(condition))
15
19
  end
16
20
  end
@@ -10,16 +10,36 @@ module CypherDSL
10
10
 
11
11
  # --- pattern elements ------------------------------------------------
12
12
 
13
- # A node with one primary label and optional additional labels; a trailing
14
- # map (`Cypher.node('Test', Cypher.map_of(…))`) sets its properties.
13
+ # A node with one primary label and optional additional labels (or lists of
14
+ # them); a map among the arguments (`Cypher.node('Test', Cypher.map_of(…))`)
15
+ # sets its properties.
15
16
  def node(primary_label, *additional_labels)
16
- properties = additional_labels.pop if [MapExpression, Hash].any? { |type| additional_labels.last.is_a?(type) }
17
- labels = [primary_label, *additional_labels].map { |label| label.is_a?(LabelExpression) ? label : label.to_s }
17
+ additional_labels = additional_labels.flatten
18
+ properties = additional_labels.find { |label| [MapExpression, Hash].any? { |type| label.is_a?(type) } }
19
+ labels = [primary_label, *additional_labels.reject { |label| properties && label.equal?(properties) }]
20
+ labels = labels.map { |label| label.is_a?(Labels) ? label : label.to_s }
21
+ raise ArgumentError, 'A primary label is required.' if blank_label?(labels.first)
22
+ raise ArgumentError, 'An empty label is not allowed.' if labels.drop(1).any? { |label| blank_label?(label) }
23
+
18
24
  node = Node.new(labels:)
19
25
  properties ? node.with_properties(properties) : node
20
26
  end
21
27
 
28
+ def blank_label?(label) = label.is_a?(String) && label.strip.empty?
29
+ private_class_method :blank_label?
30
+
22
31
  # A node without labels, optionally named.
32
+ # `Cypher.search(:movie).in('index').for_vector(v).limit(4).build`, see {Search}.
33
+ def search(name) = Search::Builder.new(name && SymbolicName.of(name))
34
+
35
+ # `vector([1, 2, 3], 3, INTEGER NOT NULL)`, see {VectorLiteral}.
36
+ def vector(elements, type = nil) = VectorLiteral.of(elements, type)
37
+
38
+ # Label expressions: `Cypher.node(Cypher.exactly_label('A').or('B'))`, `Cypher.node(Cypher.all_labels($labels))`.
39
+ def exactly_label(label) = Labels.exactly(label)
40
+ def all_labels(expression) = Labels.all(expression)
41
+ def any_label(expression) = Labels.any(expression)
42
+
23
43
  def any_node(name = nil)
24
44
  node = Node.new(labels: [])
25
45
  name ? node.named(name) : node
@@ -76,15 +96,16 @@ module CypherDSL
76
96
 
77
97
  # Path selectors, `Cypher.any_shortest.named(:p).defined_by(pattern)` → `p = ANY pattern`.
78
98
  def any_shortest = NamedPath::Selector.new('ANY')
79
- def all_shortest = NamedPath::Selector.new('ALL SHORTEST')
99
+ def all_shortest = NamedPath::Selector.new('ALL SHORTEST', legacy_function: 'allShortestPaths')
80
100
  def shortest_k(count) = NamedPath::Selector.shortest_k(count)
81
101
  def shortest_k_groups(count) = NamedPath::Selector.shortest_k(count, groups: true)
82
102
 
83
103
  # `EXISTS { pattern }` / `EXISTS { pattern, pattern }` / `EXISTS { [WITH imports] statement }`.
84
- # A property gives the condition `n.prop IS NOT NULL`, any other plain expression the
85
- # legacy `exists(expr)` function.
104
+ # A property gives the condition `n.prop IS NOT NULL` (`exists(n.prop)` on Neo4j 4), any other
105
+ # plain expression the `exists(expr)` function.
86
106
  def exists(body, *imports)
87
- return body.is_not_null if body.is_a?(Property)
107
+ raise ArgumentError, 'The expression or pattern for exists() is required.' if body.nil?
108
+ return PropertyExistsCondition.new(body) if body.is_a?(Property)
88
109
  return function('exists', body) if body.is_a?(Expression) && !body.is_a?(Statement) && !pattern?(body)
89
110
 
90
111
  ExistsCondition.new(body, imports.flatten.map { |import| Expression.coerce(import) })
@@ -95,6 +116,9 @@ module CypherDSL
95
116
 
96
117
  def pattern?(value) = PATTERN_TYPES.any? { |type| value.is_a?(type) }
97
118
 
119
+ # The condition `name:Label`, for a name that may stand for a node or a relationship.
120
+ def has_labels_or_type(name, *labels) = HasLabelCondition.new(SymbolicName.of(name), labels.flatten)
121
+
98
122
  # List predicates: `Cypher.all(:x).in(list).where(condition)`.
99
123
  def all(variable) = ListPredicate::Ongoing.new('all', variable)
100
124
  def any(variable) = ListPredicate::Ongoing.new('any', variable)
@@ -137,7 +161,26 @@ module CypherDSL
137
161
  # A map projection on a name: `Cypher.create_projection(:n, 'title')` → `n{.title}`.
138
162
  def create_projection(name, *entries) = MapProjection.new(SymbolicName.of(name), entries.flatten)
139
163
 
140
- def sort(expression) = SortItem.new(Expression.coerce(expression), nil)
164
+ # A sort item, optionally with its direction (`:asc` / `:desc`).
165
+ def sort(expression, direction = nil) = SortItem.new(Expression.coerce(expression), direction)
166
+
167
+ # A string as a Cypher string literal, `'…'` with quotes and backslashes escaped.
168
+ def quote(string) = Literal.quote(string)
169
+
170
+ # A short textual form of an expression: the name of a named element, alias or
171
+ # symbolic name, `container.name` for a property, the rendered Cypher otherwise.
172
+ def format(expression)
173
+ case expression
174
+ when Node, Relationship then expression.required_symbolic_name.value
175
+ when AliasedExpression then expression.alias_name.value
176
+ when SymbolicName then expression.value
177
+ when Property then ([expression.container] + expression.names).map { |part| format_name(part) }.join('.')
178
+ else Renderer.plain.render_fragment(expression)
179
+ end
180
+ end
181
+
182
+ def format_name(name) = name.is_a?(Expression) ? format(name) : name
183
+ private_class_method :format_name
141
184
 
142
185
  # `size(list)` or `size(pattern)`.
143
186
  def size(expression_or_pattern)
@@ -145,12 +188,14 @@ module CypherDSL
145
188
  function('size', argument)
146
189
  end
147
190
 
148
- # `count(expression)`, or `COUNT { pattern }` / `COUNT { statement }` for patterns and statements.
149
- def count(expression_or_pattern)
150
- subquery = pattern?(expression_or_pattern) || expression_or_pattern.is_a?(Statement)
151
- return CountExpression.new(expression_or_pattern) if subquery
191
+ # `count(expression)`, or `COUNT { [WITH imports] pattern }` / `COUNT { … statement }`
192
+ # for patterns and statements.
193
+ def count(expression_or_pattern, *imports)
194
+ subquery = pattern?(expression_or_pattern) || expression_or_pattern.is_a?(Statement) ||
195
+ expression_or_pattern.is_a?(UnionQuery)
196
+ return FunctionInvocation.new('count', [Expression.coerce(expression_or_pattern)]) unless subquery
152
197
 
153
- FunctionInvocation.new('count', [Expression.coerce(expression_or_pattern)])
198
+ CountExpression.new(expression_or_pattern, imports.flatten.map { |import| Expression.coerce(import) })
154
199
  end
155
200
 
156
201
  # `Cypher.call('db.labels')` / `Cypher.call('db', 'labels')`: a procedure call (see {StandaloneCall});
@@ -161,15 +206,30 @@ module CypherDSL
161
206
  StandaloneCall.new(arguments)
162
207
  end
163
208
 
164
- # `Cypher.use('graph', statement)` / `Cypher.use(Cypher.graph_by_name(name), statement)`:
165
- # the statement prefixed with `USE graph`.
209
+ # `Cypher.use('graph', statement)`: the statement prefixed with `USE graph`. An
210
+ # expression as the target (a name, literal or parameter) is wrapped in
211
+ # `graph.byName(…)` unless it already is a function call.
166
212
  def use(graph, statement)
213
+ raise ArgumentError, 'Nested USE clauses are not supported' if statement.clauses.first.is_a?(Clauses::Use)
214
+ if statement.prefix
215
+ raise ArgumentError, "#{DECORATED[statement.prefix]} statements are not supported inside USE clauses"
216
+ end
217
+
218
+ graph = graph_by_name(graph) if graph.is_a?(Expression) && !graph.is_a?(FunctionInvocation)
167
219
  graph = graph.to_s unless graph.is_a?(Expression)
168
220
  Statement.new([Clauses::Use.new(graph:)] + statement.clauses)
169
221
  end
170
222
 
223
+ DECORATED = { 'EXPLAIN' => "EXPLAIN'ed", 'PROFILE' => "PROFILE'd" }.freeze
224
+ private_constant :DECORATED
225
+
171
226
  # `Cypher.load_csv('file:///x.csv').as(:line)`.
172
- def load_csv(uri, with_headers: false) = StatementBuilder.new.load_csv(uri, with_headers:)
227
+ def load_csv(uri, positional_headers = false, with_headers: positional_headers) # rubocop:disable Style/OptionalBooleanParameter
228
+ StatementBuilder.new.load_csv(uri, with_headers:)
229
+ end
230
+
231
+ # `Cypher.using_periodic_commit(500).load_csv(uri).as(:line)`.
232
+ def using_periodic_commit(rate = nil) = UsingPeriodicCommit.new(rate:)
173
233
 
174
234
  # Imports for an EXISTS / COLLECT subquery: `Cypher.subquery_with(x).collect(statement)`.
175
235
  def subquery_with(*imports) = SubqueryWith.new(imports)
@@ -181,8 +241,9 @@ module CypherDSL
181
241
  StatementBuilder.new.send(:append, clause, nil)
182
242
  end
183
243
 
184
- # Verbatim Cypher, the escape hatch.
185
- def raw(cypher) = RawExpression.new(cypher)
244
+ # Verbatim Cypher, the escape hatch; `$E` placeholders take the arguments in
245
+ # order, `\$E` is a literal `$E`. See {RawExpression.create}.
246
+ def raw(*) = RawExpression.create(*)
186
247
 
187
248
  # Static comparison forms, `Cypher.gt(a, b)` etc.
188
249
  def eq(left, right) = Expression.coerce(left).is_equal_to(right)
@@ -11,6 +11,9 @@ module CypherDSL
11
11
 
12
12
  def as(alias_name) = AliasedExpression.new(self, alias_name)
13
13
 
14
+ # The expression as Cypher, names escaped only when necessary (as the Java `toString`).
15
+ def to_s = Renderer.plain.render_fragment(self)
16
+
14
17
  # A property of this expression's value, e.g. `$param.key` or `datetime().epochSeconds`.
15
18
  def property(*names) = Property.new(self, names)
16
19
 
@@ -5,9 +5,16 @@ module CypherDSL
5
5
  class FunctionInvocation
6
6
  include Expression
7
7
 
8
+ # The functions that accept `DISTINCT`, lower-cased.
9
+ AGGREGATES = %w[avg collect count max min percentilecont percentiledisc stdev stdevp sum].freeze
10
+
8
11
  attr_reader :function_name, :arguments, :distinct
9
12
 
10
13
  def initialize(function_name, arguments, distinct: false)
14
+ if distinct && !AGGREGATES.include?(function_name.downcase)
15
+ raise ArgumentError, 'The distinct operator can only be applied within aggregate functions.'
16
+ end
17
+
11
18
  @function_name = function_name
12
19
  @arguments = arguments.freeze
13
20
  @distinct = distinct
@@ -60,7 +60,13 @@ module CypherDSL
60
60
  def tail(expression) = function('tail', expression)
61
61
  def keys(expression) = function('keys', expression)
62
62
  def properties(expression) = function('properties', expression)
63
- def coalesce(*expressions) = function('coalesce', *expressions)
63
+
64
+ def coalesce(*expressions)
65
+ raise ArgumentError, 'The expression for coalesce() is required.' if expressions.flatten.empty?
66
+
67
+ function('coalesce', *expressions)
68
+ end
69
+
64
70
  def is_empty(expression) = function('isEmpty', expression)
65
71
  def range(start_value, end_value, step = nil) = function('range', start_value, end_value, *[step].compact)
66
72
  def nodes(path) = function('nodes', path.required_symbolic_name)
@@ -87,6 +93,10 @@ module CypherDSL
87
93
  def to_boolean(expression) = function('toBoolean', expression)
88
94
  def random_uuid = function('randomUUID')
89
95
 
96
+ # `linenumber()` and `file()`, available inside LOAD CSV.
97
+ def linenumber = function('linenumber')
98
+ def file = function('file')
99
+
90
100
  # --- mathematical ----------------------------------------------------
91
101
 
92
102
  def abs(expression) = function('abs', expression)
@@ -8,7 +8,7 @@ module CypherDSL
8
8
  def initialize(node, labels)
9
9
  super()
10
10
  @node = node
11
- @labels = labels.map(&:to_s).freeze
11
+ @labels = labels.map { |label| label.is_a?(Labels) ? label : label.to_s }.freeze
12
12
  freeze
13
13
  end
14
14
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # A planner hint on a MATCH clause: `USING INDEX [SEEK] n:Label(prop, …)`,
5
+ # `USING SCAN n:Label` or `USING JOIN ON n, m`.
6
+ class Hint
7
+ attr_reader :type, :container, :schema_name, :property_names, :names
8
+
9
+ # `use_index_for(seek, *properties)`: all properties must belong to the same node or relationship.
10
+ def self.use_index_for(seek, *properties)
11
+ properties = properties.flatten
12
+ raise ArgumentError, 'Cannot use an index without properties!' if properties.empty?
13
+
14
+ references = properties.map { |property| index_reference(property) }
15
+ if references.map(&:first).uniq.size > 1
16
+ raise ArgumentError, 'If you want to use more than one index on different nodes you must use multiple ' \
17
+ '`USING INDEX` statements.'
18
+ end
19
+
20
+ container, schema_name = references.first
21
+ new(seek ? :index_seek : :index, container:, schema_name:, property_names: properties.map { |p| p.names.first })
22
+ end
23
+
24
+ def self.use_scan_for(node)
25
+ raise ArgumentError, 'Cannot apply a SCAN hint without a node.' if node.nil?
26
+ raise ArgumentError, 'Exactly one label is required for a SCAN hint.' unless node.labels.size == 1
27
+
28
+ new(:scan, container: node, schema_name: node.labels.first)
29
+ end
30
+
31
+ def self.use_join_on(*nodes_or_names)
32
+ nodes_or_names = nodes_or_names.flatten
33
+ raise ArgumentError, 'At least one node is required to define a JOIN hint.' if nodes_or_names.empty?
34
+
35
+ names = nodes_or_names.map do |item|
36
+ item.respond_to?(:required_symbolic_name) ? item.required_symbolic_name : SymbolicName.of(item)
37
+ end
38
+ new(:join, names:)
39
+ end
40
+
41
+ # The container and its single label or type, validated for an index hint.
42
+ def self.index_reference(property)
43
+ container = property.container
44
+ unless container.is_a?(Node) || container.is_a?(Relationship)
45
+ raise ArgumentError, 'Cannot use a property without a reference to a container inside an index hint.'
46
+ end
47
+ raise ArgumentError, 'One single property is required. Nested properties are not supported.' if property.nested?
48
+
49
+ if container.is_a?(Node)
50
+ raise ArgumentError, 'Exactly one label is required to define the index.' unless container.labels.size == 1
51
+
52
+ [container, container.labels.first]
53
+ else
54
+ types = container.details.types
55
+ raise ArgumentError, 'Exactly one type is required to define the index.' unless types.size == 1
56
+
57
+ [container, types.first]
58
+ end
59
+ end
60
+ private_class_method :index_reference
61
+
62
+ def initialize(type, container: nil, schema_name: nil, property_names: [], names: [])
63
+ @type = type
64
+ @container = container
65
+ @schema_name = schema_name
66
+ @property_names = property_names.freeze
67
+ @names = names.freeze
68
+ freeze
69
+ end
70
+ end
71
+ end
@@ -1,18 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CypherDSL
4
- # `n:Label:Other` as the target of SET or REMOVE; `remove` marks an operation
5
- # built for REMOVE, which a SET clause rejects.
4
+ # `n:Label:Other` or `n:$($labels)` as the target of SET or REMOVE; `remove`
5
+ # marks an operation built for REMOVE, which a SET clause rejects.
6
6
  class LabelOperation
7
7
  attr_reader :node, :labels, :remove
8
8
 
9
9
  def initialize(node, labels, remove: false)
10
10
  @node = node
11
- @labels = labels.map(&:to_s).freeze
11
+ @labels = labels.map { |label| label.is_a?(Labels) ? updatable(label) : label.to_s }.freeze
12
12
  @remove = remove
13
13
  freeze
14
14
  end
15
15
 
16
16
  def remove? = remove
17
+
18
+ private
19
+
20
+ def updatable(labels)
21
+ raise ArgumentError, Labels::UPDATE_MESSAGE unless labels.usable_in_update?
22
+
23
+ labels
24
+ end
17
25
  end
18
26
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # A label expression as in `(n:A|B)`, `(n:(A|B)&!C)` or the dynamic `(n:$($labels))`.
5
+ # Built from {Labels.exactly}, {Labels.all} and {Labels.any} and combined with
6
+ # `and`, `or`, `negate`, `conjunction_with` (`:A:B`) and `disjunction_with`.
7
+ class Labels
8
+ # A single entry: a static label name, or an expression resolving to labels
9
+ # matched with `$()` (all) or `$any()`.
10
+ Value = Data.define(:modifier, :value)
11
+
12
+ TYPES = %i[leaf colon_conjunction colon_disjunction conjunction disjunction].freeze
13
+ OPERATORS = { conjunction: '&', disjunction: '|', colon_conjunction: ':', colon_disjunction: ':' }.freeze
14
+ UPDATE_MESSAGE = 'Only a single dynamic label expression or a set of static labels might be used in an ' \
15
+ 'updating clause'
16
+
17
+ attr_reader :type, :values, :lhs, :rhs
18
+
19
+ def self.exactly(label) = new(:leaf, values: [Value.new(:static, label.to_s)])
20
+ def self.all(expression) = new(:leaf, values: [Value.new(:all, Expression.coerce(expression))])
21
+ def self.any(expression) = new(:leaf, values: [Value.new(:any, Expression.coerce(expression))])
22
+
23
+ def initialize(type, values: [], lhs: nil, rhs: nil, negated: false)
24
+ raise ArgumentError, "Unknown label expression type #{type.inspect}" unless TYPES.include?(type)
25
+
26
+ @type = type
27
+ @values = values.freeze
28
+ @lhs = lhs
29
+ @rhs = rhs
30
+ @negated = negated
31
+ freeze
32
+ end
33
+
34
+ def negated? = @negated
35
+ def leaf? = type == :leaf
36
+
37
+ def and(other) = Labels.new(:conjunction, lhs: self, rhs: coerce(other))
38
+ def or(other) = Labels.new(:disjunction, lhs: self, rhs: coerce(other))
39
+ def negate = Labels.new(type, values:, lhs:, rhs:, negated: !negated?)
40
+ alias not negate
41
+
42
+ # `:A:B`, the form usable in SET and REMOVE.
43
+ def conjunction_with(other) = Labels.new(:colon_conjunction, values: values + coerce(other).values)
44
+ def disjunction_with(other) = Labels.new(:colon_disjunction, values: values + coerce(other).values)
45
+
46
+ # SET and REMOVE take a single dynamic expression or a colon list starting with one.
47
+ def usable_in_update?
48
+ lhs.nil? && rhs.nil? && %i[leaf colon_conjunction].include?(type) && values.first&.modifier == :all
49
+ end
50
+
51
+ # True when nothing would be rendered, e.g. `$([])`.
52
+ def empty?
53
+ values_empty = if %i[conjunction disjunction].include?(type)
54
+ true
55
+ else
56
+ first = values.first&.value
57
+ first.is_a?(ListExpression) && first.elements.empty?
58
+ end
59
+ values_empty && (lhs.nil? || lhs.empty?) && (rhs.nil? || rhs.empty?)
60
+ end
61
+
62
+ private
63
+
64
+ def coerce(other) = other.is_a?(Labels) ? other : Labels.exactly(other)
65
+ end
66
+ end