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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +53 -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.rb +11 -0
  7. data/lib/cypher_dsl/comparison.rb +6 -0
  8. data/lib/cypher_dsl/count_expression.rb +7 -3
  9. data/lib/cypher_dsl/cypher.rb +80 -19
  10. data/lib/cypher_dsl/expression.rb +3 -0
  11. data/lib/cypher_dsl/function_invocation.rb +7 -0
  12. data/lib/cypher_dsl/functions.rb +11 -1
  13. data/lib/cypher_dsl/has_label_condition.rb +1 -1
  14. data/lib/cypher_dsl/hint.rb +71 -0
  15. data/lib/cypher_dsl/label_operation.rb +11 -3
  16. data/lib/cypher_dsl/labels.rb +66 -0
  17. data/lib/cypher_dsl/literal.rb +24 -2
  18. data/lib/cypher_dsl/named_path.rb +6 -3
  19. data/lib/cypher_dsl/node.rb +15 -4
  20. data/lib/cypher_dsl/property.rb +9 -2
  21. data/lib/cypher_dsl/property_exists_condition.rb +15 -0
  22. data/lib/cypher_dsl/raw_expression.rb +76 -3
  23. data/lib/cypher_dsl/relationship.rb +8 -1
  24. data/lib/cypher_dsl/relationship_chain.rb +3 -0
  25. data/lib/cypher_dsl/renderer/configuration.rb +61 -4
  26. data/lib/cypher_dsl/renderer/dialect.rb +24 -7
  27. data/lib/cypher_dsl/renderer/escaper.rb +10 -7
  28. data/lib/cypher_dsl/renderer/generated_name_resolver.rb +110 -0
  29. data/lib/cypher_dsl/renderer/name_resolver.rb +35 -0
  30. data/lib/cypher_dsl/renderer/visitor/expressions.rb +52 -15
  31. data/lib/cypher_dsl/renderer/visitor/label_rendering.rb +84 -0
  32. data/lib/cypher_dsl/renderer/visitor/patterns.rb +17 -29
  33. data/lib/cypher_dsl/renderer/visitor.rb +99 -27
  34. data/lib/cypher_dsl/renderer.rb +11 -5
  35. data/lib/cypher_dsl/search.rb +91 -0
  36. data/lib/cypher_dsl/standalone_call.rb +0 -6
  37. data/lib/cypher_dsl/statement.rb +4 -0
  38. data/lib/cypher_dsl/statement_builder.rb +67 -14
  39. data/lib/cypher_dsl/statement_catalog.rb +40 -6
  40. data/lib/cypher_dsl/subquery_with.rb +1 -0
  41. data/lib/cypher_dsl/symbolic_name.rb +1 -1
  42. data/lib/cypher_dsl/using_periodic_commit.rb +11 -0
  43. data/lib/cypher_dsl/vector_literal.rb +41 -0
  44. data/lib/cypher_dsl/version.rb +1 -1
  45. metadata +11 -3
  46. data/lib/cypher_dsl/label_expression.rb +0 -40
@@ -6,8 +6,6 @@ module CypherDSL
6
6
  # `build` finishes a bare call, `as_function` turns it into a function
7
7
  # invocation instead.
8
8
  class StandaloneCall
9
- AGGREGATES = %w[avg collect count max min percentilecont percentiledisc stdev stdevp sum].freeze
10
-
11
9
  attr_reader :name_parts, :arguments
12
10
 
13
11
  # `name_parts` as given to `Cypher.call`: strings, symbols or arrays, dotted names split.
@@ -37,10 +35,6 @@ module CypherDSL
37
35
 
38
36
  # The procedure as a function invocation; `distinct` only for aggregates.
39
37
  def as_function(distinct = false) # rubocop:disable Style/OptionalBooleanParameter
40
- if distinct && !AGGREGATES.include?(procedure_name.downcase)
41
- raise ArgumentError, 'The distinct operator can only be applied within aggregate functions.'
42
- end
43
-
44
38
  FunctionInvocation.new(procedure_name, arguments, distinct:)
45
39
  end
46
40
 
@@ -23,6 +23,10 @@ module CypherDSL
23
23
  @cypher ||= Renderer.default.render(self).freeze
24
24
  end
25
25
 
26
+ # The same statement with `EXPLAIN` / `PROFILE` in front.
27
+ def explain = Statement.new(clauses, prefix: 'EXPLAIN')
28
+ def profile = Statement.new(clauses, prefix: 'PROFILE')
29
+
26
30
  # Parameter names and bound values; raises {ConflictingParametersException}
27
31
  # when a name is bound inconsistently. Memoised, like `cypher`.
28
32
  def catalog
@@ -19,11 +19,32 @@ module CypherDSL
19
19
  # `match(pattern, …)` or, as in Java, `match(optional, pattern, …)` with a leading flag.
20
20
  def match(*pattern_elements)
21
21
  optional = [true, false].include?(pattern_elements.first) ? pattern_elements.shift : false
22
- append(Clauses::Match.new(optional:, pattern_elements: pattern_elements.flatten.freeze, where: nil), :condition)
22
+ raise ArgumentError, 'Patterns to match are required.' if pattern_elements.any?(&:nil?)
23
+
24
+ pattern_elements = pattern_elements.flatten.freeze
25
+ raise ArgumentError, 'At least one pattern to match is required.' if pattern_elements.empty?
26
+
27
+ append(Clauses::Match.new(optional:, pattern_elements:, hints: [].freeze, search: nil, where: nil), :condition)
28
+ end
29
+
30
+ # A vector `SEARCH` on the preceding MATCH; see {Search}.
31
+ def search(search)
32
+ update_last(:condition) do |clause|
33
+ raise ArgumentError, 'SEARCH needs a preceding MATCH.' unless clause.is_a?(Clauses::Match)
34
+
35
+ clause.with(search:)
36
+ end
23
37
  end
24
38
 
25
39
  def optional_match(*pattern_elements) = match(true, *pattern_elements)
26
40
 
41
+ # Planner hints on the preceding MATCH: `using_index(n.property(:name))`,
42
+ # `using_index_seek(…)`, `using_scan(n)`, `using_join_on(n, m)`.
43
+ def using_index(*properties) = add_hint(Hint.use_index_for(false, *properties))
44
+ def using_index_seek(*properties) = add_hint(Hint.use_index_for(true, *properties))
45
+ def using_scan(node) = add_hint(Hint.use_scan_for(node))
46
+ def using_join_on(*nodes_or_names) = add_hint(Hint.use_join_on(*nodes_or_names))
47
+
27
48
  def where(condition)
28
49
  if @clauses.last.is_a?(Clauses::CallSubquery)
29
50
  raise ArgumentError, 'A CALL{} clause requires to WITH before you can add further conditions'
@@ -52,15 +73,19 @@ module CypherDSL
52
73
  def returning_raw(*expressions) = append(Clauses::RawReturn.new(items: coerce_items(expressions)), :projection)
53
74
 
54
75
  def with(*expressions)
55
- append(Clauses::With.new(distinct: false, items: coerce_items(expressions), order: nil, skip: nil, limit: nil,
56
- where: nil), :condition)
76
+ append(Clauses::With.new(distinct: false, items: projection_items(expressions), order: nil, skip: nil,
77
+ limit: nil, where: nil), :condition)
57
78
  end
58
79
 
59
80
  def with_distinct(*expressions)
60
- append(Clauses::With.new(distinct: true, items: coerce_items(expressions), order: nil, skip: nil, limit: nil,
61
- where: nil), :condition)
81
+ append(Clauses::With.new(distinct: true, items: projection_items(expressions), order: nil, skip: nil,
82
+ limit: nil, where: nil), :condition)
62
83
  end
63
84
 
85
+ # The names and expressions in scope after the last WITH or RETURN, as
86
+ # `getIdentifiableExpressions()` on the Java builder states.
87
+ def identifiable_expressions = StatementCatalog.identifiable_expressions_of(build)
88
+
64
89
  def order_by(*sort_items)
65
90
  update_last(:order) { |clause| clause.with(order: Clauses::Order.new(items: coerce_sort_items(sort_items))) }
66
91
  end
@@ -136,7 +161,18 @@ module CypherDSL
136
161
  end
137
162
 
138
163
  # `load_csv('file:///x.csv').as(:line)`.
139
- def load_csv(uri, with_headers: false) = OngoingLoadCsv.new(self, uri.to_s, with_headers)
164
+ def load_csv(uri, positional_headers = false, with_headers: positional_headers, periodic_commit: nil) # rubocop:disable Style/OptionalBooleanParameter
165
+ OngoingLoadCsv.new(self, uri.to_s, with_headers, periodic_commit)
166
+ end
167
+
168
+ # `FIELDTERMINATOR ';'` on the preceding LOAD CSV.
169
+ def with_field_terminator(terminator)
170
+ update_last(nil) do |clause|
171
+ raise ArgumentError, 'FIELDTERMINATOR needs a preceding LOAD CSV.' unless clause.is_a?(Clauses::LoadCsv)
172
+
173
+ clause.with_field_terminator(terminator&.to_s)
174
+ end
175
+ end
140
176
 
141
177
  # The statement built so far as an `EXISTS { … }` condition.
142
178
  def as_condition = ExistsCondition.new(build)
@@ -176,23 +212,22 @@ module CypherDSL
176
212
 
177
213
  def in(list) = OngoingForeach.new(@builder, @variable, Expression.coerce(list))
178
214
 
179
- def apply(*clauses)
180
- foreach = Clauses::Foreach.new(variable: @variable, list: @list, clauses: clauses.flatten.freeze)
181
- @builder.send(:append, foreach, nil)
182
- end
215
+ def apply(*clauses) = @builder.send(:append, Clauses.for_each(@variable, @list, clauses.flatten), nil)
183
216
  end
184
217
 
185
218
  # Intermediate state of `load_csv(uri)` waiting for `as(alias)`.
186
219
  class OngoingLoadCsv
187
- def initialize(builder, uri, with_headers)
220
+ def initialize(builder, uri, with_headers, periodic_commit)
188
221
  @builder = builder
189
222
  @uri = uri
190
223
  @with_headers = with_headers
224
+ @periodic_commit = periodic_commit
191
225
  freeze
192
226
  end
193
227
 
194
228
  def as(alias_name)
195
- clause = Clauses::LoadCsv.new(uri: @uri, with_headers: @with_headers, alias_name: SymbolicName.of(alias_name))
229
+ clause = Clauses::LoadCsv.new(uri: @uri, with_headers: @with_headers, alias_name: SymbolicName.of(alias_name),
230
+ field_terminator: nil, periodic_commit: @periodic_commit)
196
231
  @builder.send(:append, clause, nil)
197
232
  end
198
233
  end
@@ -220,6 +255,14 @@ module CypherDSL
220
255
  Clauses::CallSubquery.new(body: statement, imports: coerce_items(imports), in_transactions:, rows:)
221
256
  end
222
257
 
258
+ def add_hint(hint)
259
+ update_last(:condition) do |clause|
260
+ raise ArgumentError, 'Hints need a preceding MATCH.' unless clause.is_a?(Clauses::Match)
261
+
262
+ clause.with(hints: clause.hints + [hint])
263
+ end
264
+ end
265
+
223
266
  def add_merge_action(type, operations)
224
267
  update_last(nil) do |clause|
225
268
  raise ArgumentError, 'ON CREATE / ON MATCH need a preceding MERGE.' unless clause.is_a?(Clauses::Merge)
@@ -244,7 +287,7 @@ module CypherDSL
244
287
 
245
288
  def label_operation?(arguments)
246
289
  arguments.size > 1 && arguments.first.is_a?(Node) &&
247
- arguments.drop(1).all? { |argument| argument.is_a?(String) || argument.is_a?(Symbol) }
290
+ arguments.drop(1).all? { |argument| [String, Symbol, Labels].any? { |type| argument.is_a?(type) } }
248
291
  end
249
292
 
250
293
  def optional_expression(value) = value.nil? ? nil : Expression.coerce(value)
@@ -256,7 +299,17 @@ module CypherDSL
256
299
  end
257
300
 
258
301
  def return_clause(distinct, expressions)
259
- Clauses::Return.new(distinct:, items: coerce_items(expressions), order: nil, skip: nil, limit: nil)
302
+ Clauses::Return.new(distinct:, items: projection_items(expressions), order: nil, skip: nil, limit: nil)
303
+ end
304
+
305
+ # RETURN/WITH items with the upstream preconditions.
306
+ def projection_items(expressions)
307
+ raise ArgumentError, 'Expressions to return are required.' if expressions.any?(&:nil?)
308
+
309
+ items = coerce_items(expressions)
310
+ raise ArgumentError, 'At least one expressions to return is required.' if items.empty?
311
+
312
+ items
260
313
  end
261
314
 
262
315
  def add_sort_item(expression)
@@ -1,24 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CypherDSL
4
- # What a statement uses: its parameter names, and the values of the
5
- # parameters that carry one. Built from a rendering pass.
4
+ # What a statement uses: its parameter names, the values of the parameters
5
+ # that carry one, and the expressions it hands back. Built from a rendering pass.
6
6
  class StatementCatalog
7
- attr_reader :parameters, :parameter_names
7
+ attr_reader :parameters, :parameter_names, :renamed_parameters, :identifiable_expressions
8
8
 
9
9
  # `uses` maps each parameter name to every value it was given, in render
10
10
  # order, with {Parameter::NO_VALUE} for unbound uses.
11
- def self.from_uses(uses)
11
+ def self.from_uses(uses, renamed_parameters = {}, statement: nil)
12
12
  conflicts = uses.filter_map { |name, values| [name, values.uniq.to_set] if values.uniq.size > 1 }.to_h
13
13
  raise ConflictingParametersException, conflicts unless conflicts.empty?
14
14
 
15
15
  bound = uses.filter_map { |name, values| [name, values.first] unless values.first.equal?(Parameter::NO_VALUE) }
16
- new(parameters: bound.to_h, parameter_names: uses.keys)
16
+ new(parameters: bound.to_h, parameter_names: uses.keys, renamed_parameters:,
17
+ identifiable_expressions: identifiable_expressions_of(statement))
17
18
  end
18
19
 
19
- def initialize(parameters:, parameter_names:)
20
+ # The expressions a statement hands back to its caller: the identifiable items of
21
+ # its final RETURN or WITH (names for aliased items and named entities, properties
22
+ # as they are), or the names a bare procedure call yields. Empty otherwise.
23
+ def self.identifiable_expressions_of(statement)
24
+ case statement
25
+ when UnionQuery then statement.statements.flat_map { |part| identifiable_expressions_of(part) }.uniq
26
+ when Statement then result_items_of(statement.clauses.last).filter_map { |item| identifiable_expression(item) }
27
+ else []
28
+ end
29
+ end
30
+
31
+ def self.result_items_of(clause)
32
+ case clause
33
+ when Clauses::Return, Clauses::With then clause.items
34
+ when Clauses::Call then clause.yield_items
35
+ else []
36
+ end
37
+ end
38
+ private_class_method :result_items_of
39
+
40
+ # Only names, aliases, named entities and properties identify something; a literal does not.
41
+ def self.identifiable_expression(item)
42
+ case item
43
+ when AliasedExpression then item.alias_name
44
+ when Node, Relationship then item.symbolic_name
45
+ when SymbolicName, Property then item
46
+ end
47
+ end
48
+ private_class_method :identifiable_expression
49
+
50
+ # `renamed_parameters` maps original names to generated ones when the renderer rewrites them.
51
+ def initialize(parameters:, parameter_names:, renamed_parameters: {}, identifiable_expressions: [])
20
52
  @parameters = parameters.freeze
21
53
  @parameter_names = parameter_names.freeze
54
+ @renamed_parameters = renamed_parameters.freeze
55
+ @identifiable_expressions = identifiable_expressions.freeze
22
56
  freeze
23
57
  end
24
58
  end
@@ -10,6 +10,7 @@ module CypherDSL
10
10
  end
11
11
 
12
12
  def collect(statement) = CollectExpression.new(statement, @imports)
13
+ def count(statement_or_pattern) = CountExpression.new(statement_or_pattern, @imports)
13
14
  def exists(statement_or_pattern) = ExistsCondition.new(statement_or_pattern, @imports)
14
15
  end
15
16
  end
@@ -15,7 +15,7 @@ module CypherDSL
15
15
  end
16
16
 
17
17
  # A name to be generated at render time; `hint` seeds the generated name (letters only).
18
- def self.unresolved(hint) = new(nil, hint: hint.to_s.gsub(/[^a-zA-Z]/, ''))
18
+ def self.unresolved(hint = '') = new(nil, hint: hint.to_s.gsub(/[^a-zA-Z]/, ''))
19
19
 
20
20
  def initialize(value, hint: nil)
21
21
  raise ArgumentError, 'A symbolic name must not be empty.' if value && value.empty?
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # `Cypher.using_periodic_commit(500).load_csv(uri).as(:line)`: the legacy
5
+ # `USING PERIODIC COMMIT [rate]` prefix of a LOAD CSV statement.
6
+ UsingPeriodicCommit = Data.define(:rate) do
7
+ def load_csv(uri, positional_headers = false, with_headers: positional_headers) # rubocop:disable Style/OptionalBooleanParameter
8
+ StatementBuilder.new.load_csv(uri, with_headers:, periodic_commit: rate || true)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CypherDSL
4
+ # `vector([1, 2, 3], 3, INTEGER NOT NULL)`: a typed vector literal. The Java
5
+ # overloads per primitive array become an optional type: `:integer8`,
6
+ # `:integer16`, `:integer32`, `:integer` (default for integers), `:float32`
7
+ # and `:float` (default for floats).
8
+ class VectorLiteral
9
+ include Expression
10
+
11
+ TYPES = { integer8: 'INTEGER8', integer16: 'INTEGER16', integer32: 'INTEGER32', integer: 'INTEGER',
12
+ float32: 'FLOAT32', float: 'FLOAT' }.freeze
13
+ FLOAT_TYPES = %i[float32 float].freeze
14
+ MAX_SIZE = 4096
15
+
16
+ attr_reader :content, :type
17
+
18
+ def self.of(elements, type = nil)
19
+ raise ArgumentError, 'Vector elements must not be literal null' if elements.nil?
20
+
21
+ size = elements.size
22
+ unless (1..MAX_SIZE).cover?(size)
23
+ raise ArgumentError,
24
+ "'#{size}' is not a valid value. Must be a number in the range 1 to #{MAX_SIZE} (GQL 42N31)"
25
+ end
26
+
27
+ new(elements, type || (elements.all?(Integer) ? :integer : :float))
28
+ end
29
+
30
+ def initialize(content, type)
31
+ raise ArgumentError, "Unknown vector type #{type.inspect}" unless TYPES.key?(type)
32
+
33
+ @type = type
34
+ @content = (FLOAT_TYPES.include?(type) ? content.map(&:to_f) : content.map(&:to_i)).freeze
35
+ freeze
36
+ end
37
+
38
+ # The literal as Cypher text; it does not depend on any rendering option.
39
+ def as_string = "vector([#{content.join(', ')}], #{content.size}, #{TYPES[type]} NOT NULL)"
40
+ end
41
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CypherDSL
4
- VERSION = '0.1.0.alpha.1'
4
+ VERSION = '0.1.0.alpha.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cypher-dsl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.alpha.1
4
+ version: 0.1.0.alpha.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Heinrich Klobuczek
@@ -71,8 +71,9 @@ files:
71
71
  - lib/cypher_dsl/function_invocation.rb
72
72
  - lib/cypher_dsl/functions.rb
73
73
  - lib/cypher_dsl/has_label_condition.rb
74
- - lib/cypher_dsl/label_expression.rb
74
+ - lib/cypher_dsl/hint.rb
75
75
  - lib/cypher_dsl/label_operation.rb
76
+ - lib/cypher_dsl/labels.rb
76
77
  - lib/cypher_dsl/list_comprehension.rb
77
78
  - lib/cypher_dsl/list_expression.rb
78
79
  - lib/cypher_dsl/list_operator.rb
@@ -89,6 +90,7 @@ files:
89
90
  - lib/cypher_dsl/pattern_comprehension.rb
90
91
  - lib/cypher_dsl/pattern_expression.rb
91
92
  - lib/cypher_dsl/property.rb
93
+ - lib/cypher_dsl/property_exists_condition.rb
92
94
  - lib/cypher_dsl/quantified_path_pattern.rb
93
95
  - lib/cypher_dsl/raw_expression.rb
94
96
  - lib/cypher_dsl/reduction.rb
@@ -99,9 +101,13 @@ files:
99
101
  - lib/cypher_dsl/renderer/configuration.rb
100
102
  - lib/cypher_dsl/renderer/dialect.rb
101
103
  - lib/cypher_dsl/renderer/escaper.rb
104
+ - lib/cypher_dsl/renderer/generated_name_resolver.rb
105
+ - lib/cypher_dsl/renderer/name_resolver.rb
102
106
  - lib/cypher_dsl/renderer/visitor.rb
103
107
  - lib/cypher_dsl/renderer/visitor/expressions.rb
108
+ - lib/cypher_dsl/renderer/visitor/label_rendering.rb
104
109
  - lib/cypher_dsl/renderer/visitor/patterns.rb
110
+ - lib/cypher_dsl/search.rb
105
111
  - lib/cypher_dsl/sort_item.rb
106
112
  - lib/cypher_dsl/standalone_call.rb
107
113
  - lib/cypher_dsl/statement.rb
@@ -111,6 +117,8 @@ files:
111
117
  - lib/cypher_dsl/symbolic_name.rb
112
118
  - lib/cypher_dsl/unary_operation.rb
113
119
  - lib/cypher_dsl/union_query.rb
120
+ - lib/cypher_dsl/using_periodic_commit.rb
121
+ - lib/cypher_dsl/vector_literal.rb
114
122
  - lib/cypher_dsl/version.rb
115
123
  homepage: https://github.com/neo4jrb/cypher-dsl
116
124
  licenses:
@@ -134,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
142
  - !ruby/object:Gem::Version
135
143
  version: '0'
136
144
  requirements: []
137
- rubygems_version: 4.0.20
145
+ rubygems_version: 4.0.3
138
146
  specification_version: 4
139
147
  summary: A Ruby DSL (builder) for the Cypher query language
140
148
  test_files: []
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module CypherDSL
4
- # A label expression as in `(n:A|B)`, `(n:A&!B)` or `(n:(A|B)&C)`; built from
5
- # {Labels.exactly} and combined with {#or}, {#and} and {#negate}.
6
- class LabelExpression
7
- TYPES = %i[leaf conjunction disjunction negation].freeze
8
-
9
- attr_reader :type, :value, :operands
10
-
11
- def self.exactly(name) = new(:leaf, name.to_s, [])
12
-
13
- def initialize(type, value, operands)
14
- raise ArgumentError, "Unknown label expression type #{type.inspect}" unless TYPES.include?(type)
15
-
16
- @type = type
17
- @value = value
18
- @operands = operands.freeze
19
- freeze
20
- end
21
-
22
- def or(other) = LabelExpression.new(:disjunction, nil, [self, coerce(other)])
23
- def and(other) = LabelExpression.new(:conjunction, nil, [self, coerce(other)])
24
- def negate = LabelExpression.new(:negation, nil, [self])
25
- alias not negate
26
-
27
- def leaf? = type == :leaf
28
-
29
- private
30
-
31
- def coerce(other) = other.is_a?(LabelExpression) ? other : LabelExpression.exactly(other)
32
- end
33
-
34
- # Factory for label expressions: `Labels.exactly('A').or('B')`.
35
- module Labels
36
- module_function
37
-
38
- def exactly(name) = LabelExpression.exactly(name)
39
- end
40
- end