cypher-dsl 0.1.0.alpha.1 → 0.1.0.alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/README.md +66 -4
  4. data/lib/cypher_dsl/clauses/load_csv.rb +8 -2
  5. data/lib/cypher_dsl/clauses/match.rb +2 -2
  6. data/lib/cypher_dsl/clauses/raw.rb +9 -0
  7. data/lib/cypher_dsl/clauses.rb +11 -0
  8. data/lib/cypher_dsl/comparison.rb +6 -0
  9. data/lib/cypher_dsl/condition.rb +1 -1
  10. data/lib/cypher_dsl/count_expression.rb +7 -3
  11. data/lib/cypher_dsl/cypher.rb +80 -19
  12. data/lib/cypher_dsl/expression.rb +3 -0
  13. data/lib/cypher_dsl/function_invocation.rb +7 -0
  14. data/lib/cypher_dsl/functions.rb +11 -1
  15. data/lib/cypher_dsl/has_label_condition.rb +1 -1
  16. data/lib/cypher_dsl/hint.rb +71 -0
  17. data/lib/cypher_dsl/label_operation.rb +11 -3
  18. data/lib/cypher_dsl/labels.rb +66 -0
  19. data/lib/cypher_dsl/literal.rb +24 -2
  20. data/lib/cypher_dsl/named_path.rb +6 -3
  21. data/lib/cypher_dsl/node.rb +15 -4
  22. data/lib/cypher_dsl/property.rb +9 -2
  23. data/lib/cypher_dsl/property_exists_condition.rb +15 -0
  24. data/lib/cypher_dsl/raw_expression.rb +76 -3
  25. data/lib/cypher_dsl/relationship.rb +8 -1
  26. data/lib/cypher_dsl/relationship_chain.rb +3 -0
  27. data/lib/cypher_dsl/renderer/configuration.rb +61 -4
  28. data/lib/cypher_dsl/renderer/dialect.rb +24 -7
  29. data/lib/cypher_dsl/renderer/escaper.rb +10 -7
  30. data/lib/cypher_dsl/renderer/generated_name_resolver.rb +110 -0
  31. data/lib/cypher_dsl/renderer/name_resolver.rb +35 -0
  32. data/lib/cypher_dsl/renderer/visitor/conditions.rb +74 -0
  33. data/lib/cypher_dsl/renderer/visitor/expressions.rb +40 -40
  34. data/lib/cypher_dsl/renderer/visitor/label_rendering.rb +84 -0
  35. data/lib/cypher_dsl/renderer/visitor/patterns.rb +18 -29
  36. data/lib/cypher_dsl/renderer/visitor.rb +106 -29
  37. data/lib/cypher_dsl/renderer.rb +11 -5
  38. data/lib/cypher_dsl/search.rb +91 -0
  39. data/lib/cypher_dsl/standalone_call.rb +0 -6
  40. data/lib/cypher_dsl/statement.rb +4 -0
  41. data/lib/cypher_dsl/statement_builder.rb +81 -15
  42. data/lib/cypher_dsl/statement_catalog.rb +40 -6
  43. data/lib/cypher_dsl/subquery_with.rb +1 -0
  44. data/lib/cypher_dsl/symbolic_name.rb +1 -1
  45. data/lib/cypher_dsl/using_periodic_commit.rb +11 -0
  46. data/lib/cypher_dsl/vector_literal.rb +41 -0
  47. data/lib/cypher_dsl/version.rb +1 -1
  48. metadata +13 -3
  49. data/lib/cypher_dsl/label_expression.rb +0 -40
@@ -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
@@ -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 lists.
5
- # Hashes become a {MapExpression}; expressions are returned unchanged.
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
- attr_reader :keyword
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
- new("SHORTEST #{count}#{' GROUPS' if groups}")
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
 
@@ -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
- def property(*names) = Property.new(required_symbolic_name, names)
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(self, '+=', Expression.coerce(expression))
50
- def set(expression) = Operation.new(self, '=', Expression.coerce(expression))
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
@@ -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
- attr_reader :content
13
+ PLACEHOLDER = /((\\?\$(\w+))(?:\s*|$))/
14
+ EXPRESSION_PLACEHOLDER = '$E'
15
+ private_constant :PLACEHOLDER, :EXPRESSION_PLACEHOLDER
9
16
 
10
- def initialize(content)
11
- @content = content.to_s
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
- def property(*names) = Property.new(required_symbolic_name, names)
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
- attr_reader :always_escape_names, :dialect, :pretty_print, :indent_size, :indent_style
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::NEO4J_5_23, pretty_print: false, indent_size: 2,
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
- DEFAULT = new
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, `elementId(x)` rendered as `toString(id(x))`.
8
- # - NEO4J_5: `CALL { WITH imports … }` subqueries, native `elementId`.
9
- # - NEO4J_5_23 (default, Neo4j 5.23+): `CALL (imports) { … }` subqueries.
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 # rubocop:disable Naming/VariableNumber
14
- NEO4J_5_DEFAULT_CYPHER = NEO4J_5_23
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].freeze
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
- ESCAPED = /\A`.*`\z/m
12
+ UNICODE_BACKTICK = "#{92.chr}u0060".freeze
11
13
 
12
14
  module_function
13
15
 
14
- # Always escapes, unless the name already is.
16
+ # Always escapes.
15
17
  def escape(name)
16
- return name if ESCAPED.match?(name)
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