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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: aae0b1907cab6d377de663093abb2aca70b7925b10dafc1596a50ccfc6f8a97f
4
+ data.tar.gz: 21ad9bce707aabfb247196a75bdfb7de97ffa215e53b210aaa2179dcf0a7b313
5
+ SHA512:
6
+ metadata.gz: 69811594f30155665847bc68fc4c66471de5e0f8ae1b1a83e39d08fd6d8426720e4206aa260f20358c395a87ba2ff2a530eafe518f7621235267d97831a05754
7
+ data.tar.gz: bc7823802abd14746151e12ae00050be8070be48b0fbed3ef593ef6654bdd0e6613c5c8ebdd78089d3ffbb7303830f06560cb637bb402d893ec279c57f111e43
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0.alpha.1] - 2026-10-31
11
+
12
+ First pre-release, reserving the gem name. A Ruby reimplementation of the design of
13
+ [Neo4j's Cypher-DSL for Java](https://github.com/neo4j/cypher-dsl), built test-first from
14
+ specs translated from its test suite (CypherIT, ProcedureCalls, Subqueries, parameter
15
+ collection, IssueRelatedIT, FunctionsIT).
16
+
17
+ ### Added
18
+
19
+ - Immutable statement builder with MATCH/OPTIONAL MATCH, WHERE, WITH, RETURN, ORDER BY,
20
+ SKIP/LIMIT, UNWIND, CREATE, MERGE (ON CREATE/ON MATCH), SET, REMOVE, DELETE, FOREACH,
21
+ CALL procedures (YIELD, YIELD *), CALL subqueries (imports, IN TRANSACTIONS), LOAD CSV,
22
+ USE, UNION/UNION ALL, FINISH, EXPLAIN/PROFILE.
23
+ - Patterns: nodes, relationships, chains, named paths, quantified path patterns,
24
+ path selectors, label expressions.
25
+ - Expressions: literals, parameters, maps, lists, map projections, list and pattern
26
+ comprehensions, list predicates, CASE, reduce(), EXISTS/COUNT/COLLECT subquery
27
+ expressions, the built-in function catalogue.
28
+ - Renderer with dialects (Neo4j 4, 5, 5.23+), pretty printing, identifier escaping,
29
+ parameter collection (`Statement#catalog`).
30
+
31
+ [Unreleased]: https://github.com/neo4jrb/cypher-dsl/compare/v0.1.0.alpha.1...HEAD
32
+ [0.1.0.alpha.1]: https://github.com/neo4jrb/cypher-dsl/releases/tag/v0.1.0.alpha.1
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Heinrich Klobuczek and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/NOTICE ADDED
@@ -0,0 +1,14 @@
1
+ cypher-dsl (Ruby)
2
+ Copyright (c) 2026 Heinrich Klobuczek and contributors
3
+
4
+ This project is a Ruby implementation of the design of Neo4j's Cypher-DSL for
5
+ Java (https://github.com/neo4j/cypher-dsl), Copyright (c) 2019-2026 "Neo4j,"
6
+ Neo4j Sweden AB, licensed under the Apache License, Version 2.0.
7
+
8
+ The specifications under spec/ are translated from the Cypher-DSL test suite and
9
+ are therefore a derivative work of that Apache-2.0 licensed code. A copy of the
10
+ Apache License, Version 2.0 is available at
11
+ http://www.apache.org/licenses/LICENSE-2.0.
12
+
13
+ The Ruby source under lib/ is original work licensed under the MIT License (see
14
+ LICENSE.txt).
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # cypher-dsl
2
+
3
+ A Ruby DSL (builder) for the [Cypher](https://neo4j.com/docs/cypher-manual/current/)
4
+ query language, modelled on the design of Neo4j's
5
+ [Cypher-DSL for Java](https://github.com/neo4j/cypher-dsl).
6
+
7
+ **Status: early development.** The API is being built test-first against
8
+ specifications translated from the Java project's test suite, so rendered
9
+ output is meant to match Cypher-DSL byte for byte.
10
+
11
+ ## Design
12
+
13
+ - **Immutable AST.** Every builder call returns a new object; a built
14
+ `Statement` is a frozen tree that can be rendered, inspected or transformed.
15
+ - **Part-aware builder.** Cypher is a linear sequence of parts chained by
16
+ `WITH`, and clauses keep the order you wrote them. The builder's states
17
+ mirror which clause may follow which, exactly as the Java `StatementBuilder`
18
+ does.
19
+ - **Rendering is separate from the tree.** A `Renderer` turns a `Statement` into
20
+ Cypher text and collects parameters; options such as identifier escaping and
21
+ pretty printing live in a `Configuration`.
22
+ - **Names follow the Java API in snake_case**, so examples and tests from the
23
+ Java project translate mechanically:
24
+
25
+ ```ruby
26
+ include CypherDSL
27
+
28
+ statement = Cypher.match(Cypher.node('S1').named(:n))
29
+ .where(Cypher.property(:n, :a).is_equal_to(Cypher.literal_of('A')))
30
+ .with(:n)
31
+ .match(Cypher.any_node(:n).relationship_to(Cypher.node('S2').named(:m), 'SOMEHOW_RELATED'))
32
+ .returning(:n, :m)
33
+ .build
34
+
35
+ statement.cypher
36
+ # => "MATCH (n:`S1`) WHERE n.a = 'A' WITH n MATCH (n)-[:`SOMEHOW_RELATED`]->(m:`S2`) RETURN n, m"
37
+ ```
38
+
39
+ - **No ActiveSupport.** The only runtime dependency is Zeitwerk.
40
+
41
+ ## Parameters
42
+
43
+ Parameters render as `$name`; give them a value and the statement hands back
44
+ the map a driver needs:
45
+
46
+ ```ruby
47
+ statement = Cypher.match(Cypher.node('User').named(:u))
48
+ .where(Cypher.property(:u, :name).is_equal_to(Cypher.parameter(:name, 'Neo')))
49
+ .returning(:u)
50
+ .build
51
+
52
+ statement.cypher # => "MATCH (u:`User`) WHERE u.name = $name RETURN u"
53
+ statement.parameters # => {"name" => "Neo"}
54
+ statement.parameter_names # => ["name"]
55
+ ```
56
+
57
+ `Cypher.anon_parameter(value)` lets the renderer pick the name (`$pcdsl01`, …).
58
+ Binding one name to two different values raises `ConflictingParametersException`.
59
+
60
+ ## Rendering options
61
+
62
+ ```ruby
63
+ config = Renderer::Configuration.new_config
64
+ .with_dialect(Renderer::Dialect::NEO4J_4) # default: NEO4J_5_23
65
+ .always_escape_names(false) # default: true, false when pretty printing
66
+ .with_pretty_print(true) # one clause per line, indented blocks
67
+ .with_indent_style(:tab) # or .with_indent_size(4)
68
+ .build
69
+ Renderer.get_renderer(config).render(statement)
70
+ Renderer.get_renderer(Renderer::Configuration.pretty_printing).render(statement)
71
+ ```
72
+
73
+ The dialect decides how `CALL { }` subqueries import variables (`CALL (u) { … }`
74
+ from Neo4j 5.23 on, `CALL { WITH u … }` before) and whether `elementId()` exists.
75
+
76
+ ## Installation
77
+
78
+ ```ruby
79
+ gem 'cypher-dsl'
80
+ ```
81
+
82
+ Requires Ruby 3.4 or newer.
83
+
84
+ ## Licence
85
+
86
+ The Ruby source is MIT licensed (see `LICENSE.txt`). The specifications are
87
+ translated from Cypher-DSL's Apache-2.0 test suite; see `NOTICE`.
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # `expression AS alias`.
5
+ class AliasedExpression
6
+ include Expression
7
+
8
+ attr_reader :delegate, :alias_name
9
+
10
+ def initialize(delegate, alias_name)
11
+ @delegate = delegate
12
+ @alias_name = SymbolicName.of(alias_name)
13
+ freeze
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # The `*` in `RETURN *`.
5
+ class Asterisk
6
+ include Expression
7
+
8
+ INSTANCE = new.freeze
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # `CASE [subject] WHEN condition THEN value … [ELSE default] END`.
5
+ # Built with `Cypher.case_expression`, `.when(…).then(…)` pairs and `else_default`.
6
+ class CaseExpression
7
+ include Expression
8
+
9
+ attr_reader :subject, :whens, :default
10
+
11
+ def initialize(subject: nil, whens: [], default: nil)
12
+ @subject = subject
13
+ @whens = whens.freeze
14
+ @default = default
15
+ freeze
16
+ end
17
+
18
+ def when(condition) = OngoingWhen.new(self, Expression.coerce(condition))
19
+
20
+ def else_default(default_value) = with(default: Expression.coerce(default_value))
21
+
22
+ def with(**changes) = CaseExpression.new(subject:, whens:, default:, **changes)
23
+
24
+ # A `WHEN` waiting for its `THEN`.
25
+ class OngoingWhen
26
+ def initialize(case_expression, condition)
27
+ @case_expression = case_expression
28
+ @condition = condition
29
+ freeze
30
+ end
31
+
32
+ def then(value)
33
+ @case_expression.with(whens: @case_expression.whens + [[@condition, Expression.coerce(value)]])
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `CALL name.parts(arguments) [YIELD items] [WHERE condition]`.
6
+ Call = Data.define(:name_parts, :arguments, :yield_items, :where)
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `CALL (imports) { body } [IN TRANSACTIONS [OF n ROWS]]`; the body is a
6
+ # statement or a raw Cypher fragment, `imports` a list of names or the asterisk.
7
+ CallSubquery = Data.define(:body, :imports, :in_transactions, :rows)
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `CREATE pattern, …`.
6
+ Create = Data.define(:pattern_elements)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `[DETACH] DELETE expression, …`.
6
+ Delete = Data.define(:detach, :expressions)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `FINISH`: terminates a query without returning rows (Neo4j 5.19+).
6
+ Finish = Data.define
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `FOREACH (variable IN list | updating clauses)`.
6
+ Foreach = Data.define(:variable, :list, :clauses)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `LOAD CSV [WITH HEADERS] FROM 'uri' AS alias`.
6
+ LoadCsv = Data.define(:uri, :with_headers, :alias_name)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `[OPTIONAL] MATCH pattern [WHERE condition]`.
6
+ Match = Data.define(:optional, :pattern_elements, :where)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `MERGE pattern [ON CREATE SET …] [ON MATCH SET …]`; actions keep call order.
6
+ Merge = Data.define(:pattern_elements, :actions)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `ORDER BY item, …`; attached to RETURN or WITH.
6
+ Order = Data.define(:items)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # Items rendered without the RETURN keyword, for raw fragments that carry their own.
6
+ RawReturn = Data.define(:items)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `REMOVE n.prop, n:Label`.
6
+ Remove = Data.define(:items)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `RETURN [DISTINCT] items [ORDER BY …] [SKIP …] [LIMIT …]`.
6
+ Return = Data.define(:distinct, :items, :order, :skip, :limit)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `SET n.prop = value, …`.
6
+ Set = Data.define(:operations)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `UNWIND expression AS variable`.
6
+ Unwind = Data.define(:expression, :variable)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `USE graph`.
6
+ Use = Data.define(:graph)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ module Clauses
5
+ # `WITH [DISTINCT] items [ORDER BY …] [SKIP …] [LIMIT …] [WHERE …]`; ends a query part.
6
+ With = Data.define(:distinct, :items, :order, :skip, :limit, :where)
7
+ end
8
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # Factory methods for clause objects, mirroring the Java `Clauses` class. Most
5
+ # code goes through the {StatementBuilder}; these exist for building clause
6
+ # lists by hand, e.g. the body of a FOREACH.
7
+ module Clauses
8
+ module_function
9
+
10
+ def for_each(variable, list, clauses)
11
+ Foreach.new(variable: SymbolicName.of(variable), list: Expression.coerce(list), clauses: clauses.freeze)
12
+ end
13
+
14
+ def merge(pattern_elements, actions = [])
15
+ Merge.new(pattern_elements: pattern_elements.freeze, actions: actions.freeze)
16
+ end
17
+
18
+ def set(operations) = Set.new(operations: operations.freeze)
19
+ def remove(items) = Remove.new(items: items.freeze)
20
+
21
+ def delete(detach, expressions)
22
+ Delete.new(detach:, expressions: expressions.map { |e| Expression.coerce(e) }.freeze)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # `COLLECT { [WITH imports] statement }`: the statement must end with a RETURN of one column.
5
+ class CollectExpression
6
+ include Expression
7
+
8
+ MESSAGE = 'The final RETURN clause in a subquery used with COLLECT is mandatory and ' \
9
+ 'the RETURN clause must return exactly one column.'
10
+
11
+ attr_reader :statement, :imports
12
+
13
+ def initialize(statement, imports = [])
14
+ raise ArgumentError, MESSAGE unless CollectExpression.single_column?(statement)
15
+
16
+ @statement = statement
17
+ @imports = imports.freeze
18
+ freeze
19
+ end
20
+
21
+ def self.single_column?(statement)
22
+ return statement.statements.all? { |inner| single_column?(inner) } if statement.is_a?(UnionQuery)
23
+
24
+ last = statement.clauses.last
25
+ last.is_a?(Clauses::Return) && last.items.size == 1
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # A binary comparison (`a = b`, `a =~ b`, …) or a unary one (`a IS NULL`).
5
+ class Comparison < Condition
6
+ attr_reader :left, :operator, :right
7
+
8
+ def initialize(left, operator, right)
9
+ super()
10
+ @left = left
11
+ @operator = operator
12
+ @right = right
13
+ freeze
14
+ end
15
+
16
+ def unary? = right.nil?
17
+
18
+ # Negating a null check flips it instead of wrapping it in NOT.
19
+ def not
20
+ case operator
21
+ when 'IS NULL' then Comparison.new(left, 'IS NOT NULL', nil)
22
+ when 'IS NOT NULL' then Comparison.new(left, 'IS NULL', nil)
23
+ else super
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # Conditions joined by AND / OR / XOR, or negated with NOT. Rendered in
5
+ # parentheses; chaining the same operator flattens into one group and empty
6
+ # conditions drop out.
7
+ class CompoundCondition < Condition
8
+ attr_reader :operator, :conditions
9
+
10
+ def self.create(operator, left, right)
11
+ conditions = [left, right].flat_map do |condition|
12
+ case condition
13
+ when NoCondition then []
14
+ when CompoundCondition then condition.operator == operator ? condition.conditions : [condition]
15
+ else [condition]
16
+ end
17
+ end
18
+ case conditions.size
19
+ when 0 then NoCondition::INSTANCE
20
+ when 1 then conditions.first
21
+ else new(operator, conditions)
22
+ end
23
+ end
24
+
25
+ def initialize(operator, conditions)
26
+ super()
27
+ @operator = operator
28
+ @conditions = conditions.freeze
29
+ freeze
30
+ end
31
+
32
+ def negation? = operator == 'NOT'
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # Base class of boolean expressions; supports chaining with `and`, `or`, `xor` and `not`.
5
+ # Relationship patterns are accepted wherever a condition is expected.
6
+ class Condition
7
+ include Expression
8
+
9
+ def self.coerce(value)
10
+ case value
11
+ when Condition then value
12
+ when Relationship, RelationshipChain then RelationshipPatternCondition.new(value)
13
+ when FunctionInvocation then ExpressionCondition.new(value) # boolean functions such as isEmpty()
14
+ else raise ArgumentError, "#{value.inspect} is not a condition"
15
+ end
16
+ end
17
+
18
+ def and(other) = combine('AND', other)
19
+ def or(other) = combine('OR', other)
20
+ def xor(other) = combine('XOR', other)
21
+ def not = CompoundCondition.new('NOT', [self])
22
+
23
+ private
24
+
25
+ # Chaining conditions flattens same-operator chains; chaining a relationship
26
+ # pattern (the Java overload) wraps without flattening, as upstream does.
27
+ def combine(operator, other)
28
+ return CompoundCondition.new(operator, [self, Condition.coerce(other)]) if pattern?(other)
29
+
30
+ CompoundCondition.create(operator, self, Condition.coerce(other))
31
+ end
32
+
33
+ def pattern?(value) = value.is_a?(Relationship) || value.is_a?(RelationshipChain)
34
+ end
35
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # Raised when one parameter name is bound to different values within a
5
+ # statement, or bound in one place and left unbound in another.
6
+ class ConflictingParametersException < StandardError
7
+ # `name => Set` of the conflicting values; an unbound use is {Parameter::NO_VALUE}.
8
+ attr_reader :erroneous_parameters
9
+
10
+ def initialize(erroneous_parameters)
11
+ @erroneous_parameters = erroneous_parameters.freeze
12
+ super("Conflicting parameter values for #{erroneous_parameters.keys.join(', ')}")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # The constant conditions `true` and `false`.
5
+ class ConstantCondition < Condition
6
+ attr_reader :value
7
+
8
+ def initialize(value)
9
+ super()
10
+ @value = value
11
+ freeze
12
+ end
13
+
14
+ TRUE = new(true)
15
+ FALSE = new(false)
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # `COUNT { pattern }` / `COUNT { statement }`.
5
+ class CountExpression
6
+ include Expression
7
+
8
+ attr_reader :body, :imports
9
+
10
+ def initialize(body, imports = [])
11
+ @body = body
12
+ @imports = imports.freeze
13
+ freeze
14
+ end
15
+ end
16
+ end