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.
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