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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/README.md +53 -4
- data/lib/cypher_dsl/clauses/load_csv.rb +8 -2
- data/lib/cypher_dsl/clauses/match.rb +2 -2
- data/lib/cypher_dsl/clauses.rb +11 -0
- data/lib/cypher_dsl/comparison.rb +6 -0
- data/lib/cypher_dsl/count_expression.rb +7 -3
- data/lib/cypher_dsl/cypher.rb +80 -19
- data/lib/cypher_dsl/expression.rb +3 -0
- data/lib/cypher_dsl/function_invocation.rb +7 -0
- data/lib/cypher_dsl/functions.rb +11 -1
- data/lib/cypher_dsl/has_label_condition.rb +1 -1
- data/lib/cypher_dsl/hint.rb +71 -0
- data/lib/cypher_dsl/label_operation.rb +11 -3
- data/lib/cypher_dsl/labels.rb +66 -0
- data/lib/cypher_dsl/literal.rb +24 -2
- data/lib/cypher_dsl/named_path.rb +6 -3
- data/lib/cypher_dsl/node.rb +15 -4
- data/lib/cypher_dsl/property.rb +9 -2
- data/lib/cypher_dsl/property_exists_condition.rb +15 -0
- data/lib/cypher_dsl/raw_expression.rb +76 -3
- data/lib/cypher_dsl/relationship.rb +8 -1
- data/lib/cypher_dsl/relationship_chain.rb +3 -0
- data/lib/cypher_dsl/renderer/configuration.rb +61 -4
- data/lib/cypher_dsl/renderer/dialect.rb +24 -7
- data/lib/cypher_dsl/renderer/escaper.rb +10 -7
- data/lib/cypher_dsl/renderer/generated_name_resolver.rb +110 -0
- data/lib/cypher_dsl/renderer/name_resolver.rb +35 -0
- data/lib/cypher_dsl/renderer/visitor/expressions.rb +52 -15
- data/lib/cypher_dsl/renderer/visitor/label_rendering.rb +84 -0
- data/lib/cypher_dsl/renderer/visitor/patterns.rb +17 -29
- data/lib/cypher_dsl/renderer/visitor.rb +99 -27
- data/lib/cypher_dsl/renderer.rb +11 -5
- data/lib/cypher_dsl/search.rb +91 -0
- data/lib/cypher_dsl/standalone_call.rb +0 -6
- data/lib/cypher_dsl/statement.rb +4 -0
- data/lib/cypher_dsl/statement_builder.rb +67 -14
- data/lib/cypher_dsl/statement_catalog.rb +40 -6
- data/lib/cypher_dsl/subquery_with.rb +1 -0
- data/lib/cypher_dsl/symbolic_name.rb +1 -1
- data/lib/cypher_dsl/using_periodic_commit.rb +11 -0
- data/lib/cypher_dsl/vector_literal.rb +41 -0
- data/lib/cypher_dsl/version.rb +1 -1
- metadata +11 -3
- data/lib/cypher_dsl/label_expression.rb +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5430e442ecb75b449cefb44e0b4d1e06ccdc80ca8a4dc10a7f3da2366a8cb049
|
|
4
|
+
data.tar.gz: 393b61210b500da83e53ea17706bc8e8e691ed3f813f69c1557c85a6be86e182
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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:
|
|
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
|
|
74
|
-
|
|
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
|
-
|
|
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
|
data/lib/cypher_dsl/clauses.rb
CHANGED
|
@@ -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
|
data/lib/cypher_dsl/cypher.rb
CHANGED
|
@@ -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
|
|
14
|
-
# map (`Cypher.node('Test', Cypher.map_of(…))`)
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
85
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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 }`
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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)
|
|
165
|
-
# the
|
|
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,
|
|
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
|
-
|
|
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
|
data/lib/cypher_dsl/functions.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
@@ -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`
|
|
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(
|
|
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
|