elasticgraph-graphql 0.18.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +3 -0
- data/elasticgraph-graphql.gemspec +23 -0
- data/lib/elastic_graph/graphql/aggregation/composite_grouping_adapter.rb +79 -0
- data/lib/elastic_graph/graphql/aggregation/computation.rb +39 -0
- data/lib/elastic_graph/graphql/aggregation/date_histogram_grouping.rb +83 -0
- data/lib/elastic_graph/graphql/aggregation/field_path_encoder.rb +47 -0
- data/lib/elastic_graph/graphql/aggregation/field_term_grouping.rb +26 -0
- data/lib/elastic_graph/graphql/aggregation/key.rb +87 -0
- data/lib/elastic_graph/graphql/aggregation/nested_sub_aggregation.rb +37 -0
- data/lib/elastic_graph/graphql/aggregation/non_composite_grouping_adapter.rb +129 -0
- data/lib/elastic_graph/graphql/aggregation/path_segment.rb +31 -0
- data/lib/elastic_graph/graphql/aggregation/query.rb +172 -0
- data/lib/elastic_graph/graphql/aggregation/query_adapter.rb +345 -0
- data/lib/elastic_graph/graphql/aggregation/query_optimizer.rb +187 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb +41 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/count_detail.rb +44 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/grouped_by.rb +30 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/node.rb +64 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb +83 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/sub_aggregations.rb +82 -0
- data/lib/elastic_graph/graphql/aggregation/script_term_grouping.rb +32 -0
- data/lib/elastic_graph/graphql/aggregation/term_grouping.rb +118 -0
- data/lib/elastic_graph/graphql/client.rb +43 -0
- data/lib/elastic_graph/graphql/config.rb +81 -0
- data/lib/elastic_graph/graphql/datastore_query/document_paginator.rb +100 -0
- data/lib/elastic_graph/graphql/datastore_query/index_expression_builder.rb +142 -0
- data/lib/elastic_graph/graphql/datastore_query/paginator.rb +199 -0
- data/lib/elastic_graph/graphql/datastore_query/routing_picker.rb +239 -0
- data/lib/elastic_graph/graphql/datastore_query.rb +372 -0
- data/lib/elastic_graph/graphql/datastore_response/document.rb +78 -0
- data/lib/elastic_graph/graphql/datastore_response/search_response.rb +79 -0
- data/lib/elastic_graph/graphql/datastore_search_router.rb +151 -0
- data/lib/elastic_graph/graphql/decoded_cursor.rb +120 -0
- data/lib/elastic_graph/graphql/filtering/boolean_query.rb +45 -0
- data/lib/elastic_graph/graphql/filtering/field_path.rb +81 -0
- data/lib/elastic_graph/graphql/filtering/filter_args_translator.rb +58 -0
- data/lib/elastic_graph/graphql/filtering/filter_interpreter.rb +526 -0
- data/lib/elastic_graph/graphql/filtering/filter_value_set_extractor.rb +148 -0
- data/lib/elastic_graph/graphql/filtering/range_query.rb +56 -0
- data/lib/elastic_graph/graphql/http_endpoint.rb +229 -0
- data/lib/elastic_graph/graphql/monkey_patches/schema_field.rb +56 -0
- data/lib/elastic_graph/graphql/monkey_patches/schema_object.rb +48 -0
- data/lib/elastic_graph/graphql/query_adapter/filters.rb +161 -0
- data/lib/elastic_graph/graphql/query_adapter/pagination.rb +27 -0
- data/lib/elastic_graph/graphql/query_adapter/requested_fields.rb +124 -0
- data/lib/elastic_graph/graphql/query_adapter/sort.rb +32 -0
- data/lib/elastic_graph/graphql/query_details_tracker.rb +60 -0
- data/lib/elastic_graph/graphql/query_executor.rb +200 -0
- data/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb +49 -0
- data/lib/elastic_graph/graphql/resolvers/graphql_adapter.rb +114 -0
- data/lib/elastic_graph/graphql/resolvers/list_records.rb +29 -0
- data/lib/elastic_graph/graphql/resolvers/nested_relationships.rb +74 -0
- data/lib/elastic_graph/graphql/resolvers/query_adapter.rb +85 -0
- data/lib/elastic_graph/graphql/resolvers/query_source.rb +46 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/array_adapter.rb +71 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/generic_adapter.rb +65 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/page_info.rb +82 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder.rb +40 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection.rb +42 -0
- data/lib/elastic_graph/graphql/resolvers/resolvable_value.rb +56 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +35 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/date.rb +64 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/date_time.rb +60 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/local_time.rb +30 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/longs.rb +47 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/no_op.rb +24 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/time_zone.rb +44 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/untyped.rb +32 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones.rb +634 -0
- data/lib/elastic_graph/graphql/schema/arguments.rb +78 -0
- data/lib/elastic_graph/graphql/schema/enum_value.rb +30 -0
- data/lib/elastic_graph/graphql/schema/field.rb +147 -0
- data/lib/elastic_graph/graphql/schema/relation_join.rb +103 -0
- data/lib/elastic_graph/graphql/schema/type.rb +263 -0
- data/lib/elastic_graph/graphql/schema.rb +164 -0
- data/lib/elastic_graph/graphql.rb +253 -0
- data/script/dump_time_zones +81 -0
- data/script/dump_time_zones.java +17 -0
- metadata +503 -0
@@ -0,0 +1,187 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "elastic_graph/graphql/aggregation/key"
|
10
|
+
|
11
|
+
module ElasticGraph
|
12
|
+
class GraphQL
|
13
|
+
module Aggregation
|
14
|
+
# This class is used by `DatastoreQuery.perform` to optimize away an inefficiency that's present in
|
15
|
+
# our aggregations API. To explain what this does, it's useful to see an example:
|
16
|
+
#
|
17
|
+
# ```
|
18
|
+
# query WigdetsBySizeAndColor($filter: WidgetFilterInput) {
|
19
|
+
# by_size: widgetAggregations(filter: $filter) {
|
20
|
+
# edges { node {
|
21
|
+
# size
|
22
|
+
# count
|
23
|
+
# } }
|
24
|
+
# }
|
25
|
+
#
|
26
|
+
# by_color: widgetAggregations(filter: $filter) {
|
27
|
+
# edges { node {
|
28
|
+
# color
|
29
|
+
# count
|
30
|
+
# } }
|
31
|
+
# }
|
32
|
+
# }
|
33
|
+
# ```
|
34
|
+
#
|
35
|
+
# With this API, two separate datastore queries get built--one for `by_size`, and one
|
36
|
+
# for `by_color`. While we're able to send them to the datastore in a single `msearch` request,
|
37
|
+
# as it allows a single search to have multiple aggregations in it. The aggregations
|
38
|
+
# API we offered before April 2023 directly supported this, allowing for more efficient
|
39
|
+
# queries. (But it had other significant downsides).
|
40
|
+
#
|
41
|
+
# We found that sending 2 queries is significantly slower than sending one combined query
|
42
|
+
# (from benchmarks/aggregations_old_vs_new_api.rb):
|
43
|
+
#
|
44
|
+
# Benchmarks for old API (300 times):
|
45
|
+
# Average took value: 15
|
46
|
+
# Median took value: 14
|
47
|
+
# P99 took value: 45
|
48
|
+
#
|
49
|
+
# Benchmarks for new API (300 times):
|
50
|
+
# Average took value: 28
|
51
|
+
# Median took value: 25
|
52
|
+
# P99 took value: 75
|
53
|
+
#
|
54
|
+
# This class optimizes this case by merging `DatastoreQuery` objects together when we can safely do so,
|
55
|
+
# in order to execute fewer datastore queries. Notably, while this was designed for this specific
|
56
|
+
# aggregations case, the merging logic can also apply in non-aggregations case.
|
57
|
+
#
|
58
|
+
# Note that we want to err on the side of safety here. We only merge queries if their datastore
|
59
|
+
# payloads are byte-for-byte identical when aggregations are excluded. There are some cases where
|
60
|
+
# we _could_ merge slightly differing queries in clever ways (for example, if the only difference is
|
61
|
+
# `track_total_hits: false` vs `track_total_hits: true`, we could merge to a single query with
|
62
|
+
# `track_total_hits: true`), but that's significantly more complex and error prone, so we do not do it.
|
63
|
+
# We can always improve this further in the future to cover more cases.
|
64
|
+
#
|
65
|
+
# NOTE: the `QueryOptimizer` assumes that `Aggregation::Query` will always produce aggregation keys
|
66
|
+
# using `Aggregation::Query#name` such that `Aggregation::Key.extract_aggregation_name_from` is able
|
67
|
+
# to extract the original name from response keys. If that is violated, it will not work properly and
|
68
|
+
# subtle bugs can result. However, we have a test helper method which is hooked into our unit and
|
69
|
+
# integration tests for `DatastoreQuery` (`verify_aggregations_satisfy_optimizer_requirements`) which
|
70
|
+
# verifies that this requirement is satisfied.
|
71
|
+
class QueryOptimizer
|
72
|
+
def self.optimize_queries(queries)
|
73
|
+
return {} if queries.empty?
|
74
|
+
optimizer = new(queries, logger: (_ = queries.first).logger)
|
75
|
+
responses_by_query = yield optimizer.merged_queries
|
76
|
+
optimizer.unmerge_responses(responses_by_query)
|
77
|
+
end
|
78
|
+
|
79
|
+
def initialize(original_queries, logger:)
|
80
|
+
@original_queries = original_queries
|
81
|
+
@logger = logger
|
82
|
+
last_id = 0
|
83
|
+
@unique_prefix_by_query = ::Hash.new { |h, k| h[k] = "#{last_id += 1}_" }
|
84
|
+
end
|
85
|
+
|
86
|
+
def merged_queries
|
87
|
+
original_queries_by_merged_query.keys
|
88
|
+
end
|
89
|
+
|
90
|
+
def unmerge_responses(responses_by_merged_query)
|
91
|
+
original_queries_by_merged_query.flat_map do |merged, originals|
|
92
|
+
# When we only had a single query to start with, we didn't change the query at all, and don't need to unmerge the response.
|
93
|
+
needs_unmerging = originals.size > 1
|
94
|
+
|
95
|
+
originals.filter_map do |orig|
|
96
|
+
if (merged_response = responses_by_merged_query[merged])
|
97
|
+
response = needs_unmerging ? unmerge_response(merged_response, orig) : merged_response
|
98
|
+
[orig, response]
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end.to_h
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
def original_queries_by_merged_query
|
107
|
+
@original_queries_by_merged_query ||= queries_by_merge_key.values.to_h do |original_queries|
|
108
|
+
[merge_queries(original_queries), original_queries]
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
NO_AGGREGATIONS = {}
|
113
|
+
|
114
|
+
def queries_by_merge_key
|
115
|
+
@original_queries.group_by do |query|
|
116
|
+
# Here we group queries in the simplest, safest way possible: queries are safe to merge if
|
117
|
+
# their datastore payloads are byte-for-byte identical, excluding aggregations.
|
118
|
+
query.with(aggregations: NO_AGGREGATIONS)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def merge_queries(queries)
|
123
|
+
# If we only have a single query, there's nothing to merge!
|
124
|
+
return (_ = queries.first) if queries.one?
|
125
|
+
|
126
|
+
all_aggs_by_name = queries.flat_map do |query|
|
127
|
+
# It's possible for two queries to have aggregations with the same name but different parameters.
|
128
|
+
# In a merged query, each aggregation must have a different name. Here we guarantee that by adding
|
129
|
+
# a numeric prefix to the aggregations. For example, if both `query1` and `query2` have a `by_size`
|
130
|
+
# aggregation, on the merged query we'll have a `1_by_size` aggregation and a `2_by_size` aggregation.
|
131
|
+
prefix = @unique_prefix_by_query[query]
|
132
|
+
query.aggregations.values.map do |agg|
|
133
|
+
agg.with(name: "#{prefix}#{agg.name}")
|
134
|
+
end
|
135
|
+
end.to_h { |agg| [agg.name, agg] }
|
136
|
+
|
137
|
+
@logger.info({
|
138
|
+
"message_type" => "AggregationQueryOptimizerMergedQueries",
|
139
|
+
"query_count" => queries.size,
|
140
|
+
"aggregation_count" => all_aggs_by_name.size,
|
141
|
+
"aggregation_names" => all_aggs_by_name.keys.sort
|
142
|
+
})
|
143
|
+
|
144
|
+
(_ = queries.first).with(aggregations: all_aggs_by_name)
|
145
|
+
end
|
146
|
+
|
147
|
+
# "Unmerges" a response to convert it to what it woulud have been if we hadn't merged queries.
|
148
|
+
# To do that, we need to do two things:
|
149
|
+
#
|
150
|
+
# - Filter down the aggregations to just the ones that are for the original query.
|
151
|
+
# - Remove the query-specific prefix (e.g. `1_`) from the parts of the response that
|
152
|
+
# contain the aggregation name.
|
153
|
+
def unmerge_response(response_from_merged_query, original_query)
|
154
|
+
# If there are no aggregations, there's nothing to unmerge--just return it as is.
|
155
|
+
return response_from_merged_query unless (aggs = response_from_merged_query["aggregations"])
|
156
|
+
|
157
|
+
prefix = @unique_prefix_by_query[original_query]
|
158
|
+
agg_names = original_query.aggregations.keys.map { |name| "#{prefix}#{name}" }.to_set
|
159
|
+
|
160
|
+
filtered_aggs = aggs
|
161
|
+
.select { |key, agg_data| agg_names.include?(Key.extract_aggregation_name_from(key)) }
|
162
|
+
.to_h do |key, agg_data|
|
163
|
+
[key.delete_prefix(prefix), strip_prefix_from_agg_data(agg_data, prefix, key)]
|
164
|
+
end
|
165
|
+
|
166
|
+
response_from_merged_query.merge("aggregations" => filtered_aggs)
|
167
|
+
end
|
168
|
+
|
169
|
+
def strip_prefix_from_agg_data(agg_data, prefix, key)
|
170
|
+
case agg_data
|
171
|
+
when ::Hash
|
172
|
+
agg_data.to_h do |sub_key, sub_data|
|
173
|
+
sub_key = sub_key.delete_prefix(prefix) if sub_key.start_with?(key)
|
174
|
+
[sub_key, strip_prefix_from_agg_data(sub_data, prefix, key)]
|
175
|
+
end
|
176
|
+
when ::Array
|
177
|
+
agg_data.map do |element|
|
178
|
+
strip_prefix_from_agg_data(element, prefix, key)
|
179
|
+
end
|
180
|
+
else
|
181
|
+
agg_data
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "elastic_graph/graphql/aggregation/key"
|
10
|
+
require "elastic_graph/graphql/aggregation/path_segment"
|
11
|
+
|
12
|
+
module ElasticGraph
|
13
|
+
class GraphQL
|
14
|
+
module Aggregation
|
15
|
+
module Resolvers
|
16
|
+
class AggregatedValues < ::Data.define(:aggregation_name, :bucket, :field_path)
|
17
|
+
def can_resolve?(field:, object:)
|
18
|
+
true
|
19
|
+
end
|
20
|
+
|
21
|
+
def resolve(field:, object:, args:, context:, lookahead:)
|
22
|
+
return with(field_path: field_path + [PathSegment.for(field: field, lookahead: lookahead)]) if field.type.object?
|
23
|
+
|
24
|
+
key = Key::AggregatedValue.new(
|
25
|
+
aggregation_name: aggregation_name,
|
26
|
+
field_path: field_path.map(&:name_in_graphql_query),
|
27
|
+
function_name: field.name_in_index.to_s
|
28
|
+
)
|
29
|
+
|
30
|
+
result = bucket.fetch(key.encode)
|
31
|
+
|
32
|
+
# Aggregated value results always have a `value` key; in addition, for `date` field, they also have a `value_as_string`.
|
33
|
+
# In that case, `value` is a number (e.g. ms since epoch) whereas `value_as_string` is a formatted value. ElasticGraph
|
34
|
+
# works with date types as formatted strings, so we need to use `value_as_string` here if it is present.
|
35
|
+
result.fetch("value_as_string") { result.fetch("value") }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "elastic_graph/graphql/resolvers/resolvable_value"
|
10
|
+
|
11
|
+
module ElasticGraph
|
12
|
+
class GraphQL
|
13
|
+
module Aggregation
|
14
|
+
module Resolvers
|
15
|
+
# Resolves the detailed `count` sub-fields of a sub-aggregation. It's an object because
|
16
|
+
# the count we get from the datastore may not be accurate and we have multiple
|
17
|
+
# fields we expose to give the client control over how much detail they want.
|
18
|
+
#
|
19
|
+
# Note: for now our resolver logic only uses the bucket fields returned to us by the datastore,
|
20
|
+
# but I believe we may have some opportunities to provide more accurate responses to these when custom shard
|
21
|
+
# routing and/or index rollover are in use. For example, when grouping on the custom shard routing field,
|
22
|
+
# we know that no term bucket will have data from more than one shard. The datastore isn't aware of our
|
23
|
+
# custom shard routing logic, though, and can't account for that in what it returns, so it may indicate
|
24
|
+
# a potential error upper bound where we can deduce there is none.
|
25
|
+
class CountDetail < GraphQL::Resolvers::ResolvableValue.new(:bucket)
|
26
|
+
# The (potentially approximate) `doc_count` returned by the datastore for a bucket.
|
27
|
+
def approximate_value
|
28
|
+
@approximate_value ||= bucket.fetch("doc_count")
|
29
|
+
end
|
30
|
+
|
31
|
+
# The `doc_count`, if we know it was exact. (Otherwise, returns `nil`).
|
32
|
+
def exact_value
|
33
|
+
approximate_value if approximate_value == upper_bound
|
34
|
+
end
|
35
|
+
|
36
|
+
# The upper bound on how large the doc count could be.
|
37
|
+
def upper_bound
|
38
|
+
@upper_bound ||= bucket.fetch("doc_count_error_upper_bound") + approximate_value
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "elastic_graph/graphql/aggregation/field_path_encoder"
|
10
|
+
|
11
|
+
module ElasticGraph
|
12
|
+
class GraphQL
|
13
|
+
module Aggregation
|
14
|
+
module Resolvers
|
15
|
+
class GroupedBy < ::Data.define(:bucket, :field_path)
|
16
|
+
def can_resolve?(field:, object:)
|
17
|
+
true
|
18
|
+
end
|
19
|
+
|
20
|
+
def resolve(field:, object:, args:, context:, lookahead:)
|
21
|
+
new_field_path = field_path + [PathSegment.for(field: field, lookahead: lookahead)]
|
22
|
+
return with(field_path: new_field_path) if field.type.object?
|
23
|
+
|
24
|
+
bucket.fetch("key").fetch(FieldPathEncoder.encode(new_field_path.map(&:name_in_graphql_query)))
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "elastic_graph/graphql/aggregation/resolvers/aggregated_values"
|
10
|
+
require "elastic_graph/graphql/aggregation/resolvers/grouped_by"
|
11
|
+
require "elastic_graph/graphql/decoded_cursor"
|
12
|
+
require "elastic_graph/graphql/resolvers/resolvable_value"
|
13
|
+
|
14
|
+
module ElasticGraph
|
15
|
+
class GraphQL
|
16
|
+
module Aggregation
|
17
|
+
module Resolvers
|
18
|
+
class Node < GraphQL::Resolvers::ResolvableValue.new(:query, :parent_queries, :bucket, :field_path)
|
19
|
+
# This file defines a subclass of `Node` and can't be loaded until `Node` has been defined.
|
20
|
+
require "elastic_graph/graphql/aggregation/resolvers/sub_aggregations"
|
21
|
+
|
22
|
+
def grouped_by
|
23
|
+
@grouped_by ||= GroupedBy.new(bucket, field_path)
|
24
|
+
end
|
25
|
+
|
26
|
+
def aggregated_values
|
27
|
+
@aggregated_values ||= AggregatedValues.new(query.name, bucket, field_path)
|
28
|
+
end
|
29
|
+
|
30
|
+
def sub_aggregations
|
31
|
+
@sub_aggregations ||= SubAggregations.new(
|
32
|
+
schema_element_names,
|
33
|
+
query.sub_aggregations,
|
34
|
+
parent_queries + [query],
|
35
|
+
bucket,
|
36
|
+
field_path
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
def count
|
41
|
+
bucket.fetch("doc_count")
|
42
|
+
end
|
43
|
+
|
44
|
+
def count_detail
|
45
|
+
@count_detail ||= CountDetail.new(schema_element_names, bucket)
|
46
|
+
end
|
47
|
+
|
48
|
+
def cursor
|
49
|
+
# If there's no `key`, then we aren't grouping by anything. We just have a single aggregation
|
50
|
+
# bucket containing computed values over the entire set of filtered documents. In that case,
|
51
|
+
# we still need a pagination cursor but we have no "key" to speak of that we can encode. Instead,
|
52
|
+
# we use the special SINGLETON cursor defined for this case.
|
53
|
+
@cursor ||=
|
54
|
+
if (key = bucket.fetch("key")).empty?
|
55
|
+
DecodedCursor::SINGLETON
|
56
|
+
else
|
57
|
+
DecodedCursor.new(key)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "elastic_graph/graphql/aggregation/resolvers/node"
|
10
|
+
require "elastic_graph/graphql/datastore_query"
|
11
|
+
require "elastic_graph/graphql/resolvers/relay_connection/generic_adapter"
|
12
|
+
|
13
|
+
module ElasticGraph
|
14
|
+
class GraphQL
|
15
|
+
module Aggregation
|
16
|
+
module Resolvers
|
17
|
+
module RelayConnectionBuilder
|
18
|
+
def self.build_from_search_response(query:, search_response:, schema_element_names:)
|
19
|
+
build_from_buckets(query: query, parent_queries: [], schema_element_names: schema_element_names) do
|
20
|
+
extract_buckets_from(search_response, for_query: query)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.build_from_buckets(query:, parent_queries:, schema_element_names:, field_path: [], &build_buckets)
|
25
|
+
GraphQL::Resolvers::RelayConnection::GenericAdapter.new(
|
26
|
+
schema_element_names: schema_element_names,
|
27
|
+
raw_nodes: raw_nodes_for(query, parent_queries, schema_element_names, field_path, &build_buckets),
|
28
|
+
paginator: query.paginator,
|
29
|
+
get_total_edge_count: -> {},
|
30
|
+
to_sort_value: ->(node, decoded_cursor) do
|
31
|
+
query.groupings.map do |grouping|
|
32
|
+
DatastoreQuery::Paginator::SortValue.new(
|
33
|
+
from_item: (_ = node).bucket.fetch("key").fetch(grouping.key),
|
34
|
+
from_cursor: decoded_cursor.sort_values.fetch(grouping.key),
|
35
|
+
sort_direction: :asc # we don't yet support any alternate sorting.
|
36
|
+
)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
private_class_method def self.raw_nodes_for(query, parent_queries, schema_element_names, field_path)
|
43
|
+
# The `DecodedCursor::SINGLETON` is a special case, so handle it here.
|
44
|
+
return [] if query.paginator.paginated_from_singleton_cursor?
|
45
|
+
|
46
|
+
yield.map do |bucket|
|
47
|
+
Node.new(
|
48
|
+
schema_element_names: schema_element_names,
|
49
|
+
query: query,
|
50
|
+
parent_queries: parent_queries,
|
51
|
+
bucket: bucket,
|
52
|
+
field_path: field_path
|
53
|
+
)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private_class_method def self.extract_buckets_from(search_response, for_query:)
|
58
|
+
search_response.raw_data.dig(
|
59
|
+
"aggregations",
|
60
|
+
for_query.name,
|
61
|
+
"buckets"
|
62
|
+
) || [build_bucket(for_query, search_response.raw_data)]
|
63
|
+
end
|
64
|
+
|
65
|
+
private_class_method def self.build_bucket(query, response)
|
66
|
+
defaults = {
|
67
|
+
"key" => query.groupings.to_h { |g| [g.key, nil] },
|
68
|
+
"doc_count" => response.dig("hits", "total", "value") || 0
|
69
|
+
}
|
70
|
+
|
71
|
+
empty_bucket_computations = query.computations.to_h do |computation|
|
72
|
+
[computation.key(aggregation_name: query.name), {"value" => computation.detail.empty_bucket_value}]
|
73
|
+
end
|
74
|
+
|
75
|
+
defaults
|
76
|
+
.merge(empty_bucket_computations)
|
77
|
+
.merge(response["aggregations"] || {})
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "elastic_graph/graphql/aggregation/composite_grouping_adapter"
|
10
|
+
require "elastic_graph/graphql/aggregation/key"
|
11
|
+
require "elastic_graph/graphql/aggregation/non_composite_grouping_adapter"
|
12
|
+
require "elastic_graph/graphql/aggregation/resolvers/count_detail"
|
13
|
+
require "elastic_graph/graphql/decoded_cursor"
|
14
|
+
require "elastic_graph/graphql/resolvers/resolvable_value"
|
15
|
+
|
16
|
+
module ElasticGraph
|
17
|
+
class GraphQL
|
18
|
+
module Aggregation
|
19
|
+
module Resolvers
|
20
|
+
class SubAggregations < ::Data.define(:schema_element_names, :sub_aggregations, :parent_queries, :sub_aggs_by_name, :field_path)
|
21
|
+
def can_resolve?(field:, object:)
|
22
|
+
true
|
23
|
+
end
|
24
|
+
|
25
|
+
def resolve(field:, object:, args:, context:, lookahead:)
|
26
|
+
path_segment = PathSegment.for(field: field, lookahead: lookahead)
|
27
|
+
new_field_path = field_path + [path_segment]
|
28
|
+
return with(field_path: new_field_path) unless field.type.elasticgraph_category == :nested_sub_aggregation_connection
|
29
|
+
|
30
|
+
aggregation_name = path_segment.name_in_graphql_query
|
31
|
+
sub_agg_query = sub_aggregations.fetch(aggregation_name).query
|
32
|
+
|
33
|
+
RelayConnectionBuilder.build_from_buckets(
|
34
|
+
query: sub_agg_query,
|
35
|
+
parent_queries: parent_queries,
|
36
|
+
schema_element_names: schema_element_names,
|
37
|
+
field_path: new_field_path
|
38
|
+
) { extract_buckets(aggregation_name, args) }
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def extract_buckets(aggregation_name, args)
|
44
|
+
# When the client passes `first: 0`, we omit the sub-aggregation from the query body entirely,
|
45
|
+
# and it wont' be in `sub_aggs_by_name`. Instead, we can just return an empty list of buckets.
|
46
|
+
return [] if args[schema_element_names.first] == 0
|
47
|
+
|
48
|
+
sub_agg = sub_aggs_by_name.fetch(Key.encode(parent_queries.map(&:name) + [aggregation_name]))
|
49
|
+
meta = sub_agg.fetch("meta")
|
50
|
+
|
51
|
+
# When the sub-aggregation node of the GraphQL query has a `filter` argument, the direct sub-aggregation returned by
|
52
|
+
# the datastore will be the unfiltered sub-aggregation. To get the filtered sub-aggregation (the data our client
|
53
|
+
# actually cares about), we have a sub-aggregation under that.
|
54
|
+
#
|
55
|
+
# To indicate this case, our query includes a `meta` field which which tells us which sub-key # has the actual data we care about in it:
|
56
|
+
# - If grouping has been applied (leading to multiple buckets): `meta: {buckets_path: [path, to, bucket]}`
|
57
|
+
# - If no grouping has been applied (leading to a single bucket): `meta: {bucket_path: [path, to, bucket]}`
|
58
|
+
if (buckets_path = meta["buckets_path"])
|
59
|
+
bucket_adapter = BUCKET_ADAPTERS.fetch(sub_agg.dig("meta", "adapter"))
|
60
|
+
bucket_adapter.prepare_response_buckets(sub_agg, buckets_path, meta)
|
61
|
+
else
|
62
|
+
singleton_bucket =
|
63
|
+
if (bucket_path = meta["bucket_path"])
|
64
|
+
sub_agg.dig(*bucket_path)
|
65
|
+
else
|
66
|
+
sub_agg
|
67
|
+
end
|
68
|
+
|
69
|
+
# When we have a single ungrouped bucket, we never have any error on the `doc_count`.
|
70
|
+
# Our resolver logic expects it to be present, though.
|
71
|
+
[singleton_bucket.merge({"doc_count_error_upper_bound" => 0})]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
BUCKET_ADAPTERS = [CompositeGroupingAdapter, NonCompositeGroupingAdapter].to_h do |adapter|
|
76
|
+
[adapter.meta_name, adapter]
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "elastic_graph/graphql/aggregation/term_grouping"
|
10
|
+
|
11
|
+
module ElasticGraph
|
12
|
+
class GraphQL
|
13
|
+
module Aggregation
|
14
|
+
# Used for term groupings that use a script instead of a field
|
15
|
+
class ScriptTermGrouping < Support::MemoizableData.define(:field_path, :script_id, :params)
|
16
|
+
# @dynamic field_path
|
17
|
+
include TermGrouping
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def terms_subclause
|
22
|
+
{
|
23
|
+
"script" => {
|
24
|
+
"id" => script_id,
|
25
|
+
"params" => params.merge({"field" => encoded_index_field_path})
|
26
|
+
}
|
27
|
+
}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|