elasticgraph-graphql 0.19.1.0 → 0.19.2.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/lib/elastic_graph/graphql/aggregation/composite_grouping_adapter.rb +1 -1
  4. data/lib/elastic_graph/graphql/aggregation/computation.rb +1 -1
  5. data/lib/elastic_graph/graphql/aggregation/date_histogram_grouping.rb +1 -1
  6. data/lib/elastic_graph/graphql/aggregation/field_path_encoder.rb +1 -1
  7. data/lib/elastic_graph/graphql/aggregation/field_term_grouping.rb +1 -1
  8. data/lib/elastic_graph/graphql/aggregation/key.rb +1 -1
  9. data/lib/elastic_graph/graphql/aggregation/nested_sub_aggregation.rb +1 -1
  10. data/lib/elastic_graph/graphql/aggregation/non_composite_grouping_adapter.rb +2 -2
  11. data/lib/elastic_graph/graphql/aggregation/path_segment.rb +2 -2
  12. data/lib/elastic_graph/graphql/aggregation/query.rb +1 -1
  13. data/lib/elastic_graph/graphql/aggregation/query_adapter.rb +33 -6
  14. data/lib/elastic_graph/graphql/aggregation/query_optimizer.rb +1 -1
  15. data/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb +2 -6
  16. data/lib/elastic_graph/graphql/aggregation/resolvers/count_detail.rb +1 -1
  17. data/lib/elastic_graph/graphql/aggregation/resolvers/grouped_by.rb +26 -6
  18. data/lib/elastic_graph/graphql/aggregation/resolvers/node.rb +1 -1
  19. data/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb +5 -6
  20. data/lib/elastic_graph/graphql/aggregation/resolvers/sub_aggregations.rb +10 -8
  21. data/lib/elastic_graph/graphql/aggregation/script_term_grouping.rb +1 -1
  22. data/lib/elastic_graph/graphql/aggregation/term_grouping.rb +2 -2
  23. data/lib/elastic_graph/graphql/client.rb +1 -1
  24. data/lib/elastic_graph/graphql/config.rb +21 -6
  25. data/lib/elastic_graph/graphql/datastore_query/document_paginator.rb +10 -5
  26. data/lib/elastic_graph/graphql/datastore_query/index_expression_builder.rb +2 -3
  27. data/lib/elastic_graph/graphql/datastore_query/paginator.rb +1 -1
  28. data/lib/elastic_graph/graphql/datastore_query/routing_picker.rb +2 -3
  29. data/lib/elastic_graph/graphql/datastore_query.rb +66 -74
  30. data/lib/elastic_graph/graphql/datastore_response/document.rb +1 -1
  31. data/lib/elastic_graph/graphql/datastore_response/search_response.rb +83 -9
  32. data/lib/elastic_graph/graphql/datastore_search_router.rb +19 -4
  33. data/lib/elastic_graph/graphql/decoded_cursor.rb +1 -1
  34. data/lib/elastic_graph/graphql/filtering/boolean_query.rb +1 -1
  35. data/lib/elastic_graph/graphql/filtering/field_path.rb +1 -1
  36. data/lib/elastic_graph/graphql/filtering/filter_args_translator.rb +2 -2
  37. data/lib/elastic_graph/graphql/filtering/filter_interpreter.rb +10 -5
  38. data/lib/elastic_graph/graphql/filtering/filter_node_interpreter.rb +2 -2
  39. data/lib/elastic_graph/graphql/filtering/filter_value_set_extractor.rb +17 -2
  40. data/lib/elastic_graph/graphql/filtering/range_query.rb +1 -1
  41. data/lib/elastic_graph/graphql/http_endpoint.rb +2 -2
  42. data/lib/elastic_graph/graphql/monkey_patches/schema_field.rb +1 -1
  43. data/lib/elastic_graph/graphql/monkey_patches/schema_object.rb +1 -1
  44. data/lib/elastic_graph/graphql/query_adapter/filters.rb +1 -1
  45. data/lib/elastic_graph/graphql/query_adapter/pagination.rb +1 -1
  46. data/lib/elastic_graph/graphql/query_adapter/requested_fields.rb +18 -3
  47. data/lib/elastic_graph/graphql/query_adapter/sort.rb +1 -1
  48. data/lib/elastic_graph/graphql/query_details_tracker.rb +13 -4
  49. data/lib/elastic_graph/graphql/query_executor.rb +12 -5
  50. data/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb +6 -12
  51. data/lib/elastic_graph/graphql/resolvers/graphql_adapter_builder.rb +123 -0
  52. data/lib/elastic_graph/graphql/resolvers/list_records.rb +4 -4
  53. data/lib/elastic_graph/graphql/resolvers/nested_relationships.rb +57 -27
  54. data/lib/elastic_graph/graphql/resolvers/nested_relationships_source.rb +324 -0
  55. data/lib/elastic_graph/graphql/resolvers/object.rb +36 -0
  56. data/lib/elastic_graph/graphql/resolvers/query_adapter.rb +2 -2
  57. data/lib/elastic_graph/graphql/resolvers/query_source.rb +6 -3
  58. data/lib/elastic_graph/graphql/resolvers/relay_connection/array_adapter.rb +1 -1
  59. data/lib/elastic_graph/graphql/resolvers/relay_connection/generic_adapter.rb +1 -1
  60. data/lib/elastic_graph/graphql/resolvers/relay_connection/page_info.rb +1 -1
  61. data/lib/elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder.rb +1 -1
  62. data/lib/elastic_graph/graphql/resolvers/relay_connection.rb +1 -1
  63. data/lib/elastic_graph/graphql/resolvers/resolvable_value.rb +2 -7
  64. data/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +1 -1
  65. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date.rb +1 -1
  66. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date_time.rb +1 -1
  67. data/lib/elastic_graph/graphql/scalar_coercion_adapters/local_time.rb +1 -1
  68. data/lib/elastic_graph/graphql/scalar_coercion_adapters/longs.rb +1 -1
  69. data/lib/elastic_graph/graphql/scalar_coercion_adapters/no_op.rb +1 -1
  70. data/lib/elastic_graph/graphql/scalar_coercion_adapters/time_zone.rb +1 -1
  71. data/lib/elastic_graph/graphql/scalar_coercion_adapters/untyped.rb +1 -1
  72. data/lib/elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones.rb +1 -1
  73. data/lib/elastic_graph/graphql/schema/arguments.rb +1 -1
  74. data/lib/elastic_graph/graphql/schema/enum_value.rb +1 -1
  75. data/lib/elastic_graph/graphql/schema/field.rb +12 -27
  76. data/lib/elastic_graph/graphql/schema/relation_join.rb +17 -9
  77. data/lib/elastic_graph/graphql/schema/type.rb +15 -7
  78. data/lib/elastic_graph/graphql/schema.rb +11 -31
  79. data/lib/elastic_graph/graphql.rb +38 -40
  80. data/script/dump_time_zones +1 -1
  81. metadata +25 -27
  82. data/lib/elastic_graph/graphql/resolvers/graphql_adapter.rb +0 -114
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -30,45 +30,8 @@ module ElasticGraph
30
30
  :total_document_count_needed, :aggregations, :logger, :filter_interpreter, :routing_picker,
31
31
  :index_expression_builder, :default_page_size, :search_index_definitions, :max_page_size,
32
32
  :filters, :sort, :document_pagination, :requested_fields, :individual_docs_needed,
33
- :monotonic_clock_deadline, :schema_element_names
34
- ) {
35
- def initialize(
36
- filter: nil,
37
- filters: nil,
38
- sort: nil,
39
- document_pagination: nil,
40
- aggregations: nil,
41
- requested_fields: nil,
42
- individual_docs_needed: false,
43
- total_document_count_needed: false,
44
- monotonic_clock_deadline: nil,
45
- **kwargs
46
- )
47
- # Deal with `:filter` vs `:filters` input and normalize it to a single `filters` set.
48
- filters = ::Set.new(filters || [])
49
- filters << filter if filter && !filter.empty?
50
- filters.freeze
51
-
52
- aggregations ||= {}
53
- requested_fields ||= []
54
-
55
- super(
56
- filters: filters,
57
- sort: sort || [],
58
- document_pagination: document_pagination || {},
59
- aggregations: aggregations,
60
- requested_fields: requested_fields.to_set,
61
- individual_docs_needed: individual_docs_needed || !requested_fields.empty?,
62
- total_document_count_needed: total_document_count_needed || aggregations.values.any?(&:needs_total_doc_count?),
63
- monotonic_clock_deadline: monotonic_clock_deadline,
64
- **kwargs
65
- )
66
-
67
- if search_index_definitions.empty?
68
- raise Errors::SearchFailedError, "Query is invalid, since it contains no `search_index_definitions`."
69
- end
70
- end
71
- }
33
+ :size_multiplier, :monotonic_clock_deadline, :schema_element_names
34
+ )
72
35
  # Load these files after the `Query` class has been defined, to avoid
73
36
  # `TypeError: superclass mismatch for class Query`
74
37
  require "elastic_graph/graphql/datastore_query/document_paginator"
@@ -129,34 +92,31 @@ module ElasticGraph
129
92
  end
130
93
  end
131
94
 
132
- # Merges the provided query, returning a new combined query object.
133
- # Both query objects are left unchanged.
134
- def merge(other_query)
135
- if search_index_definitions != other_query.search_index_definitions
136
- raise ElasticGraph::Errors::InvalidMergeError, "`search_index_definitions` conflict while merging between " \
137
- "#{search_index_definitions} and #{other_query.search_index_definitions}"
138
- end
139
-
95
+ # Merges in the provided attribute overrides, honoring the intended semantics and invariants of `DatastoreQuery`.
96
+ def merge_with(
97
+ individual_docs_needed: false,
98
+ total_document_count_needed: false,
99
+ filters: [],
100
+ sort: [],
101
+ requested_fields: [],
102
+ document_pagination: {},
103
+ size_multiplier: 1,
104
+ monotonic_clock_deadline: nil,
105
+ aggregations: {}
106
+ )
140
107
  with(
141
- individual_docs_needed: individual_docs_needed || other_query.individual_docs_needed,
142
- total_document_count_needed: total_document_count_needed || other_query.total_document_count_needed,
143
- filters: filters + other_query.filters,
144
- sort: merge_attribute(other_query, :sort),
145
- requested_fields: requested_fields + other_query.requested_fields,
146
- document_pagination: merge_attribute(other_query, :document_pagination),
147
- monotonic_clock_deadline: [monotonic_clock_deadline, other_query.monotonic_clock_deadline].compact.min,
148
- aggregations: aggregations.merge(other_query.aggregations)
108
+ individual_docs_needed: self.individual_docs_needed || individual_docs_needed || !requested_fields.empty?,
109
+ total_document_count_needed: self.total_document_count_needed || total_document_count_needed || aggregations.values.any?(&:needs_total_doc_count?),
110
+ filters: self.filters + filters,
111
+ sort: merge_attribute(:sort, sort),
112
+ requested_fields: self.requested_fields + requested_fields,
113
+ document_pagination: merge_attribute(:document_pagination, document_pagination),
114
+ size_multiplier: self.size_multiplier * size_multiplier,
115
+ monotonic_clock_deadline: [self.monotonic_clock_deadline, monotonic_clock_deadline].compact.min,
116
+ aggregations: self.aggregations.merge(aggregations)
149
117
  )
150
118
  end
151
119
 
152
- # Convenience method for merging when you do not have access to an
153
- # `DatastoreQuery::Builder`. Allows you to pass the query options you
154
- # would like to merge. As with `#merge`, leaves the original query unchanged
155
- # and returns a combined query object.
156
- def merge_with(**query_options)
157
- merge(with(**query_options))
158
- end
159
-
160
120
  # Pairs the multi-search headers and body into a tuple, as per the format required by the datastore:
161
121
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/search-multi-search.html#search-multi-search-api-desc
162
122
  def to_datastore_msearch_header_and_body
@@ -176,6 +136,10 @@ module ElasticGraph
176
136
  ).to_s
177
137
  end
178
138
 
139
+ def excluding_indices?
140
+ search_index_expression.split(",").any? { |expr| expr.start_with?("-") }
141
+ end
142
+
179
143
  # Returns the name of the datastore cluster as a String where this query should be setn.
180
144
  # Unless exactly 1 cluster name is found, this method raises a Errors::ConfigError.
181
145
  def cluster_name
@@ -256,6 +220,8 @@ module ElasticGraph
256
220
  total_document_count_needed: total_document_count_needed,
257
221
  decoded_cursor_factory: decoded_cursor_factory,
258
222
  schema_element_names: schema_element_names,
223
+ size_multiplier: size_multiplier,
224
+ max_effective_size: search_index_definitions.map { |i| i.max_result_window }.min,
259
225
  paginator: Paginator.new(
260
226
  default_page_size: default_page_size,
261
227
  max_page_size: max_page_size,
@@ -268,11 +234,14 @@ module ElasticGraph
268
234
  )
269
235
  end
270
236
 
237
+ def effective_size
238
+ document_paginator.effective_size
239
+ end
240
+
271
241
  private
272
242
 
273
- def merge_attribute(other_query, attribute)
243
+ def merge_attribute(attribute, other_value)
274
244
  value = public_send(attribute)
275
- other_value = other_query.public_send(attribute)
276
245
 
277
246
  if value.empty?
278
247
  other_value
@@ -281,7 +250,7 @@ module ElasticGraph
281
250
  elsif value == other_value
282
251
  value
283
252
  else
284
- logger.warn("Tried to merge two queries that both define `#{attribute}`, using the value from the query being merged: #{value}, #{other_value}")
253
+ logger.warn("Tried to merge conflicting values of `#{attribute}`; using the value from the merge override: #{value} (vs. #{other_value})")
285
254
  other_value
286
255
  end
287
256
  end
@@ -344,11 +313,7 @@ module ElasticGraph
344
313
 
345
314
  # Encapsulates dependencies of `Query`, giving us something we can expose off of `application`
346
315
  # to build queries when desired.
347
- class Builder < Support::MemoizableData.define(:runtime_metadata, :logger, :filter_node_interpreter, :query_defaults)
348
- def self.with(runtime_metadata:, logger:, filter_node_interpreter:, **query_defaults)
349
- new(runtime_metadata:, logger:, filter_node_interpreter:, query_defaults:)
350
- end
351
-
316
+ class Builder < Support::MemoizableData.define(:runtime_metadata, :logger, :filter_interpreter, :filter_node_interpreter, :default_page_size, :max_page_size)
352
317
  def routing_picker
353
318
  @routing_picker ||= RoutingPicker.new(
354
319
  filter_node_interpreter: filter_node_interpreter,
@@ -363,13 +328,40 @@ module ElasticGraph
363
328
  )
364
329
  end
365
330
 
366
- def new_query(**options)
331
+ def new_query(
332
+ search_index_definitions:,
333
+ filters: [],
334
+ sort: [],
335
+ document_pagination: {},
336
+ size_multiplier: 1,
337
+ aggregations: {},
338
+ requested_fields: [],
339
+ individual_docs_needed: false,
340
+ total_document_count_needed: false,
341
+ monotonic_clock_deadline: nil
342
+ )
343
+ if search_index_definitions.empty?
344
+ raise Errors::SearchFailedError, "Query is invalid, since it contains no `search_index_definitions`."
345
+ end
346
+
367
347
  DatastoreQuery.new(
368
348
  routing_picker: routing_picker,
369
349
  index_expression_builder: index_expression_builder,
370
350
  logger: logger,
371
351
  schema_element_names: runtime_metadata.schema_element_names,
372
- **query_defaults.merge(options)
352
+ search_index_definitions: search_index_definitions,
353
+ filters: filters.to_set,
354
+ sort: sort,
355
+ document_pagination: document_pagination,
356
+ size_multiplier: size_multiplier,
357
+ aggregations: aggregations,
358
+ requested_fields: requested_fields.to_set,
359
+ individual_docs_needed: individual_docs_needed || !requested_fields.empty?,
360
+ total_document_count_needed: total_document_count_needed || aggregations.values.any?(&:needs_total_doc_count?),
361
+ monotonic_clock_deadline: monotonic_clock_deadline,
362
+ filter_interpreter: filter_interpreter,
363
+ default_page_size: default_page_size,
364
+ max_page_size: max_page_size
373
365
  )
374
366
  end
375
367
  end
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -9,6 +9,7 @@
9
9
  require "elastic_graph/errors"
10
10
  require "elastic_graph/graphql/decoded_cursor"
11
11
  require "elastic_graph/graphql/datastore_response/document"
12
+ require "elastic_graph/support/hash_util"
12
13
  require "forwardable"
13
14
 
14
15
  module ElasticGraph
@@ -17,15 +18,17 @@ module ElasticGraph
17
18
  # Represents a search response from the datastore. Exposes both the raw metadata
18
19
  # provided by the datastore and the collection of documents. Can be treated as a
19
20
  # collection of documents when you don't care about the metadata.
20
- class SearchResponse < ::Data.define(:raw_data, :metadata, :documents, :total_document_count)
21
+ class SearchResponse < ::Data.define(:raw_data, :metadata, :documents, :total_document_count, :aggregations_unavailable_reason, :decoded_cursor_factory)
21
22
  include Enumerable
22
23
  extend Forwardable
23
24
 
25
+ private :raw_data
26
+
24
27
  def_delegators :documents, :each, :to_a, :size, :empty?
25
28
 
26
29
  EXCLUDED_METADATA_KEYS = %w[hits aggregations].freeze
27
30
 
28
- def self.build(raw_data, decoded_cursor_factory: DecodedCursor::Factory::Null)
31
+ def self.build(raw_data, decoded_cursor_factory: DecodedCursor::Factory::Null, aggregations_unavailable_reason: nil)
29
32
  documents = raw_data.fetch("hits").fetch("hits").map do |doc|
30
33
  Document.build(doc, decoded_cursor_factory: decoded_cursor_factory)
31
34
  end
@@ -50,23 +53,94 @@ module ElasticGraph
50
53
  total_document_count = metadata.dig("hits", "total", "value")
51
54
 
52
55
  new(
53
- raw_data: raw_data,
54
- metadata: metadata,
55
- documents: documents,
56
- total_document_count: total_document_count
56
+ raw_data:,
57
+ metadata:,
58
+ documents:,
59
+ total_document_count:,
60
+ aggregations_unavailable_reason:,
61
+ decoded_cursor_factory:
57
62
  )
58
63
  end
59
64
 
65
+ def self.synthesize_from_ids(index, ids, decoded_cursor_factory: DecodedCursor::Factory::Null)
66
+ hits = ids.map do |id|
67
+ {
68
+ "_index" => index,
69
+ "_type" => "_doc",
70
+ "_id" => id,
71
+ "_score" => nil,
72
+ "_source" => {"id" => id},
73
+ "sort" => [id]
74
+ }
75
+ end
76
+
77
+ raw_data = {
78
+ "took" => 0,
79
+ "timed_out" => false,
80
+ "_shards" => {
81
+ "total" => 0,
82
+ "successful" => 0,
83
+ "skipped" => 0,
84
+ "failed" => 0
85
+ },
86
+ "hits" => {
87
+ "total" => {
88
+ "value" => ids.size,
89
+ "relation" => "eq"
90
+ },
91
+ "max_score" => nil,
92
+ "hits" => hits
93
+ }
94
+ }
95
+
96
+ build(raw_data, decoded_cursor_factory: decoded_cursor_factory)
97
+ end
98
+
60
99
  # Benign empty response that can be used in place of datastore response errors as needed.
61
100
  RAW_EMPTY = {"hits" => {"hits" => [], "total" => {"value" => 0}}}.freeze
62
101
  EMPTY = build(RAW_EMPTY)
63
102
 
103
+ # Returns a response filtered to results that have matching `values` at the given `field_path`, limiting
104
+ # the results to the first `size` results.
105
+ #
106
+ # This is designed for use in situations where we have N different datastore queries which are identical
107
+ # except for differing filter values. For efficiency, we combine those queries into a single query that
108
+ # filters on the set union of values. We can then use this method to "split" the single response into what
109
+ # the separate responses would have been if we hadn't combined into a single query.
110
+ def filter_results(field_path, values, size)
111
+ filter =
112
+ if field_path == ["id"]
113
+ # `id` filtering is a very common case, and we want to avoid having to request
114
+ # `id` within `_source`, given it's available as `_id`.
115
+ ->(hit) { values.include?(hit.fetch("_id")) }
116
+ else
117
+ ->(hit) { values.intersect?(Support::HashUtil.fetch_leaf_values_at_path(hit.fetch("_source"), field_path).to_set) }
118
+ end
119
+
120
+ hits = raw_data.fetch("hits").fetch("hits").select(&filter).first(size)
121
+ updated_raw_data = Support::HashUtil.deep_merge(raw_data, {"hits" => {"hits" => hits, "total" => nil}})
122
+
123
+ SearchResponse.build(
124
+ updated_raw_data,
125
+ decoded_cursor_factory: decoded_cursor_factory,
126
+ aggregations_unavailable_reason: "aggregations cannot be provided accurately on a search response filtered in memory"
127
+ )
128
+ end
129
+
64
130
  def docs_description
65
131
  (documents.size < 3) ? documents.inspect : "[#{documents.first}, ..., #{documents.last}]"
66
132
  end
67
133
 
68
- def total_document_count
69
- super || raise(Errors::CountUnavailableError, "#{__method__} is unavailable; set `query.total_document_count_needed = true` to make it available")
134
+ def total_document_count(default: nil)
135
+ super() || default || raise(Errors::CountUnavailableError, "#{__method__} is unavailable; set `query.total_document_count_needed = true` to make it available")
136
+ end
137
+
138
+ def aggregations
139
+ if (reason = aggregations_unavailable_reason)
140
+ raise Errors::AggregationsUnavailableError, "Aggregations are unavailable on this search response: #{reason}."
141
+ end
142
+
143
+ raw_data["aggregations"] || {}
70
144
  end
71
145
 
72
146
  def to_s
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -28,6 +28,8 @@ module ElasticGraph
28
28
  @config = config
29
29
  end
30
30
 
31
+ INDICES_NOT_CONFIGURED_MESSAGE = "The datastore indices have not been configured. They must be configured before ElasticGraph can serve queries."
32
+
31
33
  # Sends the datastore a multi-search request based on the given queries.
32
34
  # Returns a hash of responses keyed by the query.
33
35
  def msearch(queries, query_tracker: QueryDetailsTracker.empty)
@@ -78,9 +80,22 @@ module ElasticGraph
78
80
  [response["took"], queries_for_cluster.zip(ordered_responses)]
79
81
  end
80
82
 
81
- query_tracker.record_datastore_query_duration_ms(
82
- client: @monotonic_clock.now_in_ms - datastore_query_started_at,
83
- server: server_took_and_results.map(&:first).compact.max
83
+ queried_shard_count = server_took_and_results.reduce(0) do |outer_accum, (query, queries_and_responses)|
84
+ outer_accum + queries_and_responses.reduce(0) do |inner_accum, (query, response)|
85
+ shards_total = response.dig("_shards", "total")
86
+
87
+ if shards_total == 0 && !query.excluding_indices?
88
+ raise ::GraphQL::ExecutionError, INDICES_NOT_CONFIGURED_MESSAGE
89
+ end
90
+
91
+ inner_accum + (shards_total || 0)
92
+ end
93
+ end
94
+
95
+ query_tracker.record_datastore_query_metrics(
96
+ client_duration_ms: @monotonic_clock.now_in_ms - datastore_query_started_at,
97
+ server_duration_ms: server_took_and_results.map(&:first).compact.max,
98
+ queried_shard_count: queried_shard_count
84
99
  )
85
100
 
86
101
  server_took_and_results.flat_map(&:last).to_h.tap do |responses_by_query|
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -37,7 +37,7 @@ module ElasticGraph
37
37
  when ::Hash
38
38
  filter_object.to_h do |key, value|
39
39
  field = parent_type.field_named(key)
40
- [field.name_in_index.to_s, convert(field.type.unwrap_fully, value)]
40
+ [field.name_in_index, convert(field.type.unwrap_fully, value)]
41
41
  end
42
42
  when ::Array
43
43
  filter_object.map { |value| convert(parent_type, value) }
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -219,10 +219,14 @@ module ElasticGraph
219
219
  end
220
220
 
221
221
  def process_all_of_expression(bool_node, expressions, field_path)
222
- # `all_of` represents an AND. AND is the default way that `process_filter_hash` combines
223
- # filters so we just have to call it for each sub-expression.
222
+ # `all_of` represents an AND of multiple subexpressions.
223
+ # To achieve this, we build a new bool sub-filter for each subexpression and push it onto
224
+ # the parent’s `:filter` array. Each item in `:filter` is independently required (AND).
224
225
  expressions.each do |sub_expression|
225
- process_filter_hash(bool_node, sub_expression, field_path)
226
+ sub_filter = build_bool_hash do |sub_bool_node|
227
+ process_filter_hash(sub_bool_node, sub_expression, field_path)
228
+ end
229
+ bool_node[:filter] << sub_filter if sub_filter
226
230
  end
227
231
  end
228
232
 
@@ -339,7 +343,8 @@ module ElasticGraph
339
343
  end
340
344
 
341
345
  def build_bool_hash(&block)
342
- bool_node = Hash.new { |h, k| h[k] = [] }.tap(&block)
346
+ bool_node = Hash.new { |h, k| h[k] = [] } # : stringOrSymbolHash
347
+ bool_node.tap(&block)
343
348
 
344
349
  # To treat "empty" filter predicates as `true` we need to return `nil` here.
345
350
  return nil if bool_node.empty?
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -168,7 +168,7 @@ module ElasticGraph
168
168
  when ::Array
169
169
  value.map { |v| to_datastore_value(v) }
170
170
  when Schema::EnumValue
171
- value.name.to_s
171
+ value.name
172
172
  else
173
173
  value
174
174
  end
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -99,6 +99,8 @@ module ElasticGraph
99
99
  filter_value_set_for_filter_hash(filter_value, target_field_path_parts, traversed_field_path_parts + [field_or_op], negate: negate)
100
100
  when :any_of
101
101
  filter_value_set_for_any_of(filter_value, target_field_path_parts, traversed_field_path_parts, negate: negate)
102
+ when :all_of
103
+ filter_value_set_for_all_of(filter_value, target_field_path_parts, traversed_field_path_parts, negate: negate)
102
104
  when :operator
103
105
  # Check to make sure the operator applies to the target field. If not, we have no information
104
106
  # in this clause. The set is unbounded, and may have exclusions.
@@ -106,7 +108,7 @@ module ElasticGraph
106
108
 
107
109
  set = filter_value_set_for_field_filter(field_or_op, filter_value)
108
110
  negate ? set.negate : set
109
- when :all_of, :list_any_filter, :list_count, :unknown
111
+ when :list_any_filter, :list_count, :unknown
110
112
  # We have no information in this clause. The set is unbounded, and may have exclusions.
111
113
  UnboundedSetWithExclusions
112
114
  else
@@ -134,6 +136,19 @@ module ElasticGraph
134
136
  end
135
137
  end
136
138
 
139
+ # Determines the set of filter values for an `all_of` clause, which is used for ANDing multiple filters together.
140
+ def filter_value_set_for_all_of(filter_hashes, target_field_path_parts, traversed_field_path_parts, negate:)
141
+ # all_of: [] => match all values
142
+ if filter_hashes.empty?
143
+ return negate ? @empty_set : @all_values_set
144
+ end
145
+
146
+ # With all_of (AND), we do an intersection of subfilters
147
+ map_reduce_sets(filter_hashes, :intersection, negate: negate) do |filter_hash|
148
+ filter_value_set_for_filter_hash(filter_hash, target_field_path_parts, traversed_field_path_parts, negate: negate)
149
+ end
150
+ end
151
+
137
152
  # Determines the set of filter values for a single filter on a single field.
138
153
  def filter_value_set_for_field_filter(filter_op, filter_value)
139
154
  operator_name = @schema_names.canonical_name_for(filter_op)
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -227,7 +227,7 @@ module ElasticGraph
227
227
  # Steep weirdly expects them here...
228
228
  # @dynamic initialize, config, logger, runtime_metadata, graphql_schema_string, datastore_core, clock
229
229
  # @dynamic graphql_http_endpoint, graphql_query_executor, schema, datastore_search_router, filter_interpreter, filter_node_interpreter
230
- # @dynamic datastore_query_builder, graphql_gem_plugins, graphql_resolvers, datastore_query_adapters, monotonic_clock
230
+ # @dynamic datastore_query_builder, graphql_gem_plugins, graphql_adapter, resolver_query_adapter, named_graphql_resolvers, datastore_query_adapters, monotonic_clock
231
231
  # @dynamic load_dependencies_eagerly, self.from_parsed_yaml, filter_args_translator, sub_aggregation_grouping_adapter
232
232
  end
233
233
  end
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -14,13 +14,28 @@ module ElasticGraph
14
14
  # need to satisfy the GraphQL query. This results in more efficient datastore queries,
15
15
  # similar to doing `SELECT f1, f2, ...` instead of `SELECT *` for a SQL query.
16
16
  class RequestedFields
17
+ # Partially applied `RequestedFields` -- essentially the `RequestedFields` without the schema,
18
+ # so that it can be instantiated before the `Schema` instance exists, instead providing it from
19
+ # `context` at query time.
20
+ class WithoutSchema
21
+ def call(field:, query:, lookahead:, args:, context:)
22
+ return query if field.type.unwrap_fully.indexed_aggregation?
23
+
24
+ RequestedFields.new(context.fetch(:elastic_graph_schema)).call(
25
+ field: field,
26
+ query: query,
27
+ lookahead: lookahead,
28
+ args: args,
29
+ context: context
30
+ )
31
+ end
32
+ end
33
+
17
34
  def initialize(schema)
18
35
  @schema = schema
19
36
  end
20
37
 
21
38
  def call(field:, query:, lookahead:, args:, context:)
22
- return query if field.type.unwrap_fully.indexed_aggregation?
23
-
24
39
  attributes = query_attributes_for(field: field, lookahead: lookahead)
25
40
  query.merge_with(**attributes)
26
41
  end
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Block, Inc.
1
+ # Copyright 2024 - 2025 Block, Inc.
2
2
  #
3
3
  # Use of this source code is governed by an MIT-style
4
4
  # license that can be found in the LICENSE file or at
@@ -21,6 +21,7 @@ module ElasticGraph
21
21
  :query_counts_per_datastore_request,
22
22
  :datastore_query_server_duration_ms,
23
23
  :datastore_query_client_duration_ms,
24
+ :queried_shard_count,
24
25
  :mutex
25
26
  )
26
27
  def self.empty
@@ -31,6 +32,7 @@ module ElasticGraph
31
32
  query_counts_per_datastore_request: [],
32
33
  datastore_query_server_duration_ms: 0,
33
34
  datastore_query_client_duration_ms: 0,
35
+ queried_shard_count: 0,
34
36
  mutex: ::Thread::Mutex.new
35
37
  )
36
38
  end
@@ -49,12 +51,19 @@ module ElasticGraph
49
51
  end
50
52
  end
51
53
 
52
- def record_datastore_query_duration_ms(client:, server:)
54
+ def record_datastore_query_metrics(client_duration_ms:, server_duration_ms:, queried_shard_count:)
53
55
  mutex.synchronize do
54
- self.datastore_query_client_duration_ms += client
55
- self.datastore_query_server_duration_ms += server if server
56
+ self.datastore_query_client_duration_ms += client_duration_ms
57
+ self.datastore_query_server_duration_ms += server_duration_ms if server_duration_ms
58
+ self.queried_shard_count += queried_shard_count
56
59
  end
57
60
  end
61
+
62
+ # Indicates how long was spent on transport between the client and the datastore server, including
63
+ # network time, JSON serialization time, etc.
64
+ def datastore_request_transport_duration_ms
65
+ datastore_query_client_duration_ms - datastore_query_server_duration_ms
66
+ end
58
67
  end
59
68
  end
60
69
  end