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,172 @@
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/datastore_query"
11
+ require "elastic_graph/graphql/filtering/field_path"
12
+
13
+ module ElasticGraph
14
+ class GraphQL
15
+ module Aggregation
16
+ class Query < ::Data.define(
17
+ # Unique name for the aggregation
18
+ :name,
19
+ # Whether or not we need to get the document count for each bucket.
20
+ :needs_doc_count,
21
+ # Whether or not we need to get the error on the document count to satisfy the sub-aggregation query.
22
+ # https://www.elastic.co/guide/en/elasticsearch/reference/8.10/search-aggregations-bucket-terms-aggregation.html#_per_bucket_document_count_error
23
+ :needs_doc_count_error,
24
+ # Filter to apply to this sub-aggregation.
25
+ :filter,
26
+ # Paginator for handling size and other pagination concerns.
27
+ :paginator,
28
+ # A sub-aggregation query can have sub-aggregations of its own.
29
+ :sub_aggregations,
30
+ # Collection of `Computation` objects that specify numeric computations to perform.
31
+ :computations,
32
+ # Collection of `DateHistogramGrouping`, `FieldTermGrouping`, and `ScriptTermGrouping` objects that specify how this sub-aggregation should be grouped.
33
+ :groupings,
34
+ # Adapter to use for groupings.
35
+ :grouping_adapter
36
+ )
37
+ def needs_total_doc_count?
38
+ # We only need a total document count when there are NO groupings and the doc count is requested.
39
+ # The datastore will return the number of hits in each grouping automatically, so we don't need
40
+ # a total doc count when there are groupings. And when the query isn't requesting the field, we
41
+ # don't need it, either.
42
+ needs_doc_count && groupings.empty?
43
+ end
44
+
45
+ # Builds an aggregations hash. The returned value has a few different cases:
46
+ #
47
+ # - If `size` is 0, or `groupings` and `computations` are both empty, we return an empty hash,
48
+ # so that `to_datastore_body` is an empty hash. We do this so that we avoid sending
49
+ # the datastore any sort of aggregations query in these cases, as the client is not
50
+ # requesting any aggregation data.
51
+ # - If `SINGLETON_CURSOR` was provide for either `before` or `after`, we also return an empty hash,
52
+ # because we know there cannot be any results to return--the cursor is a reference to
53
+ # the one and only item in the list, and nothing can exist before or after it.
54
+ # - Otherwise, we return an aggregatinos hash based on the groupings, computations, and sub-aggregations.
55
+ def build_agg_hash(filter_interpreter)
56
+ build_agg_detail(filter_interpreter, field_path: [], parent_queries: [])&.clauses || {}
57
+ end
58
+
59
+ def build_agg_detail(filter_interpreter, field_path:, parent_queries:)
60
+ return nil if paginator.desired_page_size.zero? || paginator.paginated_from_singleton_cursor?
61
+ queries = parent_queries + [self] # : ::Array[Query]
62
+
63
+ filter_detail(filter_interpreter, field_path) do
64
+ grouping_adapter.grouping_detail_for(self) do
65
+ computations_detail.merge(sub_aggregation_detail(filter_interpreter, queries))
66
+ end
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def filter_detail(filter_interpreter, field_path)
73
+ filtering_field_path = Filtering::FieldPath.of(field_path.filter_map(&:name_in_index))
74
+ filter_clause = filter_interpreter.build_query([filter].compact, from_field_path: filtering_field_path)
75
+
76
+ inner_detail = yield
77
+
78
+ return inner_detail if filter_clause.nil?
79
+ key = "#{name}:filtered"
80
+
81
+ clause = {
82
+ key => {
83
+ "filter" => filter_clause,
84
+ "aggs" => inner_detail.clauses
85
+ }.compact
86
+ }
87
+
88
+ inner_meta = inner_detail.meta
89
+ meta =
90
+ if (buckets_path = inner_detail.meta["buckets_path"])
91
+ # In this case, we have some grouping aggregations applied, and the response will include a `buckets` array.
92
+ # Here we are prefixing the `buckets_path` with the `key` used for our filter aggregation to maintain its accuracy.
93
+ inner_meta.merge({"buckets_path" => [key] + buckets_path})
94
+ else
95
+ # In this case, no grouping aggregations have been applied, and the response will _not_ have a `buckets` array.
96
+ # Instead, we'll need to treat the single unbucketed aggregation as a single bucket. To indicate that, we use
97
+ # `bucket_path` (singular) rather than `buckets_path` (plural).
98
+ inner_meta.merge({"bucket_path" => [key]})
99
+ end
100
+
101
+ AggregationDetail.new(clause, meta)
102
+ end
103
+
104
+ def computations_detail
105
+ build_inner_aggregation_detail(computations) do |computation|
106
+ {computation.key(aggregation_name: name) => computation.clause}
107
+ end
108
+ end
109
+
110
+ def sub_aggregation_detail(filter_interpreter, parent_queries)
111
+ build_inner_aggregation_detail(sub_aggregations.values) do |sub_agg|
112
+ sub_agg.build_agg_hash(filter_interpreter, parent_queries: parent_queries)
113
+ end
114
+ end
115
+
116
+ def build_inner_aggregation_detail(collection, &block)
117
+ collection.map(&block).reduce({}, :merge)
118
+ end
119
+ end
120
+
121
+ # The details of an aggregation level, including the `aggs` clauses themselves and `meta`
122
+ # that we want echoed back to us in the response for the aggregation level.
123
+ AggregationDetail = ::Data.define(
124
+ # Aggregation clauses that would go under `aggs.
125
+ :clauses,
126
+ # Custom metadata that will be echoed back to us in the response.
127
+ # https://www.elastic.co/guide/en/elasticsearch/reference/8.11/search-aggregations.html#add-metadata-to-an-agg
128
+ :meta
129
+ ) do
130
+ # @implements AggregationDetail
131
+
132
+ # Wraps this aggregation detail in another aggregation layer for the given `grouping`,
133
+ # so that we can easily build up the necessary multi-level aggregation structure.
134
+ def wrap_with_grouping(grouping, query:)
135
+ agg_key = grouping.key
136
+ extra_inner_meta = grouping.inner_meta.merge({
137
+ # The response just includes tuples of values for the key of each bucket. We need to know what fields those
138
+ # values come from, and this `meta` field indicates that.
139
+ "grouping_fields" => [agg_key]
140
+ })
141
+
142
+ inner_agg_hash = {
143
+ "aggs" => (clauses unless (clauses || {}).empty?),
144
+ "meta" => meta.merge(extra_inner_meta)
145
+ }.compact
146
+
147
+ missing_bucket_inner_agg_hash = inner_agg_hash.key?("aggs") ? inner_agg_hash : {} # : ::Hash[::String, untyped]
148
+
149
+ AggregationDetail.new(
150
+ {
151
+ agg_key => grouping.non_composite_clause_for(query).merge(inner_agg_hash),
152
+
153
+ # Here we include a `missing` aggregation as a sibling to the main grouping aggregation. We do this
154
+ # so that we get a bucket of documents that have `null` values for the field we are grouping on, in
155
+ # order to provide the same behavior as the `CompositeGroupingAdapter` (which uses the built-in
156
+ # `missing_bucket` option).
157
+ #
158
+ # To work correctly, we need to include this `missing` aggregation as a sibling at _every_ level of
159
+ # the aggregation structure, and the `missing` aggregation needs the same child aggregations as the
160
+ # main grouping aggregation has. Given the recursive nature of how this is applied, this results in
161
+ # a fairly complex structure, even though conceptually the idea behind this isn't _too_ bad.
162
+ Key.missing_value_bucket_key(agg_key) => {
163
+ "missing" => {"field" => grouping.encoded_index_field_path}
164
+ }.merge(missing_bucket_inner_agg_hash)
165
+ },
166
+ {"buckets_path" => [agg_key]}
167
+ )
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,345 @@
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/computation"
11
+ require "elastic_graph/graphql/aggregation/date_histogram_grouping"
12
+ require "elastic_graph/graphql/aggregation/field_term_grouping"
13
+ require "elastic_graph/graphql/aggregation/nested_sub_aggregation"
14
+ require "elastic_graph/graphql/aggregation/path_segment"
15
+ require "elastic_graph/graphql/aggregation/query"
16
+ require "elastic_graph/graphql/aggregation/script_term_grouping"
17
+ require "elastic_graph/graphql/schema/arguments"
18
+ require "elastic_graph/support/memoizable_data"
19
+
20
+ module ElasticGraph
21
+ class GraphQL
22
+ module Aggregation
23
+ # Responsible for taking in the incoming GraphQL request context, arguments, and the GraphQL
24
+ # schema and directives and populating the `aggregations` portion of `query`.
25
+ class QueryAdapter < Support::MemoizableData.define(:schema, :config, :filter_args_translator, :runtime_metadata, :sub_aggregation_grouping_adapter)
26
+ # @dynamic element_names
27
+ attr_reader :element_names
28
+
29
+ def call(query:, lookahead:, args:, field:, context:)
30
+ return query unless field.type.unwrap_fully.indexed_aggregation?
31
+
32
+ aggregations_node = extract_aggregation_node(lookahead, field, context.query)
33
+ return query unless aggregations_node
34
+
35
+ aggregation_query = build_aggregation_query_for(
36
+ aggregations_node,
37
+ field: field,
38
+ grouping_adapter: CompositeGroupingAdapter,
39
+ # Filters on root aggregations applied to the search query body itself instead of
40
+ # using a filter aggregation, like sub-aggregations do, so we don't want a filter
41
+ # aggregation generated here.
42
+ unfiltered: true
43
+ )
44
+
45
+ query.merge_with(aggregations: {aggregation_query.name => aggregation_query})
46
+ end
47
+
48
+ private
49
+
50
+ def after_initialize
51
+ @element_names = schema.element_names
52
+ end
53
+
54
+ def extract_aggregation_node(lookahead, field, graphql_query)
55
+ return nil unless (ast_nodes = lookahead.ast_nodes)
56
+
57
+ if ast_nodes.size > 1
58
+ names = ast_nodes.map { |n| "`#{name_of(n)}`" }
59
+ raise_conflicting_grouping_requirement_selections("`#{lookahead.name}` selection with the same name", names)
60
+ end
61
+
62
+ ::GraphQL::Execution::Lookahead.new(
63
+ query: graphql_query,
64
+ ast_nodes: ast_nodes,
65
+ field: lookahead.field,
66
+ owner_type: field.parent_type.graphql_type
67
+ )
68
+ end
69
+
70
+ def build_aggregation_query_for(aggregations_node, field:, grouping_adapter:, nested_path: [], unfiltered: false)
71
+ aggregation_name = name_of(_ = aggregations_node.ast_nodes.first)
72
+
73
+ # Get the AST node for the `nodes` subfield (e.g. from `fooAggregations { nodes { ... } }`)
74
+ nodes_node = selection_above_grouping_fields(aggregations_node, element_names.nodes, aggregation_name)
75
+
76
+ # Also get the AST node for `edges.node` (e.g. from `fooAggregations { edges { node { ... } } }`)
77
+ edges_node_node = [element_names.edges, element_names.node].reduce(aggregations_node) do |node, sub_selection|
78
+ selection_above_grouping_fields(node, sub_selection, aggregation_name)
79
+ end
80
+
81
+ # ...and then determine which one is being used for nodes.
82
+ node_node =
83
+ if nodes_node.selected? && edges_node_node.selected?
84
+ raise_conflicting_grouping_requirement_selections("node selection", ["`#{element_names.nodes}`", "`#{element_names.edges}.#{element_names.node}`"])
85
+ elsif !nodes_node.selected?
86
+ edges_node_node
87
+ else
88
+ nodes_node
89
+ end
90
+
91
+ count_detail_node = node_node.selection(element_names.count_detail)
92
+ needs_doc_count_error =
93
+ # We need to know what the error is to determine if the approximate count is in fact the exact count.
94
+ count_detail_node.selects?(element_names.exact_value) ||
95
+ # We need to know what the error is to determine the upper bound on the count.
96
+ count_detail_node.selects?(element_names.upper_bound)
97
+
98
+ unless unfiltered
99
+ filter = filter_args_translator.translate_filter_args(field: field, args: field.args_to_schema_form(aggregations_node.arguments))
100
+ end
101
+
102
+ Query.new(
103
+ name: aggregation_name,
104
+ groupings: build_groupings_from(node_node, aggregation_name, from_field_path: nested_path),
105
+ computations: build_computations_from(node_node, from_field_path: nested_path),
106
+ sub_aggregations: build_sub_aggregations_from(node_node, parent_nested_path: nested_path),
107
+ needs_doc_count: count_detail_node.selected? || node_node.selects?(element_names.count),
108
+ needs_doc_count_error: needs_doc_count_error,
109
+ paginator: build_paginator_for(aggregations_node),
110
+ filter: filter,
111
+ grouping_adapter: grouping_adapter
112
+ )
113
+ end
114
+
115
+ # Helper method for dealing with lookahead selections above the grouping fields. If the caller selects
116
+ # such a field multiple times (e.g. with aliases) it leads to conflicting grouping requirements, so we
117
+ # do not allow it.
118
+ def selection_above_grouping_fields(node, sub_selection_name, aggregation_name)
119
+ node.selection(sub_selection_name).tap do |nested_node|
120
+ ast_nodes = nested_node.ast_nodes || []
121
+ if ast_nodes.size > 1
122
+ names = ast_nodes.map { |n| "`#{name_of(n)}`" }
123
+ raise_conflicting_grouping_requirement_selections("`#{sub_selection_name}` selection under `#{aggregation_name}`", names)
124
+ end
125
+ end
126
+ end
127
+
128
+ def build_clauses_from(parent_node, &block)
129
+ get_children_nodes(parent_node).flat_map do |child_node|
130
+ transform_node_to_clauses(child_node, &block)
131
+ end.to_set
132
+ end
133
+
134
+ # Takes a `GraphQL::Execution::Lookahead` node and returns an array of children
135
+ # lookahead nodes, excluding nodes for introspection fields.
136
+ def get_children_nodes(node)
137
+ node.selections.reject do |child|
138
+ child.field.introspection?
139
+ end
140
+ end
141
+
142
+ # Takes a `GraphQL::Execution::Lookahead` node that conforms to our aggregate field
143
+ # conventions (`some_field: {Type}Metric`) and returns a Hash compatible with the `aggregations`
144
+ # argument to `DatastoreQuery.new`.
145
+ def transform_node_to_clauses(node, parent_path: [], &clause_builder)
146
+ field = field_from_node(node)
147
+ field_path = parent_path + [PathSegment.for(field: field, lookahead: node)]
148
+
149
+ clause_builder.call(node, field, field_path) || get_children_nodes(node).flat_map do |embedded_field|
150
+ transform_node_to_clauses(embedded_field, parent_path: field_path, &clause_builder)
151
+ end
152
+ end
153
+
154
+ def build_computations_from(node_node, from_field_path: [])
155
+ aggregated_values_node = node_node.selection(element_names.aggregated_values)
156
+
157
+ build_clauses_from(aggregated_values_node) do |node, field, field_path|
158
+ if field.aggregated?
159
+ field_path = from_field_path + field_path
160
+
161
+ get_children_nodes(node).map do |fn_node|
162
+ computed_field = field_from_node(fn_node)
163
+ computation_detail = field_from_node(fn_node).computation_detail # : SchemaArtifacts::RuntimeMetadata::ComputationDetail
164
+
165
+ Aggregation::Computation.new(
166
+ source_field_path: field_path,
167
+ computed_index_field_name: computed_field.name_in_index.to_s,
168
+ detail: computation_detail
169
+ )
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ def build_groupings_from(node_node, aggregation_name, from_field_path: [])
176
+ grouped_by_node = selection_above_grouping_fields(node_node, element_names.grouped_by, aggregation_name)
177
+
178
+ build_clauses_from(grouped_by_node) do |node, field, field_path|
179
+ field_path = from_field_path + field_path
180
+
181
+ # New date/time grouping API (DateGroupedBy, DateTimeGroupedBy)
182
+ if field.type.elasticgraph_category == :date_grouped_by_object
183
+ date_time_groupings_from(field_path: field_path, node: node)
184
+
185
+ elsif !field.type.object?
186
+ case field.type.name
187
+ # Legacy date grouping API
188
+ when :Date
189
+ legacy_date_histogram_groupings_from(
190
+ field_path: field_path,
191
+ node: node,
192
+ get_time_zone: ->(args) {},
193
+ get_offset: ->(args) { args[element_names.offset_days]&.then { |days| "#{days}d" } }
194
+ )
195
+ # Legacy datetime grouping API
196
+ when :DateTime
197
+ legacy_date_histogram_groupings_from(
198
+ field_path: field_path,
199
+ node: node,
200
+ get_time_zone: ->(args) { args.fetch(element_names.time_zone) },
201
+ get_offset: ->(args) { datetime_offset_from(node, args) }
202
+ )
203
+ # Non-date/time grouping
204
+ else
205
+ [FieldTermGrouping.new(field_path: field_path)]
206
+ end
207
+ end
208
+ end
209
+ end
210
+
211
+ # Given a `GraphQL::Execution::Lookahead` node, returns the corresponding `Schema::Field`
212
+ def field_from_node(node)
213
+ schema.field_named(node.owner_type.graphql_name, node.field.name)
214
+ end
215
+
216
+ # Returns an array of `...Grouping`, one for each child node (`as_date_time`, `as_date`, etc).
217
+ def date_time_groupings_from(field_path:, node:)
218
+ get_children_nodes(node).map do |child_node|
219
+ schema_args = Schema::Arguments.to_schema_form(child_node.arguments, child_node.field)
220
+ # Because `DateGroupedBy` doesn't have a `timeZone` argument, and we want to reuse the same
221
+ # script for both `Date` and `DateTime`, we fall back to "UTC" here.
222
+ time_zone = schema_args[element_names.time_zone] || "UTC"
223
+ child_field_path = field_path + [PathSegment.for(lookahead: child_node)]
224
+
225
+ if child_node.field.name == element_names.as_day_of_week
226
+ ScriptTermGrouping.new(
227
+ field_path: child_field_path,
228
+ script_id: runtime_metadata.static_script_ids_by_scoped_name.fetch("field/as_day_of_week"),
229
+ params: {
230
+ "offset_ms" => datetime_offset_as_ms_from(child_node, schema_args),
231
+ "time_zone" => time_zone
232
+ }
233
+ )
234
+ elsif child_node.field.name == element_names.as_time_of_day
235
+ ScriptTermGrouping.new(
236
+ field_path: child_field_path,
237
+ script_id: runtime_metadata.static_script_ids_by_scoped_name.fetch("field/as_time_of_day"),
238
+ params: {
239
+ "interval" => interval_from(child_node, schema_args, interval_unit_key: element_names.truncation_unit),
240
+ "offset_ms" => datetime_offset_as_ms_from(child_node, schema_args),
241
+ "time_zone" => time_zone
242
+ }
243
+ )
244
+ else
245
+ DateHistogramGrouping.new(
246
+ field_path: child_field_path,
247
+ interval: interval_from(child_node, schema_args, interval_unit_key: element_names.truncation_unit),
248
+ offset: datetime_offset_from(child_node, schema_args),
249
+ time_zone: time_zone
250
+ )
251
+ end
252
+ end
253
+ end
254
+
255
+ def legacy_date_histogram_groupings_from(field_path:, node:, get_time_zone:, get_offset:)
256
+ schema_args = Schema::Arguments.to_schema_form(node.arguments, node.field)
257
+
258
+ [DateHistogramGrouping.new(
259
+ field_path: field_path,
260
+ interval: interval_from(node, schema_args, interval_unit_key: element_names.granularity),
261
+ time_zone: get_time_zone.call(schema_args),
262
+ offset: get_offset.call(schema_args)
263
+ )]
264
+ end
265
+
266
+ # Figure out the Date histogram grouping interval for the given node based on the `grouped_by` argument.
267
+ # Until `legacy_grouping_schema` is removed, we need to check both `granularity` and `truncation_unit`.
268
+ def interval_from(node, schema_args, interval_unit_key:)
269
+ enum_type_name = node.field.arguments.fetch(interval_unit_key).type.unwrap.graphql_name
270
+ enum_value_name = schema_args.fetch(interval_unit_key)
271
+ enum_value = schema.type_named(enum_type_name).enum_value_named(enum_value_name)
272
+
273
+ _ = enum_value.runtime_metadata.datastore_value
274
+ end
275
+
276
+ def datetime_offset_from(node, schema_args)
277
+ if (unit_name = schema_args.dig(element_names.offset, element_names.unit))
278
+ enum_value = enum_value_from_offset(node, unit_name)
279
+ amount = schema_args.fetch(element_names.offset).fetch(element_names.amount)
280
+ "#{amount}#{enum_value.runtime_metadata.datastore_abbreviation}"
281
+ end
282
+ end
283
+
284
+ # Convert from amount and unit to milliseconds, using runtime metadata `datastore_value`
285
+ def datetime_offset_as_ms_from(node, schema_args)
286
+ unit_name = schema_args.dig(element_names.offset, element_names.unit)
287
+ return 0 unless unit_name
288
+
289
+ amount = schema_args.fetch(element_names.offset).fetch(element_names.amount)
290
+ enum_value = enum_value_from_offset(node, unit_name)
291
+
292
+ amount * enum_value.runtime_metadata.datastore_value
293
+ end
294
+
295
+ def enum_value_from_offset(node, unit_name)
296
+ offset_input_type = node.field.arguments.fetch(element_names.offset).type.unwrap # : ::GraphQL::Schema::InputObject
297
+ enum_type_name = offset_input_type.arguments.fetch(element_names.unit).type.unwrap.graphql_name
298
+ schema.type_named(enum_type_name).enum_value_named(unit_name)
299
+ end
300
+
301
+ def name_of(ast_node)
302
+ ast_node.alias || ast_node.name
303
+ end
304
+
305
+ def build_sub_aggregations_from(node_node, parent_nested_path: [])
306
+ build_clauses_from(node_node.selection(element_names.sub_aggregations)) do |node, field, field_path|
307
+ if field.type.elasticgraph_category == :nested_sub_aggregation_connection
308
+ nested_path = parent_nested_path + field_path
309
+ nested_sub_agg = NestedSubAggregation.new(
310
+ nested_path: nested_path,
311
+ query: build_aggregation_query_for(
312
+ node,
313
+ field: field,
314
+ grouping_adapter: sub_aggregation_grouping_adapter,
315
+ nested_path: nested_path
316
+ )
317
+ )
318
+
319
+ [[nested_sub_agg.query.name, nested_sub_agg]]
320
+ end
321
+ end.to_h
322
+ end
323
+
324
+ def build_paginator_for(node)
325
+ args = field_from_node(node).args_to_schema_form(node.arguments)
326
+
327
+ DatastoreQuery::Paginator.new(
328
+ first: args[element_names.first],
329
+ after: args[element_names.after],
330
+ last: args[element_names.last],
331
+ before: args[element_names.before],
332
+ default_page_size: config.default_page_size,
333
+ max_page_size: config.max_page_size,
334
+ schema_element_names: schema.element_names
335
+ )
336
+ end
337
+
338
+ def raise_conflicting_grouping_requirement_selections(more_than_one_description, paths)
339
+ raise ::GraphQL::ExecutionError, "Cannot have more than one #{more_than_one_description} " \
340
+ "(#{paths.join(", ")}), because that could lead to conflicting grouping requirements."
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end