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
data/lib/cypher_dsl/literal.rb
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
3
6
|
module CypherDSL
|
|
4
|
-
# A literal value rendered inline: strings, numbers, booleans, NULL and
|
|
5
|
-
# Hashes become a {MapExpression}
|
|
7
|
+
# A literal value rendered inline: strings, numbers, booleans, NULL, dates and
|
|
8
|
+
# times. Hashes become a {MapExpression}, arrays a {ListExpression}; expressions
|
|
9
|
+
# are returned unchanged.
|
|
6
10
|
class Literal
|
|
7
11
|
include Expression
|
|
8
12
|
|
|
13
|
+
SUPPORTED_TYPES = [String, Numeric, TrueClass, FalseClass, NilClass, Date, Time].freeze
|
|
14
|
+
|
|
9
15
|
attr_reader :value
|
|
10
16
|
|
|
11
17
|
def self.of(value)
|
|
@@ -13,15 +19,31 @@ module CypherDSL
|
|
|
13
19
|
when Expression then value
|
|
14
20
|
when Hash then MapExpression.of(value)
|
|
15
21
|
when Array then ListExpression.of(value)
|
|
22
|
+
when true then Literal::TRUE
|
|
23
|
+
when false then Literal::FALSE
|
|
24
|
+
when nil then Literal::NULL
|
|
16
25
|
else new(value)
|
|
17
26
|
end
|
|
18
27
|
end
|
|
19
28
|
|
|
29
|
+
# `'…'`, with backslashes and quotes escaped; `nil` quotes to `''`.
|
|
30
|
+
def self.quote(string) = "'#{escape_string(string)}'"
|
|
31
|
+
|
|
32
|
+
# The content of a string literal: backslashes, single and double quotes get a backslash.
|
|
33
|
+
def self.escape_string(string) = string&.gsub(/['"\\]/) { |character| "\\#{character}" }
|
|
34
|
+
|
|
20
35
|
def initialize(value)
|
|
36
|
+
unless SUPPORTED_TYPES.any? { |type| value.is_a?(type) }
|
|
37
|
+
raise ArgumentError, "Unsupported literal type: #{value.class}"
|
|
38
|
+
end
|
|
39
|
+
|
|
21
40
|
@value = value.frozen? ? value : value.dup.freeze
|
|
22
41
|
freeze
|
|
23
42
|
end
|
|
24
43
|
|
|
44
|
+
# The literal as Cypher, as the Java `Literal#asString`.
|
|
45
|
+
def as_string = to_s
|
|
46
|
+
|
|
25
47
|
TRUE = new(true)
|
|
26
48
|
FALSE = new(false)
|
|
27
49
|
NULL = new(nil)
|
|
@@ -40,16 +40,19 @@ module CypherDSL
|
|
|
40
40
|
|
|
41
41
|
# `Cypher.any_shortest` and friends: a path selector waiting for `named`.
|
|
42
42
|
class Selector
|
|
43
|
-
|
|
43
|
+
# `legacy_function` is the pre-5.21 spelling (`shortestPath`, `allShortestPaths`), if any.
|
|
44
|
+
attr_reader :keyword, :legacy_function
|
|
44
45
|
|
|
45
46
|
def self.shortest_k(count, groups: false)
|
|
46
47
|
raise ArgumentError, 'The path count needs to be greater than 0.' unless count.positive?
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
legacy_function = count == 1 && !groups ? 'shortestPath' : nil
|
|
50
|
+
new("SHORTEST #{count}#{' GROUPS' if groups}", legacy_function:)
|
|
49
51
|
end
|
|
50
52
|
|
|
51
|
-
def initialize(keyword)
|
|
53
|
+
def initialize(keyword, legacy_function: nil)
|
|
52
54
|
@keyword = keyword
|
|
55
|
+
@legacy_function = legacy_function
|
|
53
56
|
freeze
|
|
54
57
|
end
|
|
55
58
|
|
data/lib/cypher_dsl/node.rb
CHANGED
|
@@ -40,14 +40,19 @@ module CypherDSL
|
|
|
40
40
|
def name_hint = labels.first.is_a?(String) ? labels.first.downcase : 'n'
|
|
41
41
|
alias as_expression required_symbolic_name
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
# `n.prop`; the property keeps this node as its container (index hints need the label),
|
|
44
|
+
# the name is settled now so generated names keep their order.
|
|
45
|
+
def property(*names)
|
|
46
|
+
required_symbolic_name
|
|
47
|
+
Property.new(self, names)
|
|
48
|
+
end
|
|
44
49
|
|
|
45
50
|
# The condition `n:Label`, checking the node for the given labels.
|
|
46
51
|
def has_labels(*labels) = HasLabelCondition.new(self, labels)
|
|
47
52
|
|
|
48
|
-
# `n += {…}` and `n = {…}`, for use in SET.
|
|
49
|
-
def mutate(expression) = Operation.new(
|
|
50
|
-
def set(expression) = Operation.new(
|
|
53
|
+
# `n += {…}` and `n = {…}`, for use in SET; the node must be named.
|
|
54
|
+
def mutate(expression) = Operation.new(named_for_update, '+=', Expression.coerce(expression))
|
|
55
|
+
def set(expression) = Operation.new(named_for_update, '=', Expression.coerce(expression))
|
|
51
56
|
|
|
52
57
|
# The map projection `n{…}`, built on the (possibly generated) name.
|
|
53
58
|
def project(*entries) = MapProjection.new(required_symbolic_name, entries.flatten)
|
|
@@ -76,6 +81,12 @@ module CypherDSL
|
|
|
76
81
|
|
|
77
82
|
private
|
|
78
83
|
|
|
84
|
+
def named_for_update
|
|
85
|
+
raise ArgumentError, 'A property container must be named to be mutated.' unless named?
|
|
86
|
+
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
79
90
|
def with(**changes)
|
|
80
91
|
Node.new(labels:, symbolic_name: @symbolic_name, properties:, where_condition:, **changes)
|
|
81
92
|
end
|
data/lib/cypher_dsl/property.rb
CHANGED
|
@@ -6,18 +6,25 @@ module CypherDSL
|
|
|
6
6
|
class Property
|
|
7
7
|
include Expression
|
|
8
8
|
|
|
9
|
-
attr_reader :container, :names
|
|
9
|
+
attr_reader :container, :names, :external_reference
|
|
10
10
|
|
|
11
|
-
def initialize(container, names)
|
|
11
|
+
def initialize(container, names, external_reference: nil)
|
|
12
12
|
@container = container
|
|
13
13
|
@names = names.map { |name| name.is_a?(Expression) ? name : name.to_s }.freeze
|
|
14
14
|
raise ArgumentError, 'A property needs at least one name.' if @names.empty?
|
|
15
15
|
|
|
16
|
+
@external_reference = external_reference
|
|
16
17
|
freeze
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def nested? = names.size > 1
|
|
20
21
|
|
|
22
|
+
# The dotted name (`address.city`), or the external reference if one was given.
|
|
23
|
+
def name = external_reference || names.join('.')
|
|
24
|
+
|
|
25
|
+
# The same property known under another name, e.g. the attribute it maps to.
|
|
26
|
+
def referenced_as(reference) = Property.new(container, names, external_reference: reference.to_s)
|
|
27
|
+
|
|
21
28
|
# `n.prop = value`, for use in SET.
|
|
22
29
|
def to(expression) = Operation.new(self, '=', Expression.coerce(expression))
|
|
23
30
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# `Cypher.exists(n.prop)`: `exists(n.prop)` on Neo4j 4, `n.prop IS NOT NULL`
|
|
5
|
+
# from Neo4j 5 on (and `n.prop IS NULL` when negated).
|
|
6
|
+
class PropertyExistsCondition < Condition
|
|
7
|
+
attr_reader :property
|
|
8
|
+
|
|
9
|
+
def initialize(property)
|
|
10
|
+
super()
|
|
11
|
+
@property = property
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -2,14 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
module CypherDSL
|
|
4
4
|
# A fragment of Cypher text rendered verbatim: the escape hatch, like `Arel.sql`.
|
|
5
|
+
#
|
|
6
|
+
# `RawExpression.create(format, *arguments)` mirrors `Cypher.raw(format, args…)`:
|
|
7
|
+
# every `$E` in the format is replaced by the next argument (expressions as they are,
|
|
8
|
+
# anything else as a literal); `\$E` stands for a literal `$E`. A `$name` matching a
|
|
9
|
+
# named parameter among the arguments is bound to that parameter.
|
|
5
10
|
class RawExpression
|
|
6
11
|
include Expression
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
PLACEHOLDER = /((\\?\$(\w+))(?:\s*|$))/
|
|
14
|
+
EXPRESSION_PLACEHOLDER = '$E'
|
|
15
|
+
private_constant :PLACEHOLDER, :EXPRESSION_PLACEHOLDER
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
# Strings and expressions, in order.
|
|
18
|
+
attr_reader :parts
|
|
19
|
+
|
|
20
|
+
def self.create(format, *mixed_arguments)
|
|
21
|
+
raise ArgumentError, 'Cannot create a raw literal without a format.' if format.nil? || format.strip.empty?
|
|
22
|
+
|
|
23
|
+
new(Parser.new(format, mixed_arguments).parts)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `parts` is a string or a list of strings and expressions.
|
|
27
|
+
def initialize(parts)
|
|
28
|
+
@parts = Array(parts).filter_map do |part|
|
|
29
|
+
next part unless part.is_a?(String)
|
|
30
|
+
|
|
31
|
+
part.gsub('\$E', EXPRESSION_PLACEHOLDER) unless part.empty?
|
|
32
|
+
end.freeze
|
|
12
33
|
freeze
|
|
13
34
|
end
|
|
35
|
+
|
|
36
|
+
# The text, with expressions in their default rendering.
|
|
37
|
+
def content = parts.join
|
|
38
|
+
|
|
39
|
+
# Splits a format into text and the expressions bound to its placeholders.
|
|
40
|
+
class Parser
|
|
41
|
+
def initialize(format, mixed_arguments)
|
|
42
|
+
@format = format
|
|
43
|
+
@parameters = mixed_arguments.grep(Parameter).reject(&:anonymous?).to_h { |p| [p.name, p] }
|
|
44
|
+
@positional = mixed_arguments.dup
|
|
45
|
+
@matches = format.enum_for(:scan, PLACEHOLDER).map { Regexp.last_match }
|
|
46
|
+
# Placeholders naming an actual parameter take it with precedence over the positional ones.
|
|
47
|
+
@matches.each { |match| @positional.delete(@parameters[match[3]]) if @parameters.key?(match[3]) }
|
|
48
|
+
@parts = []
|
|
49
|
+
@index = 0
|
|
50
|
+
@count = 0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def parts
|
|
54
|
+
@matches.each { |match| consume(match) }
|
|
55
|
+
raise ArgumentError, "Too many arguments for the raw literal format `#{@format}`." if @count < @positional.size
|
|
56
|
+
|
|
57
|
+
@parts + [@format[@index..]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def consume(match)
|
|
63
|
+
if match[2] == EXPRESSION_PLACEHOLDER
|
|
64
|
+
bind(match, next_positional)
|
|
65
|
+
elsif @parameters.key?(match[3])
|
|
66
|
+
bind(match, @parameters[match[3]])
|
|
67
|
+
else
|
|
68
|
+
@parts << @format[@index...match.end(0)]
|
|
69
|
+
@index = match.end(0)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def bind(match, expression)
|
|
74
|
+
@parts << @format[@index...match.begin(2)] << expression
|
|
75
|
+
@index = match.end(2)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def next_positional
|
|
79
|
+
raise ArgumentError, "Too few arguments for the raw literal format `#{@format}`." if @count >= @positional.size
|
|
80
|
+
|
|
81
|
+
argument = @positional[@count]
|
|
82
|
+
@count += 1
|
|
83
|
+
Expression.coerce(argument)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
private_constant :Parser
|
|
14
87
|
end
|
|
15
88
|
end
|
|
@@ -32,6 +32,9 @@ module CypherDSL
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def initialize(left, direction, right, details)
|
|
35
|
+
raise ArgumentError, 'Left node is required.' if left.nil?
|
|
36
|
+
raise ArgumentError, 'Right node is required.' if right.nil?
|
|
37
|
+
|
|
35
38
|
@left = left
|
|
36
39
|
@direction = direction
|
|
37
40
|
@right = right
|
|
@@ -72,7 +75,11 @@ module CypherDSL
|
|
|
72
75
|
# The pattern as a predicate, `WHERE (a)-[:X]->(b)`.
|
|
73
76
|
def as_condition = RelationshipPatternCondition.new(self)
|
|
74
77
|
|
|
75
|
-
|
|
78
|
+
# `r.prop`; the property keeps this relationship as its container (index hints need the type).
|
|
79
|
+
def property(*names)
|
|
80
|
+
required_symbolic_name
|
|
81
|
+
Property.new(self, names)
|
|
82
|
+
end
|
|
76
83
|
|
|
77
84
|
# `r += {…}` and `r = {…}`, for use in SET.
|
|
78
85
|
def mutate(expression) = Operation.new(self, '+=', Expression.coerce(expression))
|
|
@@ -31,6 +31,9 @@ module CypherDSL
|
|
|
31
31
|
# The chain as a predicate.
|
|
32
32
|
def as_condition = RelationshipPatternCondition.new(self)
|
|
33
33
|
|
|
34
|
+
# The pattern as Cypher, names escaped only when necessary.
|
|
35
|
+
def to_s = Renderer.plain.render_fragment(self)
|
|
36
|
+
|
|
34
37
|
private
|
|
35
38
|
|
|
36
39
|
def add(direction, other, types)
|
|
@@ -9,13 +9,22 @@ module CypherDSL
|
|
|
9
9
|
# - `dialect`: one of {Dialect::ALL}.
|
|
10
10
|
# - `pretty_print`: one clause per line, indented subqueries, conditions and maps.
|
|
11
11
|
# - `indent_size` / `indent_style` (`:space` or `:tab`): the indentation unit.
|
|
12
|
+
# - `disable_dynamic_labels`: render label expressions statically even on dialects
|
|
13
|
+
# that support `n:$($labels)`.
|
|
14
|
+
# - `generated_names`: rewrite names while rendering (`v0`, `v1`, … and `p0`, `p1`, …);
|
|
15
|
+
# `true` for all of {GENERATED_NAMES}, or a subset of those flags.
|
|
12
16
|
class Configuration
|
|
13
|
-
|
|
17
|
+
# The Java `GeneratedNames` enum: which names get rewritten. `reuse_aliases` makes an alias
|
|
18
|
+
# that is defined again reuse its generated name instead of getting a new one.
|
|
19
|
+
GENERATED_NAMES = %i[entity_names parameter_names all_aliases internal_aliases_only reuse_aliases].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :always_escape_names, :dialect, :pretty_print, :indent_size, :indent_style, :disable_dynamic_labels,
|
|
22
|
+
:generated_names
|
|
14
23
|
|
|
15
24
|
# `always_escape_names` defaults to true, except when pretty printing, where
|
|
16
25
|
# names are escaped only when necessary (as upstream does).
|
|
17
|
-
def initialize(always_escape_names: nil, dialect: Dialect::
|
|
18
|
-
indent_style: :space)
|
|
26
|
+
def initialize(always_escape_names: nil, dialect: Dialect::NEO4J_5_DEFAULT_CYPHER, pretty_print: false,
|
|
27
|
+
indent_size: 2, indent_style: :space, disable_dynamic_labels: false, generated_names: false)
|
|
19
28
|
raise ArgumentError, "Unknown dialect #{dialect.inspect}" unless Dialect::ALL.include?(dialect)
|
|
20
29
|
raise ArgumentError, "Unknown indent style #{indent_style.inspect}" unless %i[space tab].include?(indent_style)
|
|
21
30
|
|
|
@@ -24,10 +33,25 @@ module CypherDSL
|
|
|
24
33
|
@pretty_print = pretty_print
|
|
25
34
|
@indent_size = indent_size
|
|
26
35
|
@indent_style = indent_style
|
|
36
|
+
@disable_dynamic_labels = disable_dynamic_labels
|
|
37
|
+
@generated_names = generated_name_flags(generated_names)
|
|
27
38
|
freeze
|
|
28
39
|
end
|
|
29
40
|
|
|
30
|
-
|
|
41
|
+
def use_generated_names? = !generated_names.empty?
|
|
42
|
+
|
|
43
|
+
# Every option by name; configurations with equal options are equal.
|
|
44
|
+
def to_h
|
|
45
|
+
{ always_escape_names:, dialect:, pretty_print:, indent_size:, indent_style:, disable_dynamic_labels:,
|
|
46
|
+
generated_names: }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ==(other) = other.is_a?(Configuration) && other.to_h == to_h
|
|
50
|
+
alias eql? ==
|
|
51
|
+
def hash = to_h.hash
|
|
52
|
+
|
|
53
|
+
# The default configuration, as used by `Statement#cypher`.
|
|
54
|
+
def self.default = @default ||= new
|
|
31
55
|
|
|
32
56
|
# Mirrors `Configuration.newConfig()…build()`.
|
|
33
57
|
def self.new_config = Builder.new
|
|
@@ -38,6 +62,18 @@ module CypherDSL
|
|
|
38
62
|
# `CALL { WITH imports … }` (older dialects) rather than `CALL (imports) { … }`.
|
|
39
63
|
def legacy_subquery_syntax? = Dialect::LEGACY_SUBQUERY_SYNTAX.include?(dialect)
|
|
40
64
|
|
|
65
|
+
# `shortestPath()` / `allShortestPaths()` functions rather than `SHORTEST 1` / `ALL SHORTEST` selectors.
|
|
66
|
+
def legacy_path_selectors? = Dialect::LEGACY_PATH_SELECTORS.include?(dialect)
|
|
67
|
+
|
|
68
|
+
# `exists(n.prop)` and `distance()` rather than `n.prop IS NOT NULL` and `point.distance()`.
|
|
69
|
+
def legacy_functions? = dialect == Dialect::NEO4J_4
|
|
70
|
+
|
|
71
|
+
# `n:$($labels)` is available and wanted.
|
|
72
|
+
def dynamic_labels? = Dialect::DYNAMIC_LABELS.include?(dialect) && !disable_dynamic_labels
|
|
73
|
+
|
|
74
|
+
# The `CYPHER 5 ` / `CYPHER 25 ` statement prefix of the dialect, if any.
|
|
75
|
+
def prefix = Dialect::PREFIXES[dialect]
|
|
76
|
+
|
|
41
77
|
def indent_unit = indent_style == :tab ? "\t" : ' ' * indent_size
|
|
42
78
|
|
|
43
79
|
# Fluent builder; each setter returns the builder.
|
|
@@ -52,6 +88,13 @@ module CypherDSL
|
|
|
52
88
|
def with_indent_size(size) = option(:indent_size, size)
|
|
53
89
|
def with_indent_style(style) = option(:indent_style, style)
|
|
54
90
|
|
|
91
|
+
def disable_dynamic_labels(value = true) # rubocop:disable Style/OptionalBooleanParameter -- Java signature
|
|
92
|
+
option(:disable_dynamic_labels, value)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `true`, `false` or a list of {Configuration::GENERATED_NAMES} flags.
|
|
96
|
+
def with_generated_names(value) = option(:generated_names, value)
|
|
97
|
+
|
|
55
98
|
def build = Configuration.new(**@options)
|
|
56
99
|
|
|
57
100
|
private
|
|
@@ -61,6 +104,20 @@ module CypherDSL
|
|
|
61
104
|
self
|
|
62
105
|
end
|
|
63
106
|
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def generated_name_flags(value)
|
|
111
|
+
flags = case value
|
|
112
|
+
when true then GENERATED_NAMES
|
|
113
|
+
when false, nil then []
|
|
114
|
+
else value.to_a.map(&:to_sym)
|
|
115
|
+
end
|
|
116
|
+
unknown = flags - GENERATED_NAMES
|
|
117
|
+
raise ArgumentError, "Unknown generated name flags #{unknown.inspect}" unless unknown.empty?
|
|
118
|
+
|
|
119
|
+
flags.to_set.freeze
|
|
120
|
+
end
|
|
64
121
|
end
|
|
65
122
|
end
|
|
66
123
|
end
|
|
@@ -2,19 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
module CypherDSL
|
|
4
4
|
class Renderer
|
|
5
|
-
# Target Cypher dialects.
|
|
5
|
+
# Target Cypher dialects, mirroring the Java enum.
|
|
6
6
|
#
|
|
7
|
-
# - NEO4J_4: `CALL { WITH imports … }` subqueries, `
|
|
8
|
-
#
|
|
9
|
-
# -
|
|
7
|
+
# - NEO4J_4: `CALL { WITH imports … }` subqueries, `shortestPath()` functions,
|
|
8
|
+
# `exists(n.prop)`, `distance()`, `elementId(x)` rendered as `toString(id(x))`.
|
|
9
|
+
# - NEO4J_5: as above with native `elementId`, `point.distance()` and `n.prop IS NOT NULL`.
|
|
10
|
+
# - NEO4J_5_23: `CALL (imports) { … }` subqueries and `SHORTEST k` path selectors.
|
|
11
|
+
# - NEO4J_5_26: adds dynamic labels (`n:$($labels)`) and the `CYPHER 5` prefix.
|
|
12
|
+
# - NEO4J_5_DEFAULT_CYPHER (default): like NEO4J_5_26 without the prefix.
|
|
13
|
+
# - NEO4J_5_CYPHER_5 / NEO4J_5_CYPHER_25: with an explicit `CYPHER 5` / `CYPHER 25` prefix.
|
|
14
|
+
# - NEO4J_2025: like NEO4J_5_23.
|
|
10
15
|
module Dialect
|
|
16
|
+
# rubocop:disable Naming/VariableNumber
|
|
11
17
|
NEO4J_4 = :neo4j4
|
|
12
18
|
NEO4J_5 = :neo4j5
|
|
13
|
-
NEO4J_5_23 = :neo4j5_23
|
|
14
|
-
|
|
19
|
+
NEO4J_5_23 = :neo4j5_23
|
|
20
|
+
NEO4J_5_26 = :neo4j5_26
|
|
21
|
+
NEO4J_5_DEFAULT_CYPHER = :neo4j5_default_cypher
|
|
22
|
+
NEO4J_5_CYPHER_5 = :neo4j5_cypher_5
|
|
23
|
+
NEO4J_5_CYPHER_25 = :neo4j5_cypher_25
|
|
24
|
+
NEO4J_2025 = :neo4j2025
|
|
25
|
+
# rubocop:enable Naming/VariableNumber
|
|
15
26
|
|
|
16
|
-
ALL = [NEO4J_4, NEO4J_5, NEO4J_5_23
|
|
27
|
+
ALL = [NEO4J_4, NEO4J_5, NEO4J_5_23, NEO4J_5_26, NEO4J_5_DEFAULT_CYPHER, NEO4J_5_CYPHER_5, NEO4J_5_CYPHER_25,
|
|
28
|
+
NEO4J_2025].freeze
|
|
17
29
|
LEGACY_SUBQUERY_SYNTAX = [NEO4J_4, NEO4J_5].freeze
|
|
30
|
+
LEGACY_PATH_SELECTORS = [NEO4J_4, NEO4J_5].freeze
|
|
31
|
+
DYNAMIC_LABELS = [NEO4J_5_26, NEO4J_5_DEFAULT_CYPHER, NEO4J_5_CYPHER_5, NEO4J_5_CYPHER_25].freeze
|
|
32
|
+
PREFIXES = {
|
|
33
|
+
NEO4J_5_26 => 'CYPHER 5 ', NEO4J_5_CYPHER_5 => 'CYPHER 5 ', NEO4J_5_CYPHER_25 => 'CYPHER 25 '
|
|
34
|
+
}.freeze
|
|
18
35
|
end
|
|
19
36
|
end
|
|
20
37
|
end
|
|
@@ -2,24 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
module CypherDSL
|
|
4
4
|
class Renderer
|
|
5
|
-
# Backtick escaping of schema names and identifiers
|
|
5
|
+
# Backtick escaping of schema names and identifiers, following the upstream
|
|
6
|
+
# rules: the text ``` stands for a backtick, doubled backticks are
|
|
7
|
+
# collapsed first, then every backtick is doubled and the name wrapped.
|
|
6
8
|
module Escaper
|
|
7
9
|
IDENTIFIER = /\A[\p{L}_][\p{L}\p{N}_]*\z/
|
|
8
10
|
# Parameter names may be dotted (`$point.distance`) without escaping.
|
|
9
11
|
PARAMETER_NAME = /\A[\p{L}_][\p{L}\p{N}_.]*\z/
|
|
10
|
-
|
|
12
|
+
UNICODE_BACKTICK = "#{92.chr}u0060".freeze
|
|
11
13
|
|
|
12
14
|
module_function
|
|
13
15
|
|
|
14
|
-
# Always escapes
|
|
16
|
+
# Always escapes.
|
|
15
17
|
def escape(name)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"`#{name.gsub('`', '``')}`"
|
|
18
|
+
normalized = name.gsub(UNICODE_BACKTICK, '`').gsub('``', '`')
|
|
19
|
+
"`#{normalized.gsub('`', '``')}`"
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
# Escapes only names that are not plain identifiers
|
|
22
|
+
# Escapes only names that are not plain identifiers; `nil` stays `nil`.
|
|
22
23
|
def escape_if_necessary(name)
|
|
24
|
+
return nil if name.nil?
|
|
25
|
+
|
|
23
26
|
IDENTIFIER.match?(name) ? name : escape(name)
|
|
24
27
|
end
|
|
25
28
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
class Renderer
|
|
5
|
+
# Rewrites names to `v0`, `v1`, … per scope and parameters to `p0`, `p1`, …,
|
|
6
|
+
# controlled by the flags of `Configuration::GENERATED_NAMES` (the Java
|
|
7
|
+
# `GeneratedNames` enum).
|
|
8
|
+
class GeneratedNameResolver < NameResolver
|
|
9
|
+
def initialize(flags)
|
|
10
|
+
super()
|
|
11
|
+
@flags = flags.to_set
|
|
12
|
+
@parameter_count = 0
|
|
13
|
+
@counters = []
|
|
14
|
+
@lookups = []
|
|
15
|
+
@used = []
|
|
16
|
+
enter_scope([])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# `imports` are the identifiables visible inside the new scope, or `:all`.
|
|
20
|
+
def enter_scope(imports)
|
|
21
|
+
outer = @lookups.last
|
|
22
|
+
lookup = {}
|
|
23
|
+
used = Set.new
|
|
24
|
+
if outer
|
|
25
|
+
lookup = imports == :all ? outer.dup : imports.to_h { |import| [key_of(import), outer[key_of(import)]] }
|
|
26
|
+
lookup.compact!
|
|
27
|
+
used.merge(outer.values)
|
|
28
|
+
end
|
|
29
|
+
@counters << 0
|
|
30
|
+
@lookups << lookup
|
|
31
|
+
@used << used
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `exports` are the identifiables the scope returns to its outer scope, or `:all`.
|
|
35
|
+
def leave_scope(exports)
|
|
36
|
+
@counters.pop
|
|
37
|
+
inner = @lookups.pop
|
|
38
|
+
@used.pop
|
|
39
|
+
outer = @lookups.last
|
|
40
|
+
if exports == :all
|
|
41
|
+
outer.merge!(inner)
|
|
42
|
+
else
|
|
43
|
+
exports.each { |export| outer[key_of(export)] = exported_name(export, inner) }
|
|
44
|
+
end
|
|
45
|
+
@used.last.merge(inner.values)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resolve(symbolic_name, in_entity: false)
|
|
49
|
+
key = key_of(symbolic_name)
|
|
50
|
+
lookup = @lookups.last
|
|
51
|
+
if !flag?(:entity_names) || (!in_entity && !flag?(:all_aliases) && !flag?(:internal_aliases_only))
|
|
52
|
+
return lookup[key] if lookup.key?(key)
|
|
53
|
+
|
|
54
|
+
return super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
lookup[key] ||= new_name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resolve_alias(aliased, define: false, in_last_return: false)
|
|
61
|
+
return super unless flag?(:all_aliases) || (flag?(:internal_aliases_only) && !in_last_return)
|
|
62
|
+
|
|
63
|
+
key = key_of(aliased)
|
|
64
|
+
lookup = @lookups.last
|
|
65
|
+
return lookup[key] ||= new_name if flag?(:reuse_aliases)
|
|
66
|
+
return lookup.fetch(key) { super } unless define
|
|
67
|
+
|
|
68
|
+
lookup[key] = new_name
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolve_parameter(parameter)
|
|
72
|
+
return super if !flag?(:parameter_names) || parameter.anonymous?
|
|
73
|
+
|
|
74
|
+
@lookups.last[[:parameter, parameter.name]] ||= begin
|
|
75
|
+
name = "p#{@parameter_count}"
|
|
76
|
+
@parameter_count += 1
|
|
77
|
+
name
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def flag?(flag) = @flags.include?(flag)
|
|
84
|
+
|
|
85
|
+
def key_of(object)
|
|
86
|
+
case object
|
|
87
|
+
when AliasedExpression then object.alias_name
|
|
88
|
+
when Node, Relationship then object.required_symbolic_name
|
|
89
|
+
else object
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def exported_name(export, inner)
|
|
94
|
+
key = key_of(export)
|
|
95
|
+
return inner[key] if inner.key?(key)
|
|
96
|
+
|
|
97
|
+
context_name(key)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def new_name
|
|
101
|
+
used = @used.last
|
|
102
|
+
loop do
|
|
103
|
+
name = "v#{@counters.last}"
|
|
104
|
+
@counters[-1] += 1
|
|
105
|
+
return name unless used.include?(name)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
class Renderer
|
|
5
|
+
# Resolves symbolic names, aliases and parameter names while rendering.
|
|
6
|
+
# The base strategy keeps the given names and invents `hint001`-style names
|
|
7
|
+
# for elements that were referenced without ever being named.
|
|
8
|
+
class NameResolver
|
|
9
|
+
def initialize
|
|
10
|
+
@context_names = {}.compare_by_identity
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve(symbolic_name, in_entity: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
14
|
+
context_name(symbolic_name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def resolve_alias(aliased, define: false, in_last_return: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
18
|
+
context_name(aliased.alias_name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve_parameter(parameter) = parameter.name
|
|
22
|
+
|
|
23
|
+
def enter_scope(imports); end
|
|
24
|
+
def leave_scope(exports); end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def context_name(symbolic_name)
|
|
29
|
+
return symbolic_name.value if symbolic_name.resolved?
|
|
30
|
+
|
|
31
|
+
@context_names[symbolic_name] ||= "#{symbolic_name.hint}#{format('%03d', @context_names.size + 1)}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|