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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aae0b1907cab6d377de663093abb2aca70b7925b10dafc1596a50ccfc6f8a97f
4
- data.tar.gz: 21ad9bce707aabfb247196a75bdfb7de97ffa215e53b210aaa2179dcf0a7b313
3
+ metadata.gz: a83e4601b0dc6294144bb3e5a87463cf322861e94927fc5ccfa7c14ab5389e0c
4
+ data.tar.gz: f7475774c957b6ad901caf376774ef2d09d97731874f3cd1b569aec141219089
5
5
  SHA512:
6
- metadata.gz: 69811594f30155665847bc68fc4c66471de5e0f8ae1b1a83e39d08fd6d8426720e4206aa260f20358c395a87ba2ff2a530eafe518f7621235267d97831a05754
7
- data.tar.gz: bc7823802abd14746151e12ae00050be8070be48b0fbed3ef593ef6654bdd0e6613c5c8ebdd78089d3ffbb7303830f06560cb637bb402d893ec279c57f111e43
6
+ metadata.gz: ef77aec97883fc7445c7ecf759d4d6438c614818bb27cbd6c85fe239d9757a0508cf9f0e2348871a2b291f101756a453ccc5930e76eecb6ecb61db6120e4f8d9
7
+ data.tar.gz: 0a37e339fa998ae143fc5691a4c50b4d18f8aa322e21ca9f66065ee1d1005304603928cfbe9884bb16905cb55f870eb8bd6facc5cd19f6df2c82c10f48639340
data/CHANGELOG.md CHANGED
@@ -7,6 +7,70 @@ 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.3] - 2026-11-01
11
+
12
+ The release activegraph's `ActiveGraph::Core::Query` builds on.
13
+
14
+ ### Added
15
+
16
+ - Raw fragments for host DSLs built on strings (Ruby-only, no Java counterpart): `Cypher.raw`
17
+ is accepted as a pattern element in MATCH/CREATE/MERGE, as a condition (also as operand of
18
+ `and`/`or`/`xor`/`not`), as a complete SET / ON CREATE SET / ON MATCH SET operation, and
19
+ `StatementBuilder#raw_clause(format, *args)` renders a whole clause verbatim.
20
+ - Raw operands of a compound condition are parenthesised unless they already form one group,
21
+ so `Cypher.raw('a OR b').as_condition.and(c)` renders `((a OR b) AND c)`.
22
+
23
+ ## [0.1.0.alpha.2] - 2026-11-01
24
+
25
+ Second pre-release. The whole upstream core test suite that applies to Ruby is now
26
+ translated (1067 examples): all remaining integration tests (HintsIT, LoadCSVIT, DialectIT,
27
+ SubqueriesGQLIT, UseIT, ExpressionsIT, IdentifiableExpressionsIT, Cypher5IT, labels, search
28
+ and vector tests) and the unit tests for literals, escaping, preconditions and `toString`.
29
+ Not translated: `DurationLiteralTests` (no duration type in Ruby's standard library), the
30
+ Java-only reflection, package-structure and foreign-adapter tests, and the QueryDSL adapter.
31
+
32
+ ### Added
33
+
34
+ - Planner hints: `using_index`, `using_index_seek`, `using_scan`, `using_join_on` (HintsIT).
35
+ - LOAD CSV: `with_field_terminator`, `Cypher.using_periodic_commit`, `linenumber()`, `file()`.
36
+ - All Java dialects including `NEO4J_5_26`, `NEO4J_5_CYPHER_5`, `NEO4J_5_CYPHER_25` with their
37
+ `CYPHER 5` / `CYPHER 25` prefixes; `NEO4J_5_DEFAULT_CYPHER` is the default.
38
+ - Label expressions (`Labels.exactly/all/any`, `and`, `or`, `negate`, `conjunction_with`),
39
+ dynamic labels `n:$($labels)` on Neo4j 5.26+, `disable_dynamic_labels`.
40
+ - Vector `SEARCH` clauses (`Cypher.search`) and `Cypher.vector` literals.
41
+ - Generated names (`with_generated_names`) and `StatementCatalog#renamed_parameters`.
42
+ - `Cypher.exists(property)` renders `exists()` on Neo4j 4 and `IS NOT NULL` afterwards;
43
+ `shortestPath()` / `allShortestPaths()` on Neo4j 4 and 5.
44
+ - `StatementCatalog#identifiable_expressions`: the items of the final `RETURN` or `YIELD`
45
+ (SubqueriesGQLIT); also on the builder after a `WITH`/`RETURN` (IdentifiableExpressionsIT).
46
+ - `Cypher.raw(format, *args)` with `$E` placeholders and `\$E` escapes (RawLiteralTests).
47
+ - `COUNT { pattern WHERE … }` and `Cypher.subquery_with(…).count(…)` (ExpressionsIT).
48
+ - `Cypher.use` validates nested `USE` and `EXPLAIN`/`PROFILE` inner statements and wraps
49
+ names, literals and parameters in `graph.byName(…)`; `Statement#explain` / `#profile` (UseIT).
50
+ - `Date`, `Time` and `DateTime` literals render as `date(…)` / `datetime(…)`.
51
+ - `Cypher.format`, `Cypher.quote`, `Cypher.has_labels_or_type`, `Cypher.sort(expr, direction)`,
52
+ `Property#name` / `#referenced_as`, `Configuration#==`, `to_s` on expressions and patterns
53
+ (the plain Cypher, as the Java `toString`).
54
+ - Upstream preconditions: empty labels, unnamed nodes in `SET`/`+=`, missing patterns or
55
+ return items, non-updating clauses inside `FOREACH`, `DISTINCT` on non-aggregates,
56
+ unsupported literal types.
57
+
58
+ ### Fixed
59
+
60
+ - Node and relationship names that need quoting (e.g. containing a backtick) are now
61
+ escaped inside patterns, not only where they are used as expressions.
62
+ - A `CALL { }` following a leading `USE` renders as `CALL () { }`.
63
+ - A `nil` additional label in `Cypher.node` raises `An empty label is not allowed.` instead of being
64
+ dropped silently; symbols and strings are validated alike.
65
+
66
+ ### Changed
67
+
68
+ - The default dialect is `NEO4J_5_DEFAULT_CYPHER` (was `NEO4J_5_23`); the only difference
69
+ is that dynamic label expressions render as such.
70
+ - Name escaping follows upstream exactly: ``` stands for a backtick, doubled
71
+ backticks are collapsed before escaping, and an already quoted name is quoted again.
72
+ String literals escape double quotes as well as single quotes and backslashes.
73
+
10
74
  ## [0.1.0.alpha.1] - 2026-10-31
11
75
 
12
76
  First pre-release, reserving the gem name. A Ruby reimplementation of the design of
@@ -28,5 +92,7 @@ collection, IssueRelatedIT, FunctionsIT).
28
92
  - Renderer with dialects (Neo4j 4, 5, 5.23+), pretty printing, identifier escaping,
29
93
  parameter collection (`Statement#catalog`).
30
94
 
31
- [Unreleased]: https://github.com/neo4jrb/cypher-dsl/compare/v0.1.0.alpha.1...HEAD
95
+ [Unreleased]: https://github.com/neo4jrb/cypher-dsl/compare/v0.1.0.alpha.3...HEAD
96
+ [0.1.0.alpha.3]: https://github.com/neo4jrb/cypher-dsl/compare/v0.1.0.alpha.2...v0.1.0.alpha.3
97
+ [0.1.0.alpha.2]: https://github.com/neo4jrb/cypher-dsl/compare/v0.1.0.alpha.1...v0.1.0.alpha.2
32
98
  [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,88 @@ 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
+ A raw fragment is also accepted as a pattern element, as a condition, as a `SET`
79
+ operation, and `raw_clause` renders a whole clause verbatim. These are escape hatches
80
+ for DSLs that have to pass user-written Cypher through; a raw condition combined with
81
+ others is parenthesised so its precedence survives:
82
+
83
+ ```ruby
84
+ Cypher.match(Cypher.raw('(n)-[:KNOWS]->(m)'))
85
+ .where(Cypher.property(:n, :age).gt(30).and(Cypher.raw('m.name STARTS WITH "A" OR m.vip')))
86
+ .raw_clause('CALL db.labels() YIELD label')
87
+ .returning(:n, :label).build.cypher
88
+ # => "MATCH (n)-[:KNOWS]->(m) WHERE (n.age > 30 AND (m.name STARTS WITH \"A\" OR m.vip)) CALL db.labels() YIELD label RETURN n, label"
89
+ ```
90
+
91
+ Every expression and pattern answers `to_s` with its plain Cypher, e.g.
92
+ `Cypher.node('Person').named(:n).to_s # => "(n:Person)"`.
93
+
60
94
  ## Rendering options
61
95
 
62
96
  ```ruby
63
97
  config = Renderer::Configuration.new_config
64
- .with_dialect(Renderer::Dialect::NEO4J_4) # default: NEO4J_5_23
98
+ .with_dialect(Renderer::Dialect::NEO4J_4) # default: NEO4J_5_DEFAULT_CYPHER
65
99
  .always_escape_names(false) # default: true, false when pretty printing
66
100
  .with_pretty_print(true) # one clause per line, indented blocks
67
101
  .with_indent_style(:tab) # or .with_indent_size(4)
102
+ .disable_dynamic_labels # render n:$($labels) statically
68
103
  .build
69
104
  Renderer.get_renderer(config).render(statement)
70
105
  Renderer.get_renderer(Renderer::Configuration.pretty_printing).render(statement)
71
106
  ```
72
107
 
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.
108
+ The dialects mirror the Java enum: `NEO4J_4`, `NEO4J_5`, `NEO4J_5_23`, `NEO4J_5_26`,
109
+ `NEO4J_5_DEFAULT_CYPHER`, `NEO4J_5_CYPHER_5`, `NEO4J_5_CYPHER_25` and `NEO4J_2025`.
110
+ The dialect decides how `CALL { }` subqueries import variables (`CALL (u) { … }` from
111
+ Neo4j 5.23 on, `CALL { WITH u … }` before), whether paths use `shortestPath()` or
112
+ `SHORTEST 1`, `exists(n.prop)` or `n.prop IS NOT NULL`, `distance()` or
113
+ `point.distance()`, whether `elementId()` exists, whether label expressions may be
114
+ dynamic (`n:$($labels)`, Neo4j 5.26+) and whether the statement carries a `CYPHER 5`
115
+ or `CYPHER 25` prefix.
116
+
117
+ ### Generated names
118
+
119
+ `with_generated_names(true)` rewrites every variable, alias and parameter name while
120
+ rendering (`v0`, `v1`, … and `p0`, `p1`, …), scope by scope, so that statements that
121
+ differ only in their names render identically, which is useful as a query-cache key.
122
+ A subset of the Java `GeneratedNames` flags narrows what gets rewritten:
123
+
124
+ ```ruby
125
+ config = Renderer::Configuration.new_config
126
+ .with_generated_names(%i[entity_names parameter_names]) # or true for all flags
127
+ .build
128
+ renderer = Renderer.get_renderer(config)
129
+ renderer.render(statement) # => "MATCH (v0:`Movie`) WHERE v0.title = $p0 RETURN v0"
130
+ renderer.catalog(statement).renamed_parameters # => {"title" => "p0"}
131
+ ```
132
+
133
+ The flags are `entity_names`, `parameter_names`, `all_aliases`, `internal_aliases_only`
134
+ (all aliases except those of the final `RETURN`) and `reuse_aliases`.
75
135
 
76
136
  ## Installation
77
137
 
138
+ Pre-releases are not picked up by a bare requirement, so name the version:
139
+
78
140
  ```ruby
79
- gem 'cypher-dsl'
141
+ gem 'cypher-dsl', '0.1.0.alpha.3'
80
142
  ```
81
143
 
82
144
  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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # A clause rendered verbatim from a {RawExpression}: the escape hatch for
6
+ # Cypher the builder does not model (`raw_clause('CALL db.labels()')`).
7
+ Raw = Data.define(:expression)
8
+ end
9
+ 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
@@ -10,7 +10,7 @@ module CypherDSL
10
10
  case value
11
11
  when Condition then value
12
12
  when Relationship, RelationshipChain then RelationshipPatternCondition.new(value)
13
- when FunctionInvocation then ExpressionCondition.new(value) # boolean functions such as isEmpty()
13
+ when FunctionInvocation, RawExpression then ExpressionCondition.new(value) # isEmpty(), raw text
14
14
  else raise ArgumentError, "#{value.inspect} is not a condition"
15
15
  end
16
16
  end
@@ -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