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,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A chain of relationships sharing nodes, `(a)-[]->(b)-[]->(c)`. Modifiers
|
|
5
|
+
# such as `named`, `min` or `properties` apply to the last relationship.
|
|
6
|
+
class RelationshipChain
|
|
7
|
+
attr_reader :relationships
|
|
8
|
+
|
|
9
|
+
def initialize(relationships)
|
|
10
|
+
@relationships = relationships.freeze
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def relationship_to(other, *types) = add(:ltr, other, types)
|
|
15
|
+
def relationship_from(other, *types) = add(:rtl, other, types)
|
|
16
|
+
def relationship_between(other, *types) = add(:uni, other, types)
|
|
17
|
+
|
|
18
|
+
def named(name) = update_last { |relationship| relationship.named(name) }
|
|
19
|
+
def with_properties(*keys_and_values) = update_last { it.with_properties(*keys_and_values) }
|
|
20
|
+
alias properties with_properties
|
|
21
|
+
def min(minimum) = update_last { |relationship| relationship.min(minimum) }
|
|
22
|
+
def max(maximum) = update_last { |relationship| relationship.max(maximum) }
|
|
23
|
+
def length(minimum, maximum) = update_last { |relationship| relationship.length(minimum, maximum) }
|
|
24
|
+
def unbounded = update_last(&:unbounded)
|
|
25
|
+
def where(condition) = update_last { |relationship| relationship.where(condition) }
|
|
26
|
+
def quantify_relationship(quantifier) = update_last { it.quantify_relationship(quantifier) }
|
|
27
|
+
|
|
28
|
+
# `((a)-[]->(b)-[]->(c)){1,3}`: a quantifier on the whole chain.
|
|
29
|
+
def quantify(quantifier) = QuantifiedPathPattern.new(self, quantifier)
|
|
30
|
+
|
|
31
|
+
# The chain as a predicate.
|
|
32
|
+
def as_condition = RelationshipPatternCondition.new(self)
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def add(direction, other, types)
|
|
37
|
+
RelationshipChain.new(relationships + [Relationship.create(relationships.last.right, direction, other, types)])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def update_last
|
|
41
|
+
RelationshipChain.new(relationships[0...-1] + [yield(relationships.last)])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
# A relationship pattern used as a predicate, e.g. `WHERE (a)-->(b)`.
|
|
5
|
+
class RelationshipPatternCondition < Condition
|
|
6
|
+
attr_reader :pattern
|
|
7
|
+
|
|
8
|
+
def initialize(pattern)
|
|
9
|
+
super()
|
|
10
|
+
@pattern = pattern
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
class Renderer
|
|
5
|
+
# Rendering options.
|
|
6
|
+
#
|
|
7
|
+
# - `always_escape_names`: backtick-escape labels and relationship types even
|
|
8
|
+
# when they are plain identifiers, as the Java default renderer does.
|
|
9
|
+
# - `dialect`: one of {Dialect::ALL}.
|
|
10
|
+
# - `pretty_print`: one clause per line, indented subqueries, conditions and maps.
|
|
11
|
+
# - `indent_size` / `indent_style` (`:space` or `:tab`): the indentation unit.
|
|
12
|
+
class Configuration
|
|
13
|
+
attr_reader :always_escape_names, :dialect, :pretty_print, :indent_size, :indent_style
|
|
14
|
+
|
|
15
|
+
# `always_escape_names` defaults to true, except when pretty printing, where
|
|
16
|
+
# 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)
|
|
19
|
+
raise ArgumentError, "Unknown dialect #{dialect.inspect}" unless Dialect::ALL.include?(dialect)
|
|
20
|
+
raise ArgumentError, "Unknown indent style #{indent_style.inspect}" unless %i[space tab].include?(indent_style)
|
|
21
|
+
|
|
22
|
+
@always_escape_names = always_escape_names.nil? ? !pretty_print : always_escape_names
|
|
23
|
+
@dialect = dialect
|
|
24
|
+
@pretty_print = pretty_print
|
|
25
|
+
@indent_size = indent_size
|
|
26
|
+
@indent_style = indent_style
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
DEFAULT = new
|
|
31
|
+
|
|
32
|
+
# Mirrors `Configuration.newConfig()…build()`.
|
|
33
|
+
def self.new_config = Builder.new
|
|
34
|
+
|
|
35
|
+
# Mirrors `Configuration.prettyPrinting()`: pretty printed, names escaped only when needed.
|
|
36
|
+
def self.pretty_printing = new(pretty_print: true)
|
|
37
|
+
|
|
38
|
+
# `CALL { WITH imports … }` (older dialects) rather than `CALL (imports) { … }`.
|
|
39
|
+
def legacy_subquery_syntax? = Dialect::LEGACY_SUBQUERY_SYNTAX.include?(dialect)
|
|
40
|
+
|
|
41
|
+
def indent_unit = indent_style == :tab ? "\t" : ' ' * indent_size
|
|
42
|
+
|
|
43
|
+
# Fluent builder; each setter returns the builder.
|
|
44
|
+
class Builder
|
|
45
|
+
def initialize
|
|
46
|
+
@options = {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def always_escape_names(value) = option(:always_escape_names, value)
|
|
50
|
+
def with_dialect(dialect) = option(:dialect, dialect)
|
|
51
|
+
def with_pretty_print(value) = option(:pretty_print, value)
|
|
52
|
+
def with_indent_size(size) = option(:indent_size, size)
|
|
53
|
+
def with_indent_style(style) = option(:indent_style, style)
|
|
54
|
+
|
|
55
|
+
def build = Configuration.new(**@options)
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def option(name, value)
|
|
60
|
+
@options[name] = value
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
class Renderer
|
|
5
|
+
# Target Cypher dialects.
|
|
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.
|
|
10
|
+
module Dialect
|
|
11
|
+
NEO4J_4 = :neo4j4
|
|
12
|
+
NEO4J_5 = :neo4j5
|
|
13
|
+
NEO4J_5_23 = :neo4j5_23 # rubocop:disable Naming/VariableNumber
|
|
14
|
+
NEO4J_5_DEFAULT_CYPHER = NEO4J_5_23
|
|
15
|
+
|
|
16
|
+
ALL = [NEO4J_4, NEO4J_5, NEO4J_5_23].freeze
|
|
17
|
+
LEGACY_SUBQUERY_SYNTAX = [NEO4J_4, NEO4J_5].freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
class Renderer
|
|
5
|
+
# Backtick escaping of schema names and identifiers.
|
|
6
|
+
module Escaper
|
|
7
|
+
IDENTIFIER = /\A[\p{L}_][\p{L}\p{N}_]*\z/
|
|
8
|
+
# Parameter names may be dotted (`$point.distance`) without escaping.
|
|
9
|
+
PARAMETER_NAME = /\A[\p{L}_][\p{L}\p{N}_.]*\z/
|
|
10
|
+
ESCAPED = /\A`.*`\z/m
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Always escapes, unless the name already is.
|
|
15
|
+
def escape(name)
|
|
16
|
+
return name if ESCAPED.match?(name)
|
|
17
|
+
|
|
18
|
+
"`#{name.gsub('`', '``')}`"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Escapes only names that are not plain identifiers.
|
|
22
|
+
def escape_if_necessary(name)
|
|
23
|
+
IDENTIFIER.match?(name) ? name : escape(name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def escape_parameter_name(name)
|
|
27
|
+
PARAMETER_NAME.match?(name) ? name : escape(name)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
class Renderer
|
|
5
|
+
class Visitor
|
|
6
|
+
# Rendering of expressions, conditions, operations, functions and subquery expressions.
|
|
7
|
+
module Expressions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def render_expression(expression)
|
|
11
|
+
case expression
|
|
12
|
+
when SymbolicName then Escaper.escape_if_necessary(resolve_name(expression))
|
|
13
|
+
when Node, Relationship then render_expression(expression.required_symbolic_name)
|
|
14
|
+
when Property then render_property(expression)
|
|
15
|
+
when Literal then render_literal(expression.value)
|
|
16
|
+
when Parameter then render_parameter(expression)
|
|
17
|
+
when Asterisk then '*'
|
|
18
|
+
when AliasedExpression then render_aliased_reference(expression)
|
|
19
|
+
when NamedPath then render_expression(expression.symbolic_name)
|
|
20
|
+
when RawExpression then expression.content
|
|
21
|
+
when CollectExpression
|
|
22
|
+
block('COLLECT {', '}') { render_subquery(expression.statement, expression.imports) }
|
|
23
|
+
when CountExpression then render_count(expression)
|
|
24
|
+
when FunctionInvocation then render_function_invocation(expression)
|
|
25
|
+
when SortItem then render_sort_item(expression)
|
|
26
|
+
when CaseExpression then render_case(expression)
|
|
27
|
+
when UnaryOperation then "#{expression.operator}#{render_expression(expression.expression)}"
|
|
28
|
+
when ListOperator then render_list_operator(expression)
|
|
29
|
+
else render_container_expression(expression)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_case(case_expression)
|
|
34
|
+
parts = ['CASE']
|
|
35
|
+
parts << render_expression(case_expression.subject) if case_expression.subject
|
|
36
|
+
case_expression.whens.each do |condition, value|
|
|
37
|
+
parts << "WHEN #{render_expression(condition)} THEN #{render_expression(value)}"
|
|
38
|
+
end
|
|
39
|
+
parts << "ELSE #{render_expression(case_expression.default)}" if case_expression.default
|
|
40
|
+
parts << 'END'
|
|
41
|
+
parts.join(' ')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_list_operator(operator)
|
|
45
|
+
list = render_expression(operator.list)
|
|
46
|
+
return "#{list}[#{render_expression(operator.start_index)}]" unless operator.slice
|
|
47
|
+
|
|
48
|
+
from = operator.start_index && render_expression(operator.start_index)
|
|
49
|
+
to = operator.end_index && render_expression(operator.end_index)
|
|
50
|
+
"#{list}[#{from}..#{to}]"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render_container_expression(expression)
|
|
54
|
+
case expression
|
|
55
|
+
when MapExpression then render_map(expression)
|
|
56
|
+
when ListExpression then "[#{expression.elements.map { |element| render_expression(element) }.join(', ')}]"
|
|
57
|
+
when PatternComprehension then render_pattern_comprehension(expression)
|
|
58
|
+
when ListComprehension then render_list_comprehension(expression)
|
|
59
|
+
when MapProjection then render_map_projection(expression)
|
|
60
|
+
when LabelOperation then render_labels(expression.node, expression.labels)
|
|
61
|
+
when Reduction then render_reduction(expression)
|
|
62
|
+
when PatternExpression then render_pattern_expression(expression.pattern)
|
|
63
|
+
else render_condition_or_operation(expression)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render_reduction(reduction)
|
|
68
|
+
accumulator = "#{render_expression(reduction.accumulator)} = #{render_expression(reduction.initial_value)}"
|
|
69
|
+
"reduce(#{accumulator}, #{render_expression(reduction.variable)} IN #{render_expression(reduction.list)} " \
|
|
70
|
+
"| #{render_expression(reduction.mapper)})"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render_labels(node, labels)
|
|
74
|
+
"#{render_expression(node)}#{labels.map { |label| ":#{escape_schema_name(label)}" }.join}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def render_condition_or_operation(expression)
|
|
78
|
+
case expression
|
|
79
|
+
when Comparison then render_comparison(expression)
|
|
80
|
+
when CompoundCondition then render_compound_condition(expression)
|
|
81
|
+
when ConstantCondition then expression.value.to_s
|
|
82
|
+
when ExpressionCondition then render_expression(expression.expression)
|
|
83
|
+
when ExistsCondition then render_exists(expression)
|
|
84
|
+
when RelationshipPatternCondition then render_pattern_expression(expression.pattern)
|
|
85
|
+
when ListPredicate then render_list_predicate(expression)
|
|
86
|
+
when HasLabelCondition then render_labels(expression.node, expression.labels)
|
|
87
|
+
when Operation then render_operation(expression)
|
|
88
|
+
else raise ArgumentError, "Cannot render expression #{expression.inspect}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_operation(operation)
|
|
93
|
+
left = render_expression(operation.left)
|
|
94
|
+
right = render_expression(operation.right)
|
|
95
|
+
return "#{left}^#{right}" if operation.operator == '^'
|
|
96
|
+
|
|
97
|
+
rendered = "#{left} #{operation.operator} #{right}"
|
|
98
|
+
operation.assignment? ? rendered : "(#{rendered})"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A plain relationship pattern uses the `exists()` function; quantified
|
|
102
|
+
# patterns and statements need the `EXISTS { }` subquery form.
|
|
103
|
+
def render_exists(condition)
|
|
104
|
+
body = condition.body
|
|
105
|
+
return block('EXISTS {', '}') { render_subquery(body, condition.imports) } if body.is_a?(Statement)
|
|
106
|
+
return "EXISTS { #{render_in_own_scope { render_pattern_elements(body) }} }" if body.is_a?(Array)
|
|
107
|
+
return "EXISTS { #{render_pattern_element(body)} }" if quantified?(body)
|
|
108
|
+
|
|
109
|
+
"exists(#{render_pattern_expression(body)})"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def render_count(count)
|
|
113
|
+
body = count.body
|
|
114
|
+
return block('COUNT {', '}') { render_subquery(body, count.imports) } if body.is_a?(Statement)
|
|
115
|
+
|
|
116
|
+
"COUNT { #{render_in_own_scope { render_pattern_element(body) }} }"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Renders with the current scope, discarding names introduced inside.
|
|
120
|
+
def render_in_own_scope
|
|
121
|
+
outer_scope = @visited_names.dup
|
|
122
|
+
yield
|
|
123
|
+
ensure
|
|
124
|
+
@visited_names = outer_scope
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def quantified?(pattern)
|
|
128
|
+
case pattern
|
|
129
|
+
when QuantifiedPathPattern then true
|
|
130
|
+
when Relationship then !pattern.details.quantifier.nil?
|
|
131
|
+
when RelationshipChain then pattern.relationships.any? { |relationship| quantified?(relationship) }
|
|
132
|
+
else false
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The predicate's variable is in scope inside its WHERE.
|
|
137
|
+
def render_list_predicate(predicate)
|
|
138
|
+
variable = render_expression(predicate.variable)
|
|
139
|
+
condition = with_names_in_scope([variable]) { render_expression(predicate.condition) }
|
|
140
|
+
"#{predicate.predicate}(#{variable} IN #{render_expression(predicate.list)} WHERE #{condition})"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def with_names_in_scope(names)
|
|
144
|
+
added = names.reject { |name| @visited_names.include?(name) }
|
|
145
|
+
@visited_names.merge(added)
|
|
146
|
+
yield
|
|
147
|
+
ensure
|
|
148
|
+
@visited_names.subtract(added)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def render_map_projection(projection)
|
|
152
|
+
subject = render_expression(projection.subject)
|
|
153
|
+
render_entries("#{subject}#{' ' if pretty?}{", '}') do
|
|
154
|
+
projection.entries.map do |kind, *args|
|
|
155
|
+
case kind
|
|
156
|
+
when :all then '.*'
|
|
157
|
+
when :lookup then ".#{Escaper.escape_if_necessary(args.first)}"
|
|
158
|
+
when :property then ".#{Escaper.escape_if_necessary(args.first.names.first)}"
|
|
159
|
+
when :pair then "#{Escaper.escape_if_necessary(args.first)}: #{render_expression(args.last)}"
|
|
160
|
+
else render_expression(args.first)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# A pattern comprehension is its own scope: names it introduces do not
|
|
167
|
+
# count as visited outside of it.
|
|
168
|
+
def render_pattern_comprehension(comprehension)
|
|
169
|
+
outer_scope = @visited_names.dup
|
|
170
|
+
parts = [render_pattern_element(comprehension.pattern)]
|
|
171
|
+
parts << "WHERE #{render_expression(comprehension.where_condition)}" if comprehension.where_condition
|
|
172
|
+
projection = comprehension.projection.map { |expression| render_expression(expression) }
|
|
173
|
+
parts << "| #{projection.size == 1 ? projection.first : "[#{projection.join(', ')}]"}"
|
|
174
|
+
@visited_names = outer_scope
|
|
175
|
+
"[#{parts.join(' ')}]"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def render_list_comprehension(comprehension)
|
|
179
|
+
parts = ["#{render_expression(comprehension.variable)} IN #{render_expression(comprehension.list)}"]
|
|
180
|
+
parts << "WHERE #{render_expression(comprehension.where_condition)}" if comprehension.where_condition
|
|
181
|
+
parts << "| #{render_expression(comprehension.projection)}" if comprehension.projection
|
|
182
|
+
"[#{parts.join(' ')}]"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Records every use for the catalog; anonymous parameters get `pcdslNN`,
|
|
186
|
+
# shared between anonymous parameters with equal values.
|
|
187
|
+
def render_parameter(parameter)
|
|
188
|
+
name = parameter.anonymous? ? anonymous_parameter_name(parameter) : parameter.name
|
|
189
|
+
(@parameter_uses[name] ||= []) << parameter.value
|
|
190
|
+
"$#{Escaper.escape_parameter_name(name)}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def anonymous_parameter_name(parameter)
|
|
194
|
+
index = @anonymous_parameters.index(parameter)
|
|
195
|
+
unless index
|
|
196
|
+
@anonymous_parameters << parameter
|
|
197
|
+
index = @anonymous_parameters.size - 1
|
|
198
|
+
end
|
|
199
|
+
format('pcdsl%02d', index + 1)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def render_property(property)
|
|
203
|
+
lookups = property.names.map do |name|
|
|
204
|
+
name.is_a?(Expression) ? "[#{render_expression(name)}]" : ".#{Escaper.escape_if_necessary(name)}"
|
|
205
|
+
end
|
|
206
|
+
"#{render_expression(property.container)}#{lookups.join}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def render_literal(value)
|
|
210
|
+
case value
|
|
211
|
+
when String then "'#{value.gsub('\\') { '\\\\' }.gsub("'") { "\\'" }}'"
|
|
212
|
+
when nil then 'NULL'
|
|
213
|
+
when true, false, Numeric then value.to_s
|
|
214
|
+
else raise ArgumentError, "Cannot render literal #{value.inspect}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def render_map(map)
|
|
219
|
+
render_entries('{', '}') do
|
|
220
|
+
map.entries.map { |key, value| "#{Escaper.escape_if_necessary(key)}: #{render_expression(value)}" }
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Comma separated entries, one per indented line when pretty printing.
|
|
225
|
+
def render_entries(opening, closing, &entries)
|
|
226
|
+
entries = nested(&entries)
|
|
227
|
+
return "#{opening}#{entries.join(', ')}#{closing}" unless pretty?
|
|
228
|
+
|
|
229
|
+
"#{opening}#{break_line(@level + 1)}#{entries.join(",#{break_line(@level + 1)}")}#{break_line}#{closing}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def render_comparison(comparison)
|
|
233
|
+
left = render_operand(comparison.left)
|
|
234
|
+
return "#{left} #{comparison.operator}" if comparison.unary?
|
|
235
|
+
|
|
236
|
+
"#{left} #{comparison.operator} #{render_operand(comparison.right)}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A comparison nested inside another comparison is parenthesised: `(1 > 2) = true`.
|
|
240
|
+
def render_operand(expression)
|
|
241
|
+
rendered = render_expression(expression)
|
|
242
|
+
expression.is_a?(Comparison) ? "(#{rendered})" : rendered
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Pretty printed, each further operand starts an indented line: `(a\n AND b)`.
|
|
246
|
+
def render_compound_condition(condition)
|
|
247
|
+
if condition.negation?
|
|
248
|
+
negated = condition.conditions.first
|
|
249
|
+
rendered = render_expression(negated)
|
|
250
|
+
return negated.is_a?(RelationshipPatternCondition) ? "NOT #{rendered}" : "NOT (#{rendered})"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
operands = nested { condition.conditions.map { |inner| render_expression(inner) } }
|
|
254
|
+
"(#{operands.join("#{break_line(@level + 1)}#{condition.operator} ")})"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def render_function_invocation(invocation)
|
|
258
|
+
arguments = invocation.arguments.map { |argument| render_expression(argument) }
|
|
259
|
+
name = invocation.function_name
|
|
260
|
+
legacy = @configuration.dialect == Dialect::NEO4J_4
|
|
261
|
+
# Neo4j 4 has no elementId(); the closest equivalent is the stringified internal id.
|
|
262
|
+
return "toString(id(#{arguments.join(', ')}))" if name == 'elementId' && legacy
|
|
263
|
+
|
|
264
|
+
name = 'point.distance' if name == 'distance' && !legacy
|
|
265
|
+
"#{name}(#{'DISTINCT ' if invocation.distinct}#{arguments.join(', ')})"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def escape_schema_name(name)
|
|
269
|
+
@configuration.always_escape_names ? Escaper.escape(name) : Escaper.escape_if_necessary(name)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CypherDSL
|
|
4
|
+
class Renderer
|
|
5
|
+
class Visitor
|
|
6
|
+
# Rendering of nodes, relationships, chains, named paths and quantified path patterns.
|
|
7
|
+
module Patterns
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def render_pattern_element(element)
|
|
11
|
+
case element
|
|
12
|
+
when Node then render_node(element)
|
|
13
|
+
when Relationship
|
|
14
|
+
render_node(element.left) + render_relationship_details(element) + render_node(element.right)
|
|
15
|
+
when RelationshipChain then render_chain(element)
|
|
16
|
+
when NamedPath then render_named_path(element)
|
|
17
|
+
when QuantifiedPathPattern then render_quantified_path_pattern(element)
|
|
18
|
+
else raise ArgumentError, "Cannot render pattern element #{element.inspect}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render_named_path(path)
|
|
23
|
+
selector = path.selector && "#{path.selector.keyword} "
|
|
24
|
+
"#{render_expression(path.symbolic_name)} = #{selector}#{render_pattern_element(path.pattern)}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render_quantified_path_pattern(quantified)
|
|
28
|
+
inner = render_pattern_element(quantified.pattern)
|
|
29
|
+
inner += " WHERE #{render_expression(quantified.where_condition)}" if quantified.where_condition
|
|
30
|
+
"(#{inner})#{quantified.quantifier.to_cypher}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_chain(chain)
|
|
34
|
+
chain.relationships.each_with_index.map do |relationship, index|
|
|
35
|
+
left = index.zero? ? render_node(relationship.left) : ''
|
|
36
|
+
left + render_relationship_details(relationship) + render_node(relationship.right)
|
|
37
|
+
end.join
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A named node renders in full once per scope and as `(name)` afterwards.
|
|
41
|
+
# Inside a pattern expression (a pattern used as a condition) no new
|
|
42
|
+
# variables may be introduced, so an unknown name is left out.
|
|
43
|
+
def render_node(node)
|
|
44
|
+
name = node.symbolic_name && resolve_name(node.symbolic_name)
|
|
45
|
+
return "(#{name})" if name && @visited_names.include?(name)
|
|
46
|
+
|
|
47
|
+
@visited_names << name if name && !@in_pattern_expression
|
|
48
|
+
name = nil if @in_pattern_expression
|
|
49
|
+
labels = node.labels.map { |label| ":#{render_label(label)}" }.join
|
|
50
|
+
properties = node.properties && " #{render_expression(node.properties)}"
|
|
51
|
+
where = node.where_condition && " WHERE #{render_expression(node.where_condition)}"
|
|
52
|
+
"(#{name}#{labels}#{properties}#{where})"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def render_label(label)
|
|
56
|
+
label.is_a?(LabelExpression) ? render_label_expression(label) : escape_schema_name(label)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# `A|B`, `A&B`, `!A`; operands of another kind are parenthesised.
|
|
60
|
+
def render_label_expression(expression)
|
|
61
|
+
case expression.type
|
|
62
|
+
when :leaf then escape_schema_name(expression.value)
|
|
63
|
+
when :negation then "!#{render_label_operand(expression.operands.first, expression)}"
|
|
64
|
+
else
|
|
65
|
+
operator = expression.type == :conjunction ? '&' : '|'
|
|
66
|
+
expression.operands.map { |operand| render_label_operand(operand, expression) }.join(operator)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_label_operand(operand, parent)
|
|
71
|
+
rendered = render_label_expression(operand)
|
|
72
|
+
operand.leaf? || operand.type == parent.type || operand.type == :negation ? rendered : "(#{rendered})"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A pattern used as a condition: known names render bare, unknown ones are dropped.
|
|
76
|
+
def render_pattern_expression(pattern)
|
|
77
|
+
@in_pattern_expression = true
|
|
78
|
+
render_pattern_element(pattern)
|
|
79
|
+
ensure
|
|
80
|
+
@in_pattern_expression = false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Like nodes, a named relationship renders in full once and as `[name]` afterwards.
|
|
84
|
+
def render_relationship_details(relationship)
|
|
85
|
+
details = relationship.details
|
|
86
|
+
name = relationship.symbolic_name && resolve_name(relationship.symbolic_name)
|
|
87
|
+
inner = if name && @visited_names.include?(name)
|
|
88
|
+
"[#{name}]"
|
|
89
|
+
else
|
|
90
|
+
@visited_names << name if name && !@in_pattern_expression
|
|
91
|
+
content = render_details_content(relationship)
|
|
92
|
+
# A name dropped from a pattern expression leaves its brackets behind: `-[]->`.
|
|
93
|
+
content.empty? && !(name && @in_pattern_expression) ? '' : "[#{content}]"
|
|
94
|
+
end
|
|
95
|
+
quantifier = details.quantifier&.to_cypher
|
|
96
|
+
case relationship.direction
|
|
97
|
+
when :ltr then "-#{inner}->#{quantifier}"
|
|
98
|
+
when :rtl then "<-#{inner}-#{quantifier}"
|
|
99
|
+
else "-#{inner}-#{quantifier}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render_details_content(relationship)
|
|
104
|
+
details = relationship.details
|
|
105
|
+
types = details.types.map { |type| escape_schema_name(type) }.join('|')
|
|
106
|
+
name = relationship.symbolic_name && !@in_pattern_expression ? resolve_name(relationship.symbolic_name) : nil
|
|
107
|
+
[
|
|
108
|
+
name,
|
|
109
|
+
(":#{types}" unless types.empty?),
|
|
110
|
+
render_length(details),
|
|
111
|
+
details.properties && " #{render_expression(details.properties)}",
|
|
112
|
+
details.where && " WHERE #{render_expression(details.where)}"
|
|
113
|
+
].join
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_length(details)
|
|
117
|
+
return nil unless details.length?
|
|
118
|
+
return '*' if details.unbounded && details.min.nil? && details.max.nil?
|
|
119
|
+
|
|
120
|
+
"*#{details.min}..#{details.max}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|