elasticgraph-graphql 0.19.1.1 → 0.19.2.1

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/query_adapter/filters.rb +1 -1
  43. data/lib/elastic_graph/graphql/query_adapter/pagination.rb +1 -1
  44. data/lib/elastic_graph/graphql/query_adapter/requested_fields.rb +18 -3
  45. data/lib/elastic_graph/graphql/query_adapter/sort.rb +1 -1
  46. data/lib/elastic_graph/graphql/query_details_tracker.rb +11 -14
  47. data/lib/elastic_graph/graphql/query_executor.rb +10 -16
  48. data/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb +6 -12
  49. data/lib/elastic_graph/graphql/resolvers/graphql_adapter_builder.rb +126 -0
  50. data/lib/elastic_graph/graphql/resolvers/list_records.rb +4 -4
  51. data/lib/elastic_graph/graphql/resolvers/nested_relationships.rb +57 -27
  52. data/lib/elastic_graph/graphql/resolvers/nested_relationships_source.rb +325 -0
  53. data/lib/elastic_graph/graphql/resolvers/object.rb +36 -0
  54. data/lib/elastic_graph/graphql/resolvers/query_adapter.rb +2 -2
  55. data/lib/elastic_graph/graphql/resolvers/query_source.rb +6 -3
  56. data/lib/elastic_graph/graphql/resolvers/relay_connection/array_adapter.rb +1 -1
  57. data/lib/elastic_graph/graphql/resolvers/relay_connection/generic_adapter.rb +1 -1
  58. data/lib/elastic_graph/graphql/resolvers/relay_connection/page_info.rb +1 -1
  59. data/lib/elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder.rb +1 -1
  60. data/lib/elastic_graph/graphql/resolvers/relay_connection.rb +2 -2
  61. data/lib/elastic_graph/graphql/resolvers/resolvable_value.rb +2 -7
  62. data/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +1 -1
  63. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date.rb +1 -1
  64. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date_time.rb +1 -1
  65. data/lib/elastic_graph/graphql/scalar_coercion_adapters/local_time.rb +1 -1
  66. data/lib/elastic_graph/graphql/scalar_coercion_adapters/longs.rb +1 -1
  67. data/lib/elastic_graph/graphql/scalar_coercion_adapters/no_op.rb +1 -1
  68. data/lib/elastic_graph/graphql/scalar_coercion_adapters/time_zone.rb +1 -1
  69. data/lib/elastic_graph/graphql/scalar_coercion_adapters/untyped.rb +1 -1
  70. data/lib/elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones.rb +1 -1
  71. data/lib/elastic_graph/graphql/schema/arguments.rb +1 -1
  72. data/lib/elastic_graph/graphql/schema/enum_value.rb +1 -1
  73. data/lib/elastic_graph/graphql/schema/field.rb +12 -27
  74. data/lib/elastic_graph/graphql/schema/relation_join.rb +17 -9
  75. data/lib/elastic_graph/graphql/schema/type.rb +19 -8
  76. data/lib/elastic_graph/graphql/schema.rb +83 -29
  77. data/lib/elastic_graph/graphql.rb +56 -43
  78. data/script/dump_time_zones +1 -1
  79. metadata +59 -29
  80. data/lib/elastic_graph/graphql/monkey_patches/schema_field.rb +0 -56
  81. data/lib/elastic_graph/graphql/monkey_patches/schema_object.rb +0 -48
  82. data/lib/elastic_graph/graphql/resolvers/graphql_adapter.rb +0 -114
@@ -0,0 +1,325 @@
1
+ # Copyright 2024 - 2025 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/query_source"
10
+
11
+ module ElasticGraph
12
+ class GraphQL
13
+ module Resolvers
14
+ # A GraphQL dataloader responsible for solving a thorny N+1 query problem related to our `NestedRelationships` resolver.
15
+ # The `QuerySource` dataloader implements a basic batching optimization: multiple datastore queries are batched up into
16
+ # a single `msearch` call against the dataastore. This is significantly better than submitting a separate request per
17
+ # query, but is still not optimal--the datastore still must execute N different queries, which could cause significant load.
18
+ #
19
+ # A significantly improved optimization is possible in one particular situation from our `NestedRelationships` resolver.
20
+ # Here's an example of that situation:
21
+ #
22
+ # - `Part` documents are indexed in a `parts` index and `Manufacturer` documents are indexed in a `manufacturers` index.
23
+ # - `Part.manufacturer` is defined as: `t.relates_to_one "manufacturer", "Manufacturer", via: "manufacturer_id", dir: :out`.
24
+ # - We are processing a GraphQL query like this: `parts(first: 10) { nodes { manufacturer { name } } }`.
25
+ # - For each of the 10 parts, the `NestedRelationships` resolver has to resolve its related `Part.manufacturer`.
26
+ # - Without the optimization provided by this class, `NestedRelationships` would have to execute 10 different queries,
27
+ # each of which is identical except for a different filter: `{id: {equal_to_any_of: [part.manufacturer_id]}}`.
28
+ # - Instead of executing this as 10 different queries, we can instead execute it as one query with this combined filter:
29
+ # `{id: {equal_to_any_of: [part1.manufacturer_id, ..., part10.manufacturer_id]}}`
30
+ # - When we do this, we get a single response, but `NestedRelationships` expects a separate response for each one.
31
+ # - To satisfy that, we can split the single response into 10 different responses (one per filter).
32
+ #
33
+ # This optimization, when we can apply it, results in much less load on the datastore. In addition, it also helps to reduce
34
+ # the amount of overhead imposed by ElasticGraph. Profiling has shown that significant overhead is incurred when we repeatedly
35
+ # merge filters into a query (e.g. `query.merge_with(filters: [{id: {equal_to_any_of: [part.manufacturer_id]}}])` 10 times to
36
+ # produce 10 different queries). This optimization also avoids that overhead.
37
+ #
38
+ # Note: while the comments discuss the examples in terms of _parent objects_, in the implementation, we deal with id sets.
39
+ # A set of ids is contributed by each parent object.
40
+ class NestedRelationshipsSource < ::GraphQL::Dataloader::Source
41
+ # The optimization implemented by this class is not guaranteed to get all expected results in a single query for cases where
42
+ # the sorted search results are not well-distributed among each of the parent objects while we're resolving a `relates_to_many`
43
+ # field. (See the comments on `fetch_via_single_query_with_merged_filters` for a detailed description of when this occurs).
44
+ #
45
+ # To deal with this situation, we retry the query for just the parent objects which may have incomplete results. However,
46
+ # each attempt is run in serial, and we want to put a strict upper bound on how many attempts are made. This constant defines
47
+ # the maximum number of optimized attempts we allow.
48
+ #
49
+ # When exceeded, we fall back to building and executing a separate query (via a single `msearch` request) for each parent object.
50
+ MAX_OPTIMIZED_ATTEMPTS = 3
51
+
52
+ # Reattempts are less likely to be needed when we execute the query with a larger `size`, because we are more likely to get back
53
+ # complete results for each parent object. This multiplier is applied to the requested size to achieve that.
54
+ #
55
+ # 4 was chosen somewhat arbitrarily, but should make reattempts needed much less often while avoiding asking for an unreasonably
56
+ # large number of results.
57
+ #
58
+ # Note: asking the datastore for a larger `size` is quite a bit more efficient than needing to execute more queries.
59
+ # Once the datastore has gone to the spot in its inverted index with the matching documents, asking for more results
60
+ # isn't particularly expensive, compared to needing to re-run an extra query.
61
+ EXTRA_SIZE_MULTIPLIER = 4
62
+
63
+ def initialize(query:, join:, context:, monotonic_clock:, mode:)
64
+ @query = query
65
+ @join = join
66
+ @filter_id_field_name_path = @join.filter_id_field_name.split(".")
67
+ @context = context
68
+ elastic_graph_schema = context.fetch(:elastic_graph_schema)
69
+ @schema_element_names = elastic_graph_schema.element_names
70
+ @logger = elastic_graph_schema.logger
71
+ @monotonic_clock = monotonic_clock
72
+ @mode = mode
73
+ end
74
+
75
+ def fetch(id_sets)
76
+ return fetch_original(id_sets) unless can_merge_filters?
77
+
78
+ case @mode
79
+ when :original
80
+ fetch_original(id_sets)
81
+ when :comparison
82
+ fetch_comparison(id_sets)
83
+ else
84
+ fetch_optimized(id_sets)
85
+ end
86
+ end
87
+
88
+ def self.execute_one(ids, query:, join:, context:, monotonic_clock:, mode:)
89
+ context.dataloader.with(self, query:, join:, context:, monotonic_clock:, mode:).load(ids)
90
+ end
91
+
92
+ private
93
+
94
+ def fetch_optimized(id_sets)
95
+ attempt_count = 0
96
+ duration_ms, responses_by_id_set = time_duration do
97
+ fetch_via_single_query_with_merged_filters(id_sets) { attempt_count += 1 }
98
+ end
99
+
100
+ if id_sets.size > 1
101
+ @logger.info({
102
+ "message_type" => "NestedRelationshipsMergedQueries",
103
+ "field" => @join.field.description,
104
+ "optimized_attempt_count" => [attempt_count, MAX_OPTIMIZED_ATTEMPTS].min,
105
+ "degraded_to_separate_queries" => (attempt_count > MAX_OPTIMIZED_ATTEMPTS),
106
+ "id_set_count" => id_sets.size,
107
+ "total_id_count" => id_sets.reduce(:union).size,
108
+ "duration_ms" => duration_ms
109
+ })
110
+ end
111
+
112
+ id_sets.map { |id_set| responses_by_id_set.fetch(id_set) }
113
+ end
114
+
115
+ def fetch_original(id_sets, requested_fields: [])
116
+ fetch_via_separate_queries(id_sets, requested_fields: requested_fields)
117
+ end
118
+
119
+ def fetch_comparison(id_sets)
120
+ # Note: we'd ideally run both versions of the logic in parallel, but our attempts to do that resulted in errors
121
+ # because of the fiber context in which dataloaders run.
122
+ original_duration_ms, original_results = time_duration do
123
+ # In the `fetch_optimized` implementation, we request this extra field. We don't need it for
124
+ # the original implementation (so `fetch_original` doesn't also request that field...) but for
125
+ # the purposes of comparison we need to request it so that the document payloads will have the
126
+ # same fields.
127
+ #
128
+ # Note: we don't add the requested field if we have only a single id set, in order to align with
129
+ # the short-circuiting logic in `fetch_via_single_query_with_merged_filters`. Otherwise, any time
130
+ # we have a single id set we always get reported differences which are not actually real!
131
+ requested_fields = (id_sets.size > 1) ? [@join.filter_id_field_name] : [] # : ::Array[::String]
132
+ fetch_original(id_sets, requested_fields: requested_fields)
133
+ end
134
+
135
+ optimized_duration_ms, optimized_results = time_duration do
136
+ fetch_optimized(id_sets)
137
+ end
138
+
139
+ # To see if we got the same results we only look at the documents, because we expect differences outside
140
+ # of the documents--for example, the `SearchResponse#metadata` will report different `took` values.
141
+ got_same_results = original_results.map(&:documents) == optimized_results.map(&:documents)
142
+ message = {
143
+ "message_type" => "NestedRelationshipsComparisonResults",
144
+ "field" => @join.field.description,
145
+ "original_duration_ms" => original_duration_ms,
146
+ "optimized_duration_ms" => optimized_duration_ms,
147
+ "optimized_faster" => (optimized_duration_ms < original_duration_ms),
148
+ "id_set_count" => id_sets.size,
149
+ "total_id_count" => id_sets.reduce(:union).size,
150
+ "got_same_results" => got_same_results
151
+ }
152
+
153
+ if got_same_results
154
+ @logger.info(message)
155
+ else
156
+ @logger.error(message.merge({
157
+ "original_documents" => loggable_results(original_results),
158
+ "optimized_documents" => loggable_results(optimized_results)
159
+ }))
160
+ end
161
+
162
+ original_results
163
+ end
164
+
165
+ # For "simple", document-based queries, we can safely merge filters. However, this cannot be done safely when the response
166
+ # cannot safely be "pulled part" into the bits that apply to a particular set of ids for a parent object. Specifically:
167
+ #
168
+ # - If `total_document_count_needed` is true, we can't merge filters, because there's no way to get a separate count
169
+ # for each parent object unless we execute separate queries (or combine them into a grouped aggregation count query,
170
+ # but that requires a much more challenging transformation of the query and response).
171
+ # - If the query has any `aggregations`, we likewise can't merge the filters, because we have no way to "pull apart"
172
+ # the aggregations response.
173
+ def can_merge_filters?
174
+ !@query.total_document_count_needed && @query.aggregations.empty?
175
+ end
176
+
177
+ # Executes a single query that contains a merged filter from the set union of the given `id_sets`.
178
+ # This merged query is (theoretically) capable of getting all the results we're looking for in a
179
+ # single query, which is much more efficient than building and performing a separate query for each
180
+ # id set. We can use `search_response.filter_results(id_set)` with each id set to get a
181
+ # response with the documents filtered down to just the ones that match the id set. (Essentially,
182
+ # this is the response we would have gotten if we had executed a separate query for the id set).
183
+ #
184
+ # However, it is not guaranteed that we will get back complete results with this approach. Consider this example:
185
+ #
186
+ # - The datastore has 50 documents that match `id_set_1`, and 50 that match `id_set_2`.
187
+ # - The requested size of `@query` is 10 (meaning the client expects the first 10 results matching `id_set_1` and
188
+ # the first 10 results matching `id_set_2).
189
+ # - All 50 documents that match `id_set_1` sort before all 50 documents that match `id_set_2`.
190
+ # - When we execute our merged query filtering on the `union(id_set_1, id_set_2)` set, we ask for
191
+ # 20 documents (since we want 10 for `id_set_1` and 10 for `id_set_2`).
192
+ # - ...but we get back 20 documents for `id_set_1` and 0 documents for `id_set_2`.
193
+ #
194
+ # There is no way to guarantee that we get back the desired number of results for each id set unless we build and
195
+ # execute a separate query per id set, which is inefficient (in some situations, it causes one GraphQL query to
196
+ # execute hundreds of queries against the datastore!).
197
+ #
198
+ # To deal with this possibility, this method takes an iterative approach:
199
+ #
200
+ # - It builds and executes an initial optimized merged query, with a large `size_multiplier` which gives us a good bit of
201
+ # "headroom" for this kind of situation. In the example above, if we requested 60 results from the datastore, we'd be
202
+ # able to get the 10 results for both id sets we are looking for--50 for `id_set_1` nad 10 for `id_set_2`.
203
+ # - It then inspects the response. If the datastore returned fewer results than we asked for, then there are no missing
204
+ # results and we can trust that we got all the results we would have gotten if we had executed a separate query per
205
+ # id set.
206
+ # - If we got back the number of results we asked for, then it's possible that we've run into this situation. We need
207
+ # to inspect each filtered response produced for each id set to see if more results were expected.
208
+ # - Note: the fact that more results were expected doesn't necessarily mean there are more results. But we have no way
209
+ # to tell for sure without querying the datastore again, so we err on the side of safety and treat this kind of response
210
+ # as being incomplete.
211
+ # - For each id set that appears to be incomplete, we try again. But on the next attempt, we exclude the id sets
212
+ # which got a complete set of results.
213
+ # - This may cause us to iterate a couple of times (which could make the single GraphQL query we are processing slower than
214
+ # it would have been without this optimization, particularly if the datastore was not under any other load...) but we expect
215
+ # it to make a big difference in the amount of load we put on the datastore, and that helps _all_ query traffic to be more
216
+ # performant overall.
217
+ def fetch_via_single_query_with_merged_filters(id_sets, remaining_attempts: MAX_OPTIMIZED_ATTEMPTS)
218
+ yield # yield to signal an attempt
219
+
220
+ # Fallback to executing separate queries when one of the following occurs:
221
+ #
222
+ # - We lack multiple sets of ids.
223
+ # - We have exhausted our MAX_OPTIMIZED_ATTEMPTS.
224
+ if id_sets.size < 2 || remaining_attempts < 1
225
+ return id_sets.zip(fetch_via_separate_queries(id_sets)).to_h
226
+ end
227
+
228
+ # First, we build a combined query with filters that account for all ids we are filtering on from all `id_sets`.
229
+ filtered_query = @query.merge_with(
230
+ filters: filters_for(id_sets.reduce(:union)),
231
+ requested_fields: [@join.filter_id_field_name],
232
+ # We need to request a larger size than `@query` originally had. If the original size was `10` and we have
233
+ # 5 sets of ids, then, at a minimum, we need to request 50 results (10 results for each id set).
234
+ #
235
+ # In addition, we apply `EXTRA_SIZE_MULTIPLIER` to increase the size further and make it less likely that
236
+ # we we get incomplete results and have to retry.
237
+ size_multiplier: id_sets.size * EXTRA_SIZE_MULTIPLIER
238
+ )
239
+
240
+ # Then we execute that combined query.
241
+ response = QuerySource.execute_one(filtered_query, for_context: @context)
242
+
243
+ # Next, we produce a separate response for each id set by filtering the results to the ones that match the ids in the set.
244
+ filtered_responses_by_id_set = id_sets.to_h do |id_set|
245
+ filtered_response = response.filter_results(@filter_id_field_name_path, id_set, @query.effective_size)
246
+ [id_set, filtered_response]
247
+ end
248
+
249
+ # If our merged/filtered query got back fewer results than we requested, then no matching results are missing,
250
+ # and we know that we've gotten complete results for all id sets.
251
+ if response.size < filtered_query.effective_size
252
+ return filtered_responses_by_id_set
253
+ end
254
+
255
+ # Since our `filtered_query` got back as many results as we asked for, there may be additional matching results that
256
+ # were not returned, and some id sets may have gotten fewer results than requested by the client.
257
+ # Here we determine which id sets that applies to.
258
+ id_sets_with_apparently_incomplete_results = filtered_responses_by_id_set.filter_map do |id_set, filtered_response|
259
+ id_set if filtered_response.size < @query.effective_size
260
+ end
261
+
262
+ # Then we try again, excluding the id sets which have already gotten complete results.
263
+ another_attempt_results = fetch_via_single_query_with_merged_filters(
264
+ id_sets_with_apparently_incomplete_results,
265
+ remaining_attempts: remaining_attempts - 1
266
+ ) { yield }
267
+
268
+ # Finally, we merge the results.
269
+ filtered_responses_by_id_set.merge(another_attempt_results)
270
+ end
271
+
272
+ def fetch_via_separate_queries(id_sets, requested_fields: [])
273
+ queries = id_sets.map do |ids|
274
+ @query.merge_with(filters: filters_for(ids), requested_fields: requested_fields)
275
+ end
276
+
277
+ results = QuerySource.execute_many(queries, for_context: @context)
278
+ queries.map { |q| results.fetch(q) }
279
+ end
280
+
281
+ def filters_for(ids)
282
+ join_filter = build_filter(@join.filter_id_field_name, nil, @join.foreign_key_nested_paths, ids.to_a)
283
+
284
+ if @join.additional_filter.empty?
285
+ [join_filter]
286
+ else
287
+ [join_filter, @join.additional_filter]
288
+ end
289
+ end
290
+
291
+ def build_filter(path, previous_nested_path, nested_paths, ids)
292
+ next_nested_path, *rest_nested_paths = nested_paths
293
+
294
+ if next_nested_path.nil?
295
+ path = path.delete_prefix("#{previous_nested_path}.") if previous_nested_path
296
+ {path => {@schema_element_names.equal_to_any_of => ids}}
297
+ else
298
+ sub_filter = build_filter(path, next_nested_path, rest_nested_paths, ids)
299
+ next_nested_path = next_nested_path.delete_prefix("#{previous_nested_path}.") if previous_nested_path
300
+ {next_nested_path => {@schema_element_names.any_satisfy => sub_filter}}
301
+ end
302
+ end
303
+
304
+ def time_duration
305
+ start_time = @monotonic_clock.now_in_ms
306
+ result = yield
307
+ stop_time = @monotonic_clock.now_in_ms
308
+ [stop_time - start_time, result]
309
+ end
310
+
311
+ # Converts the given list of responses into a format we can safely log when we are logging
312
+ # response differences. We include the `id` (to identify the document) and the `hash` (so
313
+ # we can tell if the payload of a document differed, without logging the contents of that
314
+ # payload).
315
+ def loggable_results(responses)
316
+ responses.map do |response|
317
+ response.documents.map do |doc|
318
+ "#{doc.id} (hash: #{doc.hash})"
319
+ end
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,36 @@
1
+ # Copyright 2024 - 2025 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
+ module ElasticGraph
10
+ class GraphQL
11
+ module Resolvers
12
+ # Resolvers which just delegates to `object` for resolving.
13
+ module Object
14
+ class WithLookahead
15
+ def initialize(elasticgraph_graphql:, config:)
16
+ # Nothing to initialize, but needs to be defined to satisfy the resolver interface.
17
+ end
18
+
19
+ def resolve(field:, object:, args:, context:, lookahead:)
20
+ object.resolve(field: field, object: object, args: args, context: context, lookahead: lookahead)
21
+ end
22
+ end
23
+
24
+ class WithoutLookahead
25
+ def initialize(elasticgraph_graphql:, config:)
26
+ # Nothing to initialize, but needs to be defined to satisfy the resolver interface.
27
+ end
28
+
29
+ def resolve(field:, object:, args:, context:)
30
+ object.resolve(field: field, object: object, args: args, context: context)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ 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
@@ -17,7 +17,7 @@ module ElasticGraph
17
17
  @datastore_query_adapters = datastore_query_adapters
18
18
  end
19
19
 
20
- def build_query_from(field:, args:, lookahead:, context: {})
20
+ def build_query_from(field:, args:, lookahead:, context:)
21
21
  monotonic_clock_deadline = context[:monotonic_clock_deadline]
22
22
 
23
23
  # Building an `DatastoreQuery` is not cheap; we do a lot of work to:
@@ -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
@@ -11,11 +11,14 @@ require "graphql"
11
11
  module ElasticGraph
12
12
  class GraphQL
13
13
  module Resolvers
14
- # Provides a way to avoid N+1 query problems by batching up multiple
15
- # datastore queries into one `msearch` call. In general, it is recommended
14
+ # Provides a way to avoid N+1 request problems by batching up multiple
15
+ # datastore queries into one `msearch` request. In general, it is recommended
16
16
  # that you use this from any resolver that needs to query the datastore, to
17
17
  # maximize our ability to combine multiple datastore requests. Importantly,
18
18
  # this should never be instantiated directly; instead use the `execute` method from below.
19
+ #
20
+ # Note: `NestedRelationshipsSource` implements further optimizations on top of this, and should
21
+ # be used rather than this class when applicable.
19
22
  class QuerySource < ::GraphQL::Dataloader::Source
20
23
  def initialize(datastore_router, query_tracker)
21
24
  @datastore_router = datastore_router
@@ -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
@@ -20,7 +20,7 @@ module ElasticGraph
20
20
  def self.maybe_wrap(search_response, field:, context:, lookahead:, query:)
21
21
  return search_response unless field.type.relay_connection?
22
22
 
23
- schema_element_names = context.fetch(:schema_element_names)
23
+ schema_element_names = context.fetch(:elastic_graph_schema).element_names
24
24
 
25
25
  unless field.type.unwrap_fully.indexed_aggregation?
26
26
  return SearchResponseAdapterBuilder.build_from(
@@ -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
@@ -27,16 +27,11 @@ module ElasticGraph
27
27
  end
28
28
  end
29
29
 
30
- def resolve(field:, object:, context:, args:, lookahead:)
30
+ def resolve(field:, object:, context:, args:)
31
31
  method_name = canonical_name_for(field.name, "Field")
32
32
  public_send(method_name, **args_to_canonical_form(args))
33
33
  end
34
34
 
35
- def can_resolve?(field:, object:)
36
- method_name = schema_element_names.canonical_name_for(field.name)
37
- !!method_name && respond_to?(method_name)
38
- end
39
-
40
35
  private
41
36
 
42
37
  def args_to_canonical_form(args)
@@ -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
@@ -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
@@ -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
@@ -18,37 +18,18 @@ module ElasticGraph
18
18
  # The type in which the field resides.
19
19
  attr_reader :parent_type
20
20
 
21
- attr_reader :schema, :schema_element_names, :graphql_field, :name_in_index, :relation, :computation_detail
21
+ attr_reader :schema, :schema_element_names, :graphql_field, :name_in_index, :relation, :computation_detail, :resolver
22
22
 
23
- def initialize(schema, parent_type, graphql_field, runtime_metadata)
23
+ def initialize(schema, parent_type, graphql_field, runtime_metadata, resolvers_needing_lookahead)
24
24
  @schema = schema
25
25
  @schema_element_names = schema.element_names
26
26
  @parent_type = parent_type
27
27
  @graphql_field = graphql_field
28
28
  @relation = runtime_metadata&.relation
29
29
  @computation_detail = runtime_metadata&.computation_detail
30
- @name_in_index = runtime_metadata&.name_in_index&.to_sym || name
31
-
32
- # Adds the :extras required by ElasticGraph. For now, this blindly adds `:lookahead`
33
- # to each field so that we have access to what the child selections are, as described here:
34
- #
35
- # https://graphql-ruby.org/queries/lookahead
36
- #
37
- # Currently we only need this when building an `DatastoreQuery` (which is not done for all
38
- # fields) so a future optimization may only add this to fields where we actually need it.
39
- # For now we add it to all fields because it's simplest and it's not clear if there is
40
- # any performance benefit to not adding it when we do not use it.
41
- #
42
- # Note: input fields do not respond to `extras`, which is why we do it conditionally here.
43
- #
44
- # Note: on GraphQL gem introspection types (e.g. `__Field`), the fields respond to `:extras`,
45
- # but that later causes a weird error (`ArgumentError: unknown keyword: :lookahead`)
46
- # when those types are accessed in a Query. We don't really want to mutate the fields on the
47
- # built-in types by adding `:lookahead` so it's best to avoid setting that extra on the built
48
- # in types.
49
- if @graphql_field.respond_to?(:extras) && !BUILT_IN_TYPE_NAMES.include?(parent_type.name.to_s)
50
- @graphql_field.extras([:lookahead])
51
- end
30
+ @resolver = runtime_metadata&.resolver
31
+ @name_in_index = runtime_metadata&.name_in_index || name
32
+ @graphql_field.extras([:lookahead]) if resolvers_needing_lookahead.include?(@resolver)
52
33
  end
53
34
 
54
35
  def type
@@ -56,7 +37,11 @@ module ElasticGraph
56
37
  end
57
38
 
58
39
  def name
59
- @name ||= @graphql_field.name.to_sym
40
+ @name ||= @graphql_field.name
41
+ end
42
+
43
+ def path_in_index
44
+ @path_in_index ||= name_in_index.split(".")
60
45
  end
61
46
 
62
47
  # Returns an object that knows how this field joins to its relation.
@@ -93,7 +78,7 @@ module ElasticGraph
93
78
  return [] if parent_type.relay_connection? || parent_type.relay_edge?
94
79
  return index_id_field_names_for_relation if relation_join
95
80
 
96
- [name_in_index.to_s]
81
+ [name_in_index]
97
82
  end
98
83
 
99
84
  # Indicates this field should be hidden in the GraphQL schema so as to not be queryable.