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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/NOTICE +14 -0
  5. data/README.md +87 -0
  6. data/lib/cypher_dsl/aliased_expression.rb +16 -0
  7. data/lib/cypher_dsl/asterisk.rb +10 -0
  8. data/lib/cypher_dsl/case_expression.rb +37 -0
  9. data/lib/cypher_dsl/clauses/call.rb +8 -0
  10. data/lib/cypher_dsl/clauses/call_subquery.rb +9 -0
  11. data/lib/cypher_dsl/clauses/create.rb +8 -0
  12. data/lib/cypher_dsl/clauses/delete.rb +8 -0
  13. data/lib/cypher_dsl/clauses/finish.rb +8 -0
  14. data/lib/cypher_dsl/clauses/foreach.rb +8 -0
  15. data/lib/cypher_dsl/clauses/load_csv.rb +8 -0
  16. data/lib/cypher_dsl/clauses/match.rb +8 -0
  17. data/lib/cypher_dsl/clauses/merge.rb +8 -0
  18. data/lib/cypher_dsl/clauses/order.rb +8 -0
  19. data/lib/cypher_dsl/clauses/raw_return.rb +8 -0
  20. data/lib/cypher_dsl/clauses/remove.rb +8 -0
  21. data/lib/cypher_dsl/clauses/return.rb +8 -0
  22. data/lib/cypher_dsl/clauses/set.rb +8 -0
  23. data/lib/cypher_dsl/clauses/unwind.rb +8 -0
  24. data/lib/cypher_dsl/clauses/use.rb +8 -0
  25. data/lib/cypher_dsl/clauses/with.rb +8 -0
  26. data/lib/cypher_dsl/clauses.rb +25 -0
  27. data/lib/cypher_dsl/collect_expression.rb +28 -0
  28. data/lib/cypher_dsl/comparison.rb +27 -0
  29. data/lib/cypher_dsl/compound_condition.rb +34 -0
  30. data/lib/cypher_dsl/condition.rb +35 -0
  31. data/lib/cypher_dsl/conflicting_parameters_exception.rb +15 -0
  32. data/lib/cypher_dsl/constant_condition.rb +17 -0
  33. data/lib/cypher_dsl/count_expression.rb +16 -0
  34. data/lib/cypher_dsl/cypher.rb +210 -0
  35. data/lib/cypher_dsl/exists_condition.rb +15 -0
  36. data/lib/cypher_dsl/expression.rb +69 -0
  37. data/lib/cypher_dsl/expression_condition.rb +14 -0
  38. data/lib/cypher_dsl/function_invocation.rb +17 -0
  39. data/lib/cypher_dsl/functions.rb +182 -0
  40. data/lib/cypher_dsl/has_label_condition.rb +15 -0
  41. data/lib/cypher_dsl/label_expression.rb +40 -0
  42. data/lib/cypher_dsl/label_operation.rb +18 -0
  43. data/lib/cypher_dsl/list_comprehension.rb +40 -0
  44. data/lib/cypher_dsl/list_expression.rb +17 -0
  45. data/lib/cypher_dsl/list_operator.rb +24 -0
  46. data/lib/cypher_dsl/list_predicate.rb +32 -0
  47. data/lib/cypher_dsl/literal.rb +29 -0
  48. data/lib/cypher_dsl/map_expression.rb +32 -0
  49. data/lib/cypher_dsl/map_projection.rb +58 -0
  50. data/lib/cypher_dsl/merge_action.rb +11 -0
  51. data/lib/cypher_dsl/named_path.rb +59 -0
  52. data/lib/cypher_dsl/no_condition.rb +14 -0
  53. data/lib/cypher_dsl/node.rb +83 -0
  54. data/lib/cypher_dsl/operation.rb +22 -0
  55. data/lib/cypher_dsl/parameter.rb +40 -0
  56. data/lib/cypher_dsl/pattern_comprehension.rb +31 -0
  57. data/lib/cypher_dsl/pattern_expression.rb +16 -0
  58. data/lib/cypher_dsl/property.rb +24 -0
  59. data/lib/cypher_dsl/quantified_path_pattern.rb +37 -0
  60. data/lib/cypher_dsl/raw_expression.rb +15 -0
  61. data/lib/cypher_dsl/reduction.rb +33 -0
  62. data/lib/cypher_dsl/relationship.rb +97 -0
  63. data/lib/cypher_dsl/relationship_chain.rb +44 -0
  64. data/lib/cypher_dsl/relationship_pattern_condition.rb +14 -0
  65. data/lib/cypher_dsl/renderer/configuration.rb +66 -0
  66. data/lib/cypher_dsl/renderer/dialect.rb +20 -0
  67. data/lib/cypher_dsl/renderer/escaper.rb +31 -0
  68. data/lib/cypher_dsl/renderer/visitor/expressions.rb +274 -0
  69. data/lib/cypher_dsl/renderer/visitor/patterns.rb +125 -0
  70. data/lib/cypher_dsl/renderer/visitor.rb +298 -0
  71. data/lib/cypher_dsl/renderer.rb +28 -0
  72. data/lib/cypher_dsl/sort_item.rb +17 -0
  73. data/lib/cypher_dsl/standalone_call.rb +61 -0
  74. data/lib/cypher_dsl/statement.rb +56 -0
  75. data/lib/cypher_dsl/statement_builder.rb +294 -0
  76. data/lib/cypher_dsl/statement_catalog.rb +25 -0
  77. data/lib/cypher_dsl/subquery_with.rb +15 -0
  78. data/lib/cypher_dsl/symbolic_name.rb +48 -0
  79. data/lib/cypher_dsl/unary_operation.rb +16 -0
  80. data/lib/cypher_dsl/union_query.rb +29 -0
  81. data/lib/cypher_dsl/version.rb +5 -0
  82. data/lib/cypher_dsl.rb +13 -0
  83. 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