elasticgraph-graphql 0.18.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +3 -0
  4. data/elasticgraph-graphql.gemspec +23 -0
  5. data/lib/elastic_graph/graphql/aggregation/composite_grouping_adapter.rb +79 -0
  6. data/lib/elastic_graph/graphql/aggregation/computation.rb +39 -0
  7. data/lib/elastic_graph/graphql/aggregation/date_histogram_grouping.rb +83 -0
  8. data/lib/elastic_graph/graphql/aggregation/field_path_encoder.rb +47 -0
  9. data/lib/elastic_graph/graphql/aggregation/field_term_grouping.rb +26 -0
  10. data/lib/elastic_graph/graphql/aggregation/key.rb +87 -0
  11. data/lib/elastic_graph/graphql/aggregation/nested_sub_aggregation.rb +37 -0
  12. data/lib/elastic_graph/graphql/aggregation/non_composite_grouping_adapter.rb +129 -0
  13. data/lib/elastic_graph/graphql/aggregation/path_segment.rb +31 -0
  14. data/lib/elastic_graph/graphql/aggregation/query.rb +172 -0
  15. data/lib/elastic_graph/graphql/aggregation/query_adapter.rb +345 -0
  16. data/lib/elastic_graph/graphql/aggregation/query_optimizer.rb +187 -0
  17. data/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb +41 -0
  18. data/lib/elastic_graph/graphql/aggregation/resolvers/count_detail.rb +44 -0
  19. data/lib/elastic_graph/graphql/aggregation/resolvers/grouped_by.rb +30 -0
  20. data/lib/elastic_graph/graphql/aggregation/resolvers/node.rb +64 -0
  21. data/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb +83 -0
  22. data/lib/elastic_graph/graphql/aggregation/resolvers/sub_aggregations.rb +82 -0
  23. data/lib/elastic_graph/graphql/aggregation/script_term_grouping.rb +32 -0
  24. data/lib/elastic_graph/graphql/aggregation/term_grouping.rb +118 -0
  25. data/lib/elastic_graph/graphql/client.rb +43 -0
  26. data/lib/elastic_graph/graphql/config.rb +81 -0
  27. data/lib/elastic_graph/graphql/datastore_query/document_paginator.rb +100 -0
  28. data/lib/elastic_graph/graphql/datastore_query/index_expression_builder.rb +142 -0
  29. data/lib/elastic_graph/graphql/datastore_query/paginator.rb +199 -0
  30. data/lib/elastic_graph/graphql/datastore_query/routing_picker.rb +239 -0
  31. data/lib/elastic_graph/graphql/datastore_query.rb +372 -0
  32. data/lib/elastic_graph/graphql/datastore_response/document.rb +78 -0
  33. data/lib/elastic_graph/graphql/datastore_response/search_response.rb +79 -0
  34. data/lib/elastic_graph/graphql/datastore_search_router.rb +151 -0
  35. data/lib/elastic_graph/graphql/decoded_cursor.rb +120 -0
  36. data/lib/elastic_graph/graphql/filtering/boolean_query.rb +45 -0
  37. data/lib/elastic_graph/graphql/filtering/field_path.rb +81 -0
  38. data/lib/elastic_graph/graphql/filtering/filter_args_translator.rb +58 -0
  39. data/lib/elastic_graph/graphql/filtering/filter_interpreter.rb +526 -0
  40. data/lib/elastic_graph/graphql/filtering/filter_value_set_extractor.rb +148 -0
  41. data/lib/elastic_graph/graphql/filtering/range_query.rb +56 -0
  42. data/lib/elastic_graph/graphql/http_endpoint.rb +229 -0
  43. data/lib/elastic_graph/graphql/monkey_patches/schema_field.rb +56 -0
  44. data/lib/elastic_graph/graphql/monkey_patches/schema_object.rb +48 -0
  45. data/lib/elastic_graph/graphql/query_adapter/filters.rb +161 -0
  46. data/lib/elastic_graph/graphql/query_adapter/pagination.rb +27 -0
  47. data/lib/elastic_graph/graphql/query_adapter/requested_fields.rb +124 -0
  48. data/lib/elastic_graph/graphql/query_adapter/sort.rb +32 -0
  49. data/lib/elastic_graph/graphql/query_details_tracker.rb +60 -0
  50. data/lib/elastic_graph/graphql/query_executor.rb +200 -0
  51. data/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb +49 -0
  52. data/lib/elastic_graph/graphql/resolvers/graphql_adapter.rb +114 -0
  53. data/lib/elastic_graph/graphql/resolvers/list_records.rb +29 -0
  54. data/lib/elastic_graph/graphql/resolvers/nested_relationships.rb +74 -0
  55. data/lib/elastic_graph/graphql/resolvers/query_adapter.rb +85 -0
  56. data/lib/elastic_graph/graphql/resolvers/query_source.rb +46 -0
  57. data/lib/elastic_graph/graphql/resolvers/relay_connection/array_adapter.rb +71 -0
  58. data/lib/elastic_graph/graphql/resolvers/relay_connection/generic_adapter.rb +65 -0
  59. data/lib/elastic_graph/graphql/resolvers/relay_connection/page_info.rb +82 -0
  60. data/lib/elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder.rb +40 -0
  61. data/lib/elastic_graph/graphql/resolvers/relay_connection.rb +42 -0
  62. data/lib/elastic_graph/graphql/resolvers/resolvable_value.rb +56 -0
  63. data/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +35 -0
  64. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date.rb +64 -0
  65. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date_time.rb +60 -0
  66. data/lib/elastic_graph/graphql/scalar_coercion_adapters/local_time.rb +30 -0
  67. data/lib/elastic_graph/graphql/scalar_coercion_adapters/longs.rb +47 -0
  68. data/lib/elastic_graph/graphql/scalar_coercion_adapters/no_op.rb +24 -0
  69. data/lib/elastic_graph/graphql/scalar_coercion_adapters/time_zone.rb +44 -0
  70. data/lib/elastic_graph/graphql/scalar_coercion_adapters/untyped.rb +32 -0
  71. data/lib/elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones.rb +634 -0
  72. data/lib/elastic_graph/graphql/schema/arguments.rb +78 -0
  73. data/lib/elastic_graph/graphql/schema/enum_value.rb +30 -0
  74. data/lib/elastic_graph/graphql/schema/field.rb +147 -0
  75. data/lib/elastic_graph/graphql/schema/relation_join.rb +103 -0
  76. data/lib/elastic_graph/graphql/schema/type.rb +263 -0
  77. data/lib/elastic_graph/graphql/schema.rb +164 -0
  78. data/lib/elastic_graph/graphql.rb +253 -0
  79. data/script/dump_time_zones +81 -0
  80. data/script/dump_time_zones.java +17 -0
  81. 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