Skip to content

Commit 0bb1107

Browse files
Extract EqualityValueSet for filter value set re-use (#1239)
## Summary - Introduces `EqualityValueSet`, extracted from the former `RoutingValueSet`, as a reusable inclusive/exclusive set with `union`, `intersection`, and `negate` operations - Eliminates `RoutingValueSet` entirely — its two-line conversion logic now lives inline in `RoutingPicker#extract_eligible_routing_values` - Adds `FilterValueSetExtractor.for_equality` as a factory method that configures an extractor for `equal_to_any_of` filters using `EqualityValueSet`, reducing boilerplate for callers This is a preliminary refactor in support of `__typename`-based index narrowing (#1179), where `EqualityValueSet` and `FilterValueSetExtractor.for_equality` will be reused by the new narrowing logic.
1 parent 98aef55 commit 0bb1107

6 files changed

Lines changed: 175 additions & 158 deletions

File tree

elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query/routing_picker.rb

Lines changed: 4 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,7 @@ class DatastoreQuery
1414
# Responsible for picking routing values for a specific query based on the filters.
1515
class RoutingPicker
1616
def initialize(filter_node_interpreter:, schema_names:)
17-
all_values_set = RoutingValueSet::ALL
18-
empty_set = RoutingValueSet::EMPTY
19-
20-
@filter_value_set_extractor = Filtering::FilterValueSetExtractor.new(
21-
filter_node_interpreter,
22-
schema_names,
23-
all_values_set,
24-
empty_set
25-
) do |operator, filter_value|
26-
if operator == :equal_to_any_of
27-
# This calls `.compact` to remove `nil` filter_value values
28-
RoutingValueSet.of(filter_value.compact)
29-
end
30-
end
17+
@filter_value_set_extractor = Filtering::FilterValueSetExtractor.for_equality(filter_node_interpreter, schema_names)
3118
end
3219

3320
# Given a list of `filter_hashes` and a list of `routing_field_paths`, returns a list of
@@ -53,119 +40,18 @@ def initialize(filter_node_interpreter:, schema_names:)
5340
# end
5441
# ```
5542
def extract_eligible_routing_values(filter_hashes, routing_field_paths)
56-
@filter_value_set_extractor.extract_filter_value_set(filter_hashes, routing_field_paths)&.to_return_value
57-
end
58-
end
59-
60-
class RoutingValueSet < Data.define(:type, :routing_values)
61-
def self.of(routing_values)
62-
new(:inclusive, routing_values.to_set)
63-
end
64-
65-
def self.of_all_except(routing_values)
66-
new(:exclusive, routing_values.to_set)
67-
end
68-
69-
ALL = of_all_except([])
70-
EMPTY = of([])
71-
72-
def intersection(other)
73-
if inclusive? && other.inclusive?
74-
# Since both sets are inclusive, we can just delegate to `Set#intersection` here.
75-
RoutingValueSet.of(routing_values.intersection(other.routing_values))
76-
elsif exclusive? && other.exclusive?
77-
# Since both sets are exclusive, we need to return an exclusive set of the union of the
78-
# excluded values. For example, when dealing with positive integers:
79-
#
80-
# s1 = RoutingValueSet.of_all_except([1, 2, 3]) # > 3
81-
# s2 = RoutingValueSet.of_all_except([3, 4, 5]) # 1, 2, > 5
82-
#
83-
# s3 = s1.intersection(s2)
84-
#
85-
# Here s3 would be all values > 5 (the same as `RoutingValueSet.of_all_except([1, 2, 3, 4, 5])`)
86-
RoutingValueSet.of_all_except(routing_values.union(other.routing_values))
87-
else
88-
# Since one set is inclusive and one set is exclusive, we need to return an inclusive set of
89-
# `included_values - excluded_values`. For example, when dealing with positive integers:
90-
#
91-
# s1 = RoutingValueSet.of([1, 2, 3]) # 1, 2, 3
92-
# s2 = RoutingValueSet.of_all_except([3, 4, 5]) # 1, 2, > 5
93-
#
94-
# s3 = s1.intersection(s2)
95-
#
96-
# Here s3 would be just `1, 2`.
97-
included_values, excluded_values = get_included_and_excluded_values(other)
98-
RoutingValueSet.of(included_values - excluded_values)
99-
end
100-
end
101-
102-
def union(other)
103-
if inclusive? && other.inclusive?
104-
# Since both sets are inclusive, we can just delegate to `Set#union` here.
105-
RoutingValueSet.of(routing_values.union(other.routing_values))
106-
elsif exclusive? && other.exclusive?
107-
# Since both sets are exclusive, we need to return an exclusive set of the intersection of the
108-
# excluded values. For example, when dealing with positive integers:
109-
#
110-
# s1 = RoutingValueSet.of_all_except([1, 2, 3]) # > 3
111-
# s2 = RoutingValueSet.of_all_except([3, 4, 5]) # 1, 2, > 5
112-
#
113-
# s3 = s1.union(s2)
114-
#
115-
# Here s3 would be all 1, 2, > 3 (the same as `RoutingValueSet.of_all_except([3])`)
116-
RoutingValueSet.of_all_except(routing_values.intersection(other.routing_values))
117-
else
118-
# Since one set is inclusive and one set is exclusive, we need to return an exclusive set of
119-
# `excluded_values - included_values`. For example, when dealing with positive integers:
120-
#
121-
# s1 = RoutingValueSet.of([1, 2, 3]) # 1, 2, 3
122-
# s2 = RoutingValueSet.of_all_except([3, 4, 5]) # 1, 2, > 5
123-
#
124-
# s3 = s1.union(s2)
125-
#
126-
# Here s3 would be 1, 2, 3, > 5 (the same as `RoutingValueSet.of_all_except([4, 5])`)
127-
included_values, excluded_values = get_included_and_excluded_values(other)
128-
RoutingValueSet.of_all_except(excluded_values - included_values)
129-
end
130-
end
131-
132-
def negate
133-
with(type: INVERTED_TYPES.fetch(type))
134-
end
135-
136-
INVERTED_TYPES = {inclusive: :exclusive, exclusive: :inclusive}
137-
138-
def to_return_value
43+
result = @filter_value_set_extractor.extract_filter_value_set(filter_hashes, routing_field_paths)
13944
# Elasticsearch/OpenSearch have no routing value syntax to tell it to avoid searching a specific shard
14045
# (and the fact that we are excluding a routing value doesn't mean that other documents that
14146
# live on the same shard with different routing values can't match!) so we return `nil` to
14247
# force the datastore to search all shards.
143-
return nil if exclusive?
144-
145-
routing_values.to_a
146-
end
147-
148-
protected
149-
150-
def inclusive?
151-
type == :inclusive
152-
end
153-
154-
def exclusive?
155-
type == :exclusive
156-
end
157-
158-
private
159-
160-
def get_included_and_excluded_values(other)
161-
inclusive? ? [routing_values, other.routing_values] : [other.routing_values, routing_values]
48+
return nil if result.nil? || result.exclusive?
49+
result.values.to_a
16250
end
16351
end
16452

16553
# `Query::RoutingPicker` exists only for use by `Query` and is effectively private.
16654
private_constant :RoutingPicker
167-
# `RoutingValueSet` exists only for use here and is effectively private.
168-
private_constant :RoutingValueSet
16955

17056
# Steep is complaining that it can't find some `Query` but they are not in this file...
17157
# @dynamic shard_routing_values, effective_size, merge_with, search_index_expression, with, to_datastore_msearch_header_and_body
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright 2024 - 2026 Block, Inc.
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
#
7+
# frozen_string_literal: true
8+
9+
module ElasticGraph
10+
class GraphQL
11+
module Filtering
12+
# A set that can represent either a specific list of values or all values except a specific
13+
# list, with support for common set operations (union, intersection, negation). In contrast
14+
# to other set implementations that work with `FilterValueSetExtractor`, only only works with
15+
# `equal_to_any_of` filtering (hence the `EqualityValueSet` name).
16+
class EqualityValueSet < Data.define(:type, :values)
17+
# `Data.define` provides the following methods:
18+
# @dynamic initialize, type, values, with
19+
20+
def self.of(values)
21+
new(:inclusive, values.to_set)
22+
end
23+
24+
def self.of_all_except(values)
25+
new(:exclusive, values.to_set)
26+
end
27+
28+
ALL = of_all_except([])
29+
EMPTY = of([])
30+
31+
def intersection(other)
32+
if inclusive? && other.inclusive?
33+
# Since both sets are inclusive, we can just delegate to `Set#intersection` here.
34+
EqualityValueSet.of(values.intersection(other.values))
35+
elsif exclusive? && other.exclusive?
36+
# Since both sets are exclusive, we need to return an exclusive set of the union of the
37+
# excluded values. For example, when dealing with positive integers:
38+
#
39+
# s1 = EqualityValueSet.of_all_except([1, 2, 3]) # > 3
40+
# s2 = EqualityValueSet.of_all_except([3, 4, 5]) # 1, 2, > 5
41+
#
42+
# s3 = s1.intersection(s2)
43+
#
44+
# Here s3 would be all values > 5 (the same as `EqualityValueSet.of_all_except([1, 2, 3, 4, 5])`)
45+
EqualityValueSet.of_all_except(values.union(other.values))
46+
else
47+
# Since one set is inclusive and one set is exclusive, we need to return an inclusive set of
48+
# `included_values - excluded_values`. For example, when dealing with positive integers:
49+
#
50+
# s1 = EqualityValueSet.of([1, 2, 3]) # 1, 2, 3
51+
# s2 = EqualityValueSet.of_all_except([3, 4, 5]) # 1, 2, > 5
52+
#
53+
# s3 = s1.intersection(s2)
54+
#
55+
# Here s3 would be just `1, 2`.
56+
included_values, excluded_values = get_included_and_excluded_values(other)
57+
EqualityValueSet.of(included_values - excluded_values)
58+
end
59+
end
60+
61+
def union(other)
62+
if inclusive? && other.inclusive?
63+
# Since both sets are inclusive, we can just delegate to `Set#union` here.
64+
EqualityValueSet.of(values.union(other.values))
65+
elsif exclusive? && other.exclusive?
66+
# Since both sets are exclusive, we need to return an exclusive set of the intersection of the
67+
# excluded values. For example, when dealing with positive integers:
68+
#
69+
# s1 = EqualityValueSet.of_all_except([1, 2, 3]) # > 3
70+
# s2 = EqualityValueSet.of_all_except([3, 4, 5]) # 1, 2, > 5
71+
#
72+
# s3 = s1.union(s2)
73+
#
74+
# Here s3 would be all 1, 2, > 3 (the same as `EqualityValueSet.of_all_except([3])`)
75+
EqualityValueSet.of_all_except(values.intersection(other.values))
76+
else
77+
# Since one set is inclusive and one set is exclusive, we need to return an exclusive set of
78+
# `excluded_values - included_values`. For example, when dealing with positive integers:
79+
#
80+
# s1 = EqualityValueSet.of([1, 2, 3]) # 1, 2, 3
81+
# s2 = EqualityValueSet.of_all_except([3, 4, 5]) # 1, 2, > 5
82+
#
83+
# s3 = s1.union(s2)
84+
#
85+
# Here s3 would be 1, 2, 3, > 5 (the same as `EqualityValueSet.of_all_except([4, 5])`)
86+
included_values, excluded_values = get_included_and_excluded_values(other)
87+
EqualityValueSet.of_all_except(excluded_values - included_values)
88+
end
89+
end
90+
91+
def negate
92+
with(type: INVERTED_TYPES.fetch(type))
93+
end
94+
95+
INVERTED_TYPES = {inclusive: :exclusive, exclusive: :inclusive}
96+
97+
def inclusive?
98+
type == :inclusive
99+
end
100+
101+
def exclusive?
102+
type == :exclusive
103+
end
104+
105+
private
106+
107+
def get_included_and_excluded_values(other)
108+
inclusive? ? [values, other.values] : [other.values, values]
109+
end
110+
end
111+
end
112+
end
113+
end

elasticgraph-graphql/lib/elastic_graph/graphql/filtering/filter_value_set_extractor.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,25 @@
66
#
77
# frozen_string_literal: true
88

9+
require "elastic_graph/graphql/filtering/equality_value_set"
10+
911
module ElasticGraph
1012
class GraphQL
1113
module Filtering
1214
# Responsible for extracting a set of values from query filters, based on a using a custom
1315
# set type that is able to efficiently model the "all values" case.
1416
class FilterValueSetExtractor
17+
# Factory method for building a `FilterValueSetExtractor` that uses `EqualityValueSet` to
18+
# extract the set of values matched by `equal_to_any_of` filters on the target fields.
19+
def self.for_equality(filter_node_interpreter, schema_names)
20+
new(filter_node_interpreter, schema_names, EqualityValueSet::ALL, EqualityValueSet::EMPTY) do |operator, filter_value|
21+
if operator == :equal_to_any_of
22+
# This calls `.compact` to remove `nil` filter_value values
23+
EqualityValueSet.of(filter_value.compact)
24+
end
25+
end
26+
end
27+
1528
def initialize(filter_node_interpreter, schema_names, all_values_set, empty_set, &build_set_for_filter)
1629
@filter_node_interpreter = filter_node_interpreter
1730
@schema_names = schema_names

elasticgraph-graphql/sig/elastic_graph/graphql/datastore_query/routing_picker.rbs

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,49 +14,10 @@ module ElasticGraph
1414

1515
private
1616

17-
@filter_value_set_extractor: Filtering::FilterValueSetExtractor[RoutingValueSet]
17+
@filter_value_set_extractor: Filtering::FilterValueSetExtractor[Filtering::EqualityValueSet]
1818
end
1919

2020
type routingValue = untyped
21-
22-
type routingValueSetType = :inclusive | :exclusive
23-
24-
class RoutingValueSetSupertype
25-
attr_reader type: routingValueSetType
26-
attr_reader routing_values: ::Set[routingValue]
27-
28-
def initialize: (routingValueSetType, ::Set[routingValue]) -> void
29-
def self.with: (
30-
type: routingValueSetType,
31-
routing_values: ::Set[routingValue]
32-
) -> RoutingValueSet
33-
34-
def with: (
35-
?type: routingValueSetType,
36-
?routing_values: ::Set[routingValue]
37-
) -> RoutingValueSet
38-
end
39-
40-
class RoutingValueSet < RoutingValueSetSupertype
41-
include Support::_NegatableSet[RoutingValueSet]
42-
def self.of: (::Enumerable[routingValue]) -> RoutingValueSet
43-
def self.of_all_except: (::Enumerable[routingValue]) -> RoutingValueSet
44-
45-
ALL: RoutingValueSet
46-
EMPTY: RoutingValueSet
47-
INVERTED_TYPES: ::Hash[routingValueSetType, routingValueSetType]
48-
49-
def to_return_value: () -> ::Array[routingValue]?
50-
51-
def inclusive?: () -> bool
52-
def exclusive?: () -> bool
53-
54-
private
55-
56-
def get_included_and_excluded_values: (
57-
RoutingValueSet
58-
) -> [::Set[routingValue], ::Set[routingValue]]
59-
end
6021
end
6122
end
6223
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
module ElasticGraph
2+
class GraphQL
3+
module Filtering
4+
type equalityValueSetType = :inclusive | :exclusive
5+
6+
class EqualityValueSet
7+
include Support::_NegatableSet[EqualityValueSet]
8+
9+
attr_reader type: equalityValueSetType
10+
attr_reader values: ::Set[untyped]
11+
12+
def initialize: (equalityValueSetType, ::Set[untyped]) -> void
13+
14+
def self.new:
15+
(equalityValueSetType, ::Set[untyped]) -> instance
16+
| (type: equalityValueSetType, values: ::Set[untyped]) -> instance
17+
18+
def with: (
19+
?type: equalityValueSetType,
20+
?values: ::Set[untyped]
21+
) -> EqualityValueSet
22+
23+
def self.of: (::Enumerable[untyped]) -> EqualityValueSet
24+
def self.of_all_except: (::Enumerable[untyped]) -> EqualityValueSet
25+
26+
ALL: EqualityValueSet
27+
EMPTY: EqualityValueSet
28+
INVERTED_TYPES: ::Hash[equalityValueSetType, equalityValueSetType]
29+
30+
def inclusive?: () -> bool
31+
def exclusive?: () -> bool
32+
33+
private
34+
35+
def get_included_and_excluded_values: (EqualityValueSet) -> [::Set[untyped], ::Set[untyped]]
36+
end
37+
end
38+
end
39+
end

elasticgraph-graphql/sig/elastic_graph/graphql/filtering/filter_value_set_extractor.rbs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ module ElasticGraph
44
class FilterValueSetExtractor[S < Support::_NegatableSet[S]]
55
type setType[out S] = S | singleton(UnboundedSetWithExclusions)
66

7+
def self.for_equality: (
8+
FilterNodeInterpreter,
9+
SchemaArtifacts::RuntimeMetadata::SchemaElementNames
10+
) -> FilterValueSetExtractor[EqualityValueSet]
11+
712
def initialize: (
813
FilterNodeInterpreter,
914
SchemaArtifacts::RuntimeMetadata::SchemaElementNames,

0 commit comments

Comments
 (0)