elasticgraph-graphql 0.18.0.0
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/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
|