cypher-dsl 0.1.0.alpha.1
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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/NOTICE +14 -0
- data/README.md +87 -0
- data/lib/cypher_dsl/aliased_expression.rb +16 -0
- data/lib/cypher_dsl/asterisk.rb +10 -0
- data/lib/cypher_dsl/case_expression.rb +37 -0
- data/lib/cypher_dsl/clauses/call.rb +8 -0
- data/lib/cypher_dsl/clauses/call_subquery.rb +9 -0
- data/lib/cypher_dsl/clauses/create.rb +8 -0
- data/lib/cypher_dsl/clauses/delete.rb +8 -0
- data/lib/cypher_dsl/clauses/finish.rb +8 -0
- data/lib/cypher_dsl/clauses/foreach.rb +8 -0
- data/lib/cypher_dsl/clauses/load_csv.rb +8 -0
- data/lib/cypher_dsl/clauses/match.rb +8 -0
- data/lib/cypher_dsl/clauses/merge.rb +8 -0
- data/lib/cypher_dsl/clauses/order.rb +8 -0
- data/lib/cypher_dsl/clauses/raw_return.rb +8 -0
- data/lib/cypher_dsl/clauses/remove.rb +8 -0
- data/lib/cypher_dsl/clauses/return.rb +8 -0
- data/lib/cypher_dsl/clauses/set.rb +8 -0
- data/lib/cypher_dsl/clauses/unwind.rb +8 -0
- data/lib/cypher_dsl/clauses/use.rb +8 -0
- data/lib/cypher_dsl/clauses/with.rb +8 -0
- data/lib/cypher_dsl/clauses.rb +25 -0
- data/lib/cypher_dsl/collect_expression.rb +28 -0
- data/lib/cypher_dsl/comparison.rb +27 -0
- data/lib/cypher_dsl/compound_condition.rb +34 -0
- data/lib/cypher_dsl/condition.rb +35 -0
- data/lib/cypher_dsl/conflicting_parameters_exception.rb +15 -0
- data/lib/cypher_dsl/constant_condition.rb +17 -0
- data/lib/cypher_dsl/count_expression.rb +16 -0
- data/lib/cypher_dsl/cypher.rb +210 -0
- data/lib/cypher_dsl/exists_condition.rb +15 -0
- data/lib/cypher_dsl/expression.rb +69 -0
- data/lib/cypher_dsl/expression_condition.rb +14 -0
- data/lib/cypher_dsl/function_invocation.rb +17 -0
- data/lib/cypher_dsl/functions.rb +182 -0
- data/lib/cypher_dsl/has_label_condition.rb +15 -0
- data/lib/cypher_dsl/label_expression.rb +40 -0
- data/lib/cypher_dsl/label_operation.rb +18 -0
- data/lib/cypher_dsl/list_comprehension.rb +40 -0
- data/lib/cypher_dsl/list_expression.rb +17 -0
- data/lib/cypher_dsl/list_operator.rb +24 -0
- data/lib/cypher_dsl/list_predicate.rb +32 -0
- data/lib/cypher_dsl/literal.rb +29 -0
- data/lib/cypher_dsl/map_expression.rb +32 -0
- data/lib/cypher_dsl/map_projection.rb +58 -0
- data/lib/cypher_dsl/merge_action.rb +11 -0
- data/lib/cypher_dsl/named_path.rb +59 -0
- data/lib/cypher_dsl/no_condition.rb +14 -0
- data/lib/cypher_dsl/node.rb +83 -0
- data/lib/cypher_dsl/operation.rb +22 -0
- data/lib/cypher_dsl/parameter.rb +40 -0
- data/lib/cypher_dsl/pattern_comprehension.rb +31 -0
- data/lib/cypher_dsl/pattern_expression.rb +16 -0
- data/lib/cypher_dsl/property.rb +24 -0
- data/lib/cypher_dsl/quantified_path_pattern.rb +37 -0
- data/lib/cypher_dsl/raw_expression.rb +15 -0
- data/lib/cypher_dsl/reduction.rb +33 -0
- data/lib/cypher_dsl/relationship.rb +97 -0
- data/lib/cypher_dsl/relationship_chain.rb +44 -0
- data/lib/cypher_dsl/relationship_pattern_condition.rb +14 -0
- data/lib/cypher_dsl/renderer/configuration.rb +66 -0
- data/lib/cypher_dsl/renderer/dialect.rb +20 -0
- data/lib/cypher_dsl/renderer/escaper.rb +31 -0
- data/lib/cypher_dsl/renderer/visitor/expressions.rb +274 -0
- data/lib/cypher_dsl/renderer/visitor/patterns.rb +125 -0
- data/lib/cypher_dsl/renderer/visitor.rb +298 -0
- data/lib/cypher_dsl/renderer.rb +28 -0
- data/lib/cypher_dsl/sort_item.rb +17 -0
- data/lib/cypher_dsl/standalone_call.rb +61 -0
- data/lib/cypher_dsl/statement.rb +56 -0
- data/lib/cypher_dsl/statement_builder.rb +294 -0
- data/lib/cypher_dsl/statement_catalog.rb +25 -0
- data/lib/cypher_dsl/subquery_with.rb +15 -0
- data/lib/cypher_dsl/symbolic_name.rb +48 -0
- data/lib/cypher_dsl/unary_operation.rb +16 -0
- data/lib/cypher_dsl/union_query.rb +29 -0
- data/lib/cypher_dsl/version.rb +5 -0
- data/lib/cypher_dsl.rb +13 -0
- metadata +140 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# The list predicates `all(x IN list WHERE condition)`, `any(…)`, `none(…)`, `single(…)`.
|
|
5
|
+
# Built with `Cypher.all(:x).in(list).where(condition)` (or `any`, `none`, `single`).
|
|
6
|
+
class ListPredicate < Condition
|
|
7
|
+
attr_reader :predicate, :variable, :list, :condition
|
|
8
|
+
|
|
9
|
+
def initialize(predicate, variable, list, condition)
|
|
10
|
+
super()
|
|
11
|
+
@predicate = predicate
|
|
12
|
+
@variable = SymbolicName.of(variable)
|
|
13
|
+
@list = list
|
|
14
|
+
@condition = condition
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# `Cypher.all(:x)`: waiting for `in(list)` and then `where(condition)`.
|
|
19
|
+
class Ongoing
|
|
20
|
+
def initialize(predicate, variable, list = nil)
|
|
21
|
+
@predicate = predicate
|
|
22
|
+
@variable = variable
|
|
23
|
+
@list = list
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def in(list) = Ongoing.new(@predicate, @variable, Expression.coerce(list))
|
|
28
|
+
|
|
29
|
+
def where(condition) = ListPredicate.new(@predicate, @variable, @list, Condition.coerce(condition))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A literal value rendered inline: strings, numbers, booleans, NULL and lists.
|
|
5
|
+
# Hashes become a {MapExpression}; expressions are returned unchanged.
|
|
6
|
+
class Literal
|
|
7
|
+
include Expression
|
|
8
|
+
|
|
9
|
+
attr_reader :value
|
|
10
|
+
|
|
11
|
+
def self.of(value)
|
|
12
|
+
case value
|
|
13
|
+
when Expression then value
|
|
14
|
+
when Hash then MapExpression.of(value)
|
|
15
|
+
when Array then ListExpression.of(value)
|
|
16
|
+
else new(value)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(value)
|
|
21
|
+
@value = value.frozen? ? value : value.dup.freeze
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
TRUE = new(true)
|
|
26
|
+
FALSE = new(false)
|
|
27
|
+
NULL = new(nil)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A map literal such as `{name: 'Alice', age: $age}`. Keys are strings,
|
|
5
|
+
# values are expressions.
|
|
6
|
+
class MapExpression
|
|
7
|
+
include Expression
|
|
8
|
+
|
|
9
|
+
attr_reader :entries
|
|
10
|
+
|
|
11
|
+
# Accepts a single Hash or alternating keys and values, as `Cypher.map_of` does.
|
|
12
|
+
def self.of(*keys_and_values)
|
|
13
|
+
return keys_and_values.first if keys_and_values.size == 1 && keys_and_values.first.is_a?(MapExpression)
|
|
14
|
+
|
|
15
|
+
pairs = if keys_and_values.size == 1 && keys_and_values.first.is_a?(Hash)
|
|
16
|
+
keys_and_values.first.to_a
|
|
17
|
+
else
|
|
18
|
+
raise ArgumentError, 'map_of needs an even number of arguments' if keys_and_values.size.odd?
|
|
19
|
+
|
|
20
|
+
keys_and_values.each_slice(2).to_a
|
|
21
|
+
end
|
|
22
|
+
new(pairs.map { |key, value| [key.to_s, Expression.coerce(value)] })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(entries)
|
|
26
|
+
@entries = entries.map(&:freeze).freeze
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def empty? = entries.empty?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A map projection `n{.name, .age, total: count(x), .*}`.
|
|
5
|
+
#
|
|
6
|
+
# Entries follow the Java rules: a name followed by an expression is a
|
|
7
|
+
# `key: expression` pair, a lone name is a property lookup `.name`, an
|
|
8
|
+
# expression on its own is rendered as is, and `Cypher.asterisk` is `.*`.
|
|
9
|
+
class MapProjection
|
|
10
|
+
include Expression
|
|
11
|
+
|
|
12
|
+
attr_reader :subject, :entries
|
|
13
|
+
|
|
14
|
+
def initialize(subject, entries)
|
|
15
|
+
@subject = subject
|
|
16
|
+
@entries = MapProjection.parse(entries).freeze
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def and(*more_entries) = MapProjection.new(subject, entries + more_entries.flatten)
|
|
21
|
+
|
|
22
|
+
# Entries already parsed (arrays, from `and`) pass through untouched.
|
|
23
|
+
def self.parse(raw_entries)
|
|
24
|
+
entries = []
|
|
25
|
+
pending_key = nil
|
|
26
|
+
raw_entries.each do |entry|
|
|
27
|
+
if pending_key
|
|
28
|
+
entries << (name?(entry) ? [:lookup, pending_key] : [:pair, pending_key, Expression.coerce(entry)])
|
|
29
|
+
pending_key = name?(entry) ? entry.to_s : nil
|
|
30
|
+
elsif name?(entry)
|
|
31
|
+
pending_key = entry.to_s
|
|
32
|
+
else
|
|
33
|
+
entries << parse_expression(entry)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
entries << [:lookup, pending_key] if pending_key
|
|
37
|
+
entries
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.parse_expression(entry)
|
|
41
|
+
case entry
|
|
42
|
+
when Array then entry # already parsed (from `and`)
|
|
43
|
+
when Asterisk then [:all]
|
|
44
|
+
when Property
|
|
45
|
+
raise ArgumentError, 'Cannot project nested properties!' if entry.nested?
|
|
46
|
+
|
|
47
|
+
[:property, entry]
|
|
48
|
+
when AliasedExpression then [:pair, entry.alias_name.value, entry]
|
|
49
|
+
when SymbolicName, Node, Relationship then [:expression, entry]
|
|
50
|
+
else
|
|
51
|
+
raise ArgumentError,
|
|
52
|
+
"#{entry.inspect} of type #{entry.class} cannot be used with an implicit name as map entry."
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.name?(entry) = entry.is_a?(String) || entry.is_a?(Symbol)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# `ON CREATE SET …` / `ON MATCH SET …` attached to a MERGE.
|
|
5
|
+
MergeAction = Data.define(:type, :operations) do
|
|
6
|
+
# `MergeAction.of(:on_create, set_clause)`; the type may also be given as `:ON_CREATE`.
|
|
7
|
+
def self.of(type, set_clause)
|
|
8
|
+
new(type: type.to_s.downcase.to_sym, operations: set_clause.operations)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A named path `p = (a)-[]->(b)`, optionally with a path selector such as
|
|
5
|
+
# `p = ANY SHORTEST …`. Usable as a pattern element in MATCH and as an
|
|
6
|
+
# expression (`p`) afterwards. Built with `Cypher.path(:p).defined_by(pattern)`
|
|
7
|
+
# or `Cypher.any_shortest.named(:p).defined_by(pattern)`.
|
|
8
|
+
class NamedPath
|
|
9
|
+
include Expression
|
|
10
|
+
|
|
11
|
+
attr_reader :symbolic_name, :pattern, :selector
|
|
12
|
+
|
|
13
|
+
def initialize(symbolic_name, pattern, selector: nil)
|
|
14
|
+
@symbolic_name = symbolic_name
|
|
15
|
+
@pattern = pattern
|
|
16
|
+
@selector = selector
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
alias required_symbolic_name symbolic_name
|
|
21
|
+
alias as_expression symbolic_name
|
|
22
|
+
|
|
23
|
+
# `Cypher.path(:p)`: waiting for `defined_by`, or `get` for a bare reference to the name.
|
|
24
|
+
class Ongoing
|
|
25
|
+
def initialize(symbolic_name, selector: nil)
|
|
26
|
+
@symbolic_name = symbolic_name
|
|
27
|
+
@selector = selector
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Defining a path by another named path reuses that path.
|
|
32
|
+
def defined_by(pattern)
|
|
33
|
+
return pattern if pattern.is_a?(NamedPath)
|
|
34
|
+
|
|
35
|
+
NamedPath.new(@symbolic_name, pattern, selector: @selector)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get = @symbolic_name
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `Cypher.any_shortest` and friends: a path selector waiting for `named`.
|
|
42
|
+
class Selector
|
|
43
|
+
attr_reader :keyword
|
|
44
|
+
|
|
45
|
+
def self.shortest_k(count, groups: false)
|
|
46
|
+
raise ArgumentError, 'The path count needs to be greater than 0.' unless count.positive?
|
|
47
|
+
|
|
48
|
+
new("SHORTEST #{count}#{' GROUPS' if groups}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(keyword)
|
|
52
|
+
@keyword = keyword
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def named(name) = Ongoing.new(SymbolicName.of(name), selector: self)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# The empty condition: it renders nothing and disappears when combined with
|
|
5
|
+
# another condition, which lets callers build conditions incrementally.
|
|
6
|
+
class NoCondition < Condition
|
|
7
|
+
INSTANCE = new.freeze
|
|
8
|
+
|
|
9
|
+
def and(other) = Condition.coerce(other)
|
|
10
|
+
def or(other) = Condition.coerce(other)
|
|
11
|
+
def xor(other) = Condition.coerce(other)
|
|
12
|
+
def not = self
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A node pattern `(name:Label {props} WHERE condition)`. Used as an expression
|
|
5
|
+
# it stands for its symbolic name.
|
|
6
|
+
#
|
|
7
|
+
# Nodes are value objects; the one piece of lazily computed state is the
|
|
8
|
+
# generated name an unnamed node receives when something references it, as
|
|
9
|
+
# in the Java `getRequiredSymbolicName`.
|
|
10
|
+
class Node
|
|
11
|
+
include Expression
|
|
12
|
+
|
|
13
|
+
attr_reader :labels, :properties, :where_condition
|
|
14
|
+
|
|
15
|
+
# The explicit name, or the generated one once {#required_symbolic_name} was called.
|
|
16
|
+
attr_reader :symbolic_name
|
|
17
|
+
|
|
18
|
+
def initialize(labels:, symbolic_name: nil, properties: nil, where_condition: nil)
|
|
19
|
+
@labels = labels.freeze
|
|
20
|
+
@symbolic_name = symbolic_name
|
|
21
|
+
@properties = properties
|
|
22
|
+
@where_condition = where_condition
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def named(name) = with(symbolic_name: SymbolicName.of(name))
|
|
26
|
+
|
|
27
|
+
def with_properties(*keys_and_values) = with(properties: MapExpression.of(*keys_and_values))
|
|
28
|
+
|
|
29
|
+
# The inline predicate `(n WHERE condition)`.
|
|
30
|
+
def where(condition) = with(where_condition: Condition.coerce(condition))
|
|
31
|
+
|
|
32
|
+
def named? = !@symbolic_name.nil?
|
|
33
|
+
|
|
34
|
+
# The name of this node, generating one if it has none (and keeping it, as in Java).
|
|
35
|
+
def required_symbolic_name
|
|
36
|
+
@symbolic_name ||= SymbolicName.unresolved(name_hint) # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The first plain label, lower-cased, or `n` for nodes without one.
|
|
40
|
+
def name_hint = labels.first.is_a?(String) ? labels.first.downcase : 'n'
|
|
41
|
+
alias as_expression required_symbolic_name
|
|
42
|
+
|
|
43
|
+
def property(*names) = Property.new(required_symbolic_name, names)
|
|
44
|
+
|
|
45
|
+
# The condition `n:Label`, checking the node for the given labels.
|
|
46
|
+
def has_labels(*labels) = HasLabelCondition.new(self, labels)
|
|
47
|
+
|
|
48
|
+
# `n += {…}` and `n = {…}`, for use in SET.
|
|
49
|
+
def mutate(expression) = Operation.new(self, '+=', Expression.coerce(expression))
|
|
50
|
+
def set(expression) = Operation.new(self, '=', Expression.coerce(expression))
|
|
51
|
+
|
|
52
|
+
# The map projection `n{…}`, built on the (possibly generated) name.
|
|
53
|
+
def project(*entries) = MapProjection.new(required_symbolic_name, entries.flatten)
|
|
54
|
+
|
|
55
|
+
# `id(n)`; deprecated in Neo4j 5 in favour of {#element_id}, kept for parity.
|
|
56
|
+
def internal_id = FunctionInvocation.new('id', [required_symbolic_name])
|
|
57
|
+
|
|
58
|
+
# `labels(n)`.
|
|
59
|
+
def labels_function = FunctionInvocation.new('labels', [required_symbolic_name])
|
|
60
|
+
|
|
61
|
+
# `elementId(n)`.
|
|
62
|
+
def element_id = FunctionInvocation.new('elementId', [required_symbolic_name])
|
|
63
|
+
|
|
64
|
+
def relationship_to(other, *types) = Relationship.create(self, :ltr, other, types)
|
|
65
|
+
def relationship_from(other, *types) = Relationship.create(self, :rtl, other, types)
|
|
66
|
+
def relationship_between(other, *types) = Relationship.create(self, :uni, other, types)
|
|
67
|
+
|
|
68
|
+
# `relationship_with(other, Relationship::Direction::LTR, 'TYPE')`: the direction as a value.
|
|
69
|
+
def relationship_with(other, direction, *types)
|
|
70
|
+
unless Relationship::Direction::ALL.include?(direction)
|
|
71
|
+
raise ArgumentError, "Unknown direction #{direction.inspect}, use one of Relationship::Direction"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Relationship.create(self, direction, other, types)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def with(**changes)
|
|
80
|
+
Node.new(labels:, symbolic_name: @symbolic_name, properties:, where_condition:, **changes)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A binary operation such as `n.x = true` or `a + b`.
|
|
5
|
+
class Operation
|
|
6
|
+
include Expression
|
|
7
|
+
|
|
8
|
+
attr_reader :left, :operator, :right
|
|
9
|
+
|
|
10
|
+
ASSIGNMENTS = ['=', '+='].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(left, operator, right)
|
|
13
|
+
@left = left
|
|
14
|
+
@operator = operator
|
|
15
|
+
@right = right
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Assignments (`n.p = x`) render bare; arithmetic renders in parentheses.
|
|
20
|
+
def assignment? = ASSIGNMENTS.include?(operator)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A query parameter, rendered as `$name`, optionally carrying its value.
|
|
5
|
+
# An anonymous parameter has no name of its own; the renderer assigns
|
|
6
|
+
# `pcdsl01`, `pcdsl02`, … per statement, sharing a name between anonymous
|
|
7
|
+
# parameters with equal values.
|
|
8
|
+
class Parameter
|
|
9
|
+
include Expression
|
|
10
|
+
|
|
11
|
+
NO_VALUE = Object.new.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :name, :value
|
|
14
|
+
|
|
15
|
+
def self.anonymous(value) = new(nil, value)
|
|
16
|
+
|
|
17
|
+
# A leading `$` in the name is tolerated and dropped.
|
|
18
|
+
def initialize(name, value = NO_VALUE)
|
|
19
|
+
@name = name&.to_s&.delete_prefix('$')
|
|
20
|
+
raise ArgumentError, 'A parameter name must not be empty.' if @name && @name.empty?
|
|
21
|
+
|
|
22
|
+
@value = value
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def with_value(value) = Parameter.new(name, value)
|
|
27
|
+
|
|
28
|
+
def anonymous? = name.nil?
|
|
29
|
+
def value? = !value.equal?(NO_VALUE)
|
|
30
|
+
|
|
31
|
+
# Named parameters are equal by name and value, anonymous ones by value.
|
|
32
|
+
def ==(other)
|
|
33
|
+
other.is_a?(Parameter) && other.name == name && other.value == value
|
|
34
|
+
end
|
|
35
|
+
alias eql? ==
|
|
36
|
+
def hash = [self.class, name, value].hash
|
|
37
|
+
|
|
38
|
+
def to_s = "$#{Renderer::Escaper.escape_parameter_name(name || '?')}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# `[pattern WHERE condition | expression]`. Built with `Cypher.list_based_on(pattern)`,
|
|
5
|
+
# refined with `where`/`and`/`or`, completed with `returning`.
|
|
6
|
+
class PatternComprehension
|
|
7
|
+
include Expression
|
|
8
|
+
|
|
9
|
+
attr_reader :pattern, :where_condition, :projection
|
|
10
|
+
|
|
11
|
+
def initialize(pattern, where_condition: nil, projection: nil)
|
|
12
|
+
@pattern = pattern
|
|
13
|
+
@where_condition = where_condition
|
|
14
|
+
@projection = projection&.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def where(condition) = with(where_condition: Condition.coerce(condition))
|
|
19
|
+
|
|
20
|
+
def and(condition) = with(where_condition: where_condition.and(condition))
|
|
21
|
+
def or(condition) = with(where_condition: where_condition.or(condition))
|
|
22
|
+
|
|
23
|
+
def returning(*expressions) = with(projection: expressions.flatten.map { |e| Expression.coerce(e) })
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def with(**changes)
|
|
28
|
+
PatternComprehension.new(pattern, where_condition:, projection:, **changes)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A pattern used where an expression is expected, as in `size((a)-->())`:
|
|
5
|
+
# names already in scope render bare, names not yet seen are dropped.
|
|
6
|
+
class PatternExpression
|
|
7
|
+
include Expression
|
|
8
|
+
|
|
9
|
+
attr_reader :pattern
|
|
10
|
+
|
|
11
|
+
def initialize(pattern)
|
|
12
|
+
@pattern = pattern
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A property lookup such as `n.name`, `n.address.city`, or the dynamic `n[$key]`.
|
|
5
|
+
# Names are strings; an expression as a name renders as an index lookup.
|
|
6
|
+
class Property
|
|
7
|
+
include Expression
|
|
8
|
+
|
|
9
|
+
attr_reader :container, :names
|
|
10
|
+
|
|
11
|
+
def initialize(container, names)
|
|
12
|
+
@container = container
|
|
13
|
+
@names = names.map { |name| name.is_a?(Expression) ? name : name.to_s }.freeze
|
|
14
|
+
raise ArgumentError, 'A property needs at least one name.' if @names.empty?
|
|
15
|
+
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def nested? = names.size > 1
|
|
20
|
+
|
|
21
|
+
# `n.prop = value`, for use in SET.
|
|
22
|
+
def to(expression) = Operation.new(self, '=', Expression.coerce(expression))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A whole path pattern with a quantifier, `((a)-[:X]->(b)){1,3}`, optionally
|
|
5
|
+
# with an inline WHERE. Built with `relationship.quantify(quantifier)`.
|
|
6
|
+
#
|
|
7
|
+
# Quantifiers come from {.interval}, {.plus} and {.star}; a relationship-only
|
|
8
|
+
# quantifier (`-[:X]->{1,3}`) is applied with `quantify_relationship`.
|
|
9
|
+
class QuantifiedPathPattern
|
|
10
|
+
# `{lower,upper}`, `+` or `*`.
|
|
11
|
+
Quantifier = Data.define(:lower, :upper, :symbol) do
|
|
12
|
+
def to_cypher = symbol || "{#{lower || 0},#{upper}}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.interval(lower, upper)
|
|
16
|
+
raise ArgumentError, 'Lower bound must be greater than or equal to zero' if lower&.negative?
|
|
17
|
+
raise ArgumentError, 'Upper bound must be greater than zero' if upper && upper < 1
|
|
18
|
+
raise ArgumentError, "Upper bound must be greater than or equal to #{lower}" if lower && upper && upper < lower
|
|
19
|
+
|
|
20
|
+
Quantifier.new(lower:, upper:, symbol: nil)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.plus = Quantifier.new(lower: 1, upper: nil, symbol: '+')
|
|
24
|
+
def self.star = Quantifier.new(lower: 0, upper: nil, symbol: '*')
|
|
25
|
+
|
|
26
|
+
attr_reader :pattern, :quantifier, :where_condition
|
|
27
|
+
|
|
28
|
+
def initialize(pattern, quantifier, where_condition: nil)
|
|
29
|
+
@pattern = pattern
|
|
30
|
+
@quantifier = quantifier
|
|
31
|
+
@where_condition = where_condition
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def where(condition) = QuantifiedPathPattern.new(pattern, quantifier, where_condition: Condition.coerce(condition))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A fragment of Cypher text rendered verbatim: the escape hatch, like `Arel.sql`.
|
|
5
|
+
class RawExpression
|
|
6
|
+
include Expression
|
|
7
|
+
|
|
8
|
+
attr_reader :content
|
|
9
|
+
|
|
10
|
+
def initialize(content)
|
|
11
|
+
@content = content.to_s
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# `reduce(accumulator = initial, variable IN list | mapper)`, built step by
|
|
5
|
+
# step: `Cypher.reduce(:n).in(list).map(acc.add(n)).accumulate_on(acc).with_initial_value_of(0)`.
|
|
6
|
+
class Reduction
|
|
7
|
+
include Expression
|
|
8
|
+
|
|
9
|
+
attr_reader :variable, :list, :mapper, :accumulator, :initial_value
|
|
10
|
+
|
|
11
|
+
def self.of(variable) = Ongoing.new(variable: SymbolicName.of(variable), list: nil, mapper: nil, accumulator: nil)
|
|
12
|
+
|
|
13
|
+
def initialize(variable:, list:, mapper:, accumulator:, initial_value:)
|
|
14
|
+
@variable = variable
|
|
15
|
+
@list = list
|
|
16
|
+
@mapper = mapper
|
|
17
|
+
@accumulator = accumulator
|
|
18
|
+
@initial_value = initial_value
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The intermediate states; `with_initial_value_of` completes the reduction.
|
|
23
|
+
Ongoing = Data.define(:variable, :list, :mapper, :accumulator) do
|
|
24
|
+
def in(list) = with(list: Expression.coerce(list))
|
|
25
|
+
def map(mapper) = with(mapper: Expression.coerce(mapper))
|
|
26
|
+
def accumulate_on(accumulator) = with(accumulator: Expression.coerce(accumulator))
|
|
27
|
+
|
|
28
|
+
def with_initial_value_of(initial_value)
|
|
29
|
+
Reduction.new(variable:, list:, mapper:, accumulator:, initial_value: Expression.coerce(initial_value))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A relationship pattern between two nodes, `(a)-[r:TYPE*1..3 {props} WHERE …]->{1,3}(b)`.
|
|
5
|
+
# Used as an expression it stands for its symbolic name.
|
|
6
|
+
class Relationship
|
|
7
|
+
include Expression
|
|
8
|
+
|
|
9
|
+
# Everything between the brackets plus the direction and a relationship quantifier.
|
|
10
|
+
Details = Data.define(:types, :symbolic_name, :properties, :min, :max, :unbounded, :where, :quantifier) do
|
|
11
|
+
def empty?
|
|
12
|
+
symbolic_name.nil? && types.empty? && properties.nil? && !length? && where.nil?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def length? = unbounded || !min.nil? || !max.nil?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The direction of a relationship pattern, for {Node#relationship_with}.
|
|
19
|
+
module Direction
|
|
20
|
+
LTR = :ltr
|
|
21
|
+
RTL = :rtl
|
|
22
|
+
UNI = :uni
|
|
23
|
+
ALL = [LTR, RTL, UNI].freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :left, :right, :direction, :details
|
|
27
|
+
|
|
28
|
+
def self.create(left, direction, right, types)
|
|
29
|
+
details = Details.new(types: types.map(&:to_s).freeze, symbolic_name: nil, properties: nil,
|
|
30
|
+
min: nil, max: nil, unbounded: false, where: nil, quantifier: nil)
|
|
31
|
+
new(left, direction, right, details)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(left, direction, right, details)
|
|
35
|
+
@left = left
|
|
36
|
+
@direction = direction
|
|
37
|
+
@right = right
|
|
38
|
+
@details = details
|
|
39
|
+
@generated_name = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The explicit name, or the generated one once {#required_symbolic_name} was called.
|
|
43
|
+
def symbolic_name = details.symbolic_name || @generated_name
|
|
44
|
+
|
|
45
|
+
def named? = !symbolic_name.nil?
|
|
46
|
+
|
|
47
|
+
# The name of this relationship, generating one if it has none.
|
|
48
|
+
def required_symbolic_name
|
|
49
|
+
details.symbolic_name || (@generated_name ||= SymbolicName.unresolved(details.types.first&.downcase || 'r'))
|
|
50
|
+
end
|
|
51
|
+
alias as_expression required_symbolic_name
|
|
52
|
+
|
|
53
|
+
def named(name) = with_details(symbolic_name: SymbolicName.of(name))
|
|
54
|
+
|
|
55
|
+
def with_properties(*keys_and_values) = with_details(properties: MapExpression.of(*keys_and_values))
|
|
56
|
+
alias properties with_properties
|
|
57
|
+
|
|
58
|
+
def min(minimum) = with_details(min: minimum)
|
|
59
|
+
def max(maximum) = with_details(max: maximum)
|
|
60
|
+
def length(minimum, maximum) = with_details(min: minimum, max: maximum)
|
|
61
|
+
def unbounded = with_details(unbounded: true)
|
|
62
|
+
|
|
63
|
+
# The inline predicate `-[r WHERE condition]->`.
|
|
64
|
+
def where(condition) = with_details(where: Condition.coerce(condition))
|
|
65
|
+
|
|
66
|
+
# `-[:X]->{1,3}`: a quantifier on the relationship only.
|
|
67
|
+
def quantify_relationship(quantifier) = with_details(quantifier:)
|
|
68
|
+
|
|
69
|
+
# `((a)-[:X]->(b)){1,3}`: a quantifier on the whole pattern.
|
|
70
|
+
def quantify(quantifier) = QuantifiedPathPattern.new(self, quantifier)
|
|
71
|
+
|
|
72
|
+
# The pattern as a predicate, `WHERE (a)-[:X]->(b)`.
|
|
73
|
+
def as_condition = RelationshipPatternCondition.new(self)
|
|
74
|
+
|
|
75
|
+
def property(*names) = Property.new(required_symbolic_name, names)
|
|
76
|
+
|
|
77
|
+
# `r += {…}` and `r = {…}`, for use in SET.
|
|
78
|
+
def mutate(expression) = Operation.new(self, '+=', Expression.coerce(expression))
|
|
79
|
+
def set(expression) = Operation.new(self, '=', Expression.coerce(expression))
|
|
80
|
+
|
|
81
|
+
# The map projection `r{…}`, built on the (possibly generated) name.
|
|
82
|
+
def project(*entries) = MapProjection.new(required_symbolic_name, entries.flatten)
|
|
83
|
+
|
|
84
|
+
# `elementId(r)`.
|
|
85
|
+
def element_id = FunctionInvocation.new('elementId', [required_symbolic_name])
|
|
86
|
+
|
|
87
|
+
def relationship_to(other, *types) = RelationshipChain.new([self]).relationship_to(other, *types)
|
|
88
|
+
def relationship_from(other, *types) = RelationshipChain.new([self]).relationship_from(other, *types)
|
|
89
|
+
def relationship_between(other, *types) = RelationshipChain.new([self]).relationship_between(other, *types)
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def with_details(**changes)
|
|
94
|
+
Relationship.new(left, direction, right, details.with(**changes))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|