Skip to content

Commit 76b9c7b

Browse files
committed
Introduce QueryContext to replace magic GraphQL context keys, closes #1339
Replaces untyped context hash keys (:elastic_graph_schema, :datastore_search_router, :datastore_query_cache, :elastic_graph_query_tracker, :monotonic_clock_deadline, :elastic_graph_client, :http_request) with typed accessors on a QueryContext < GraphQL::Query::Context. - QueryContext.new_class closes an anonymous subclass over elastic_graph_schema and datastore_search_router, registered as the schema's context_class. - monotonic_clock_deadline/elastic_graph_client/http_request are read-only attrs, set once via QueryContext#register_elastic_graph_values (defaults: elastic_graph_client: Client::ANONYMOUS, others nil) called by each query-building call site right after building the query. - :visibility_profile stays in the context hash (needed at schema-boot time, before a QueryContext exists). - No dedicated unit spec: existing seam-level specs already give 100% coverage, and a direct unit test would pin internal APIs unnecessarily.
1 parent 2228134 commit 76b9c7b

44 files changed

Lines changed: 280 additions & 108 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

elasticgraph-apollo/apollo_tests_implementation/lib/product_resolver.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def initialize(elasticgraph_graphql:, config:)
2323
def resolve(field:, object:, args:, context:)
2424
query = @datastore_query_builder.new_query(
2525
initial_search_index_definitions: [@product_index_def],
26-
monotonic_clock_deadline: context[:monotonic_clock_deadline],
26+
monotonic_clock_deadline: context.monotonic_clock_deadline,
2727
client_filters: [{"id" => {"equalToAnyOf" => [args.fetch("id")]}}],
2828
individual_docs_needed: true,
2929
request_all_fields: true

elasticgraph-apollo/lib/elastic_graph/apollo/graphql/entities_field_resolver.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def initialize(elasticgraph_graphql:, config:)
2525
end
2626

2727
def resolve(field:, object:, args:, context:, lookahead:)
28-
schema = context.fetch(:elastic_graph_schema)
28+
schema = context.elastic_graph_schema
2929

3030
representations = args.fetch("representations").map.with_index do |rep, index|
3131
try_parse_representation(rep, schema) do |error_description|
@@ -40,7 +40,7 @@ def resolve(field:, object:, args:, context:, lookahead:)
4040
query_attributes = ElasticGraph::GraphQL::QueryAdapter::RequestedFields
4141
.new(schema)
4242
.query_attributes_for(field: field, lookahead: lookahead)
43-
.merge(monotonic_clock_deadline: context[:monotonic_clock_deadline])
43+
.merge(monotonic_clock_deadline: context.monotonic_clock_deadline)
4444

4545
# Build a separate query per adapter instance since each adapter instance is capable of building
4646
# a single query that handles all representations assigned to it.

elasticgraph-apollo/lib/elastic_graph/apollo/graphql/service_field_resolver.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def initialize(elasticgraph_graphql:, config:)
1818
end
1919

2020
def resolve(field:, object:, args:, context:)
21-
{"sdl" => service_sdl(context.fetch(:elastic_graph_schema))}
21+
{"sdl" => service_sdl(context.elastic_graph_schema)}
2222
end
2323

2424
private

elasticgraph-apollo/sig/elastic_graph/apollo/graphql/entities_field_resolver.rbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ module ElasticGraph
4343
def identify_matching_hit: (
4444
I,
4545
R,
46-
context: ::GraphQL::Query::Context,
46+
context: ElasticGraph::GraphQL::QueryContext,
4747
index: ::Integer
4848
) -> (ElasticGraph::GraphQL::DatastoreResponse::Document | ::Hash[::String, untyped])?
4949

elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/query_adapter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def initialize(config:, filter_args_translator:, runtime_metadata:, sub_aggregat
4343
def call(query:, lookahead:, args:, field:, context:)
4444
return query unless field.type.unwrap_fully.indexed_aggregation?
4545

46-
@build_adapter.call(context.fetch(:elastic_graph_schema)).call(
46+
@build_adapter.call(context.elastic_graph_schema).call(
4747
query: query,
4848
lookahead: lookahead,
4949
args: args,

elasticgraph-graphql/lib/elastic_graph/graphql/http_endpoint.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def process(request, max_timeout_in_ms: nil, start_time_in_ms: @monotonic_clock.
5757
client: client_or_response,
5858
timeout_in_ms: parsed.timeout_in_ms,
5959
context: parsed.context,
60+
http_request: request,
6061
start_time_in_ms: start_time_in_ms
6162
)
6263

@@ -155,17 +156,18 @@ def with_timeout(request, max_timeout_in_ms:)
155156
yield [max_timeout_in_ms, requested_timeout_in_ms].compact.min
156157
end
157158

158-
# Responsible for determining any `context` values to pass down into the `query_executor`,
159-
# which in turn will make the values available to the GraphQL resolvers.
159+
# Responsible for determining any extra `context` values to pass down into the `query_executor`,
160+
# which in turn will make the values available to the GraphQL resolvers. (`http_request` itself is
161+
# always passed down separately--see `process`--so it does not need to be added here.)
160162
#
161-
# By default, our only context value is the HTTP request. This method exists to provide an extension
163+
# By default we have no extra context values. This method exists to provide an extension
162164
# point so that ElasticGraph extensions can add `context` values based on the `request` as desired.
163165
#
164166
# Extensions can return an `HTTPResponse` with an error if the `request` is invalid according
165167
# to their requirements. Otherwise, they must call `super` (to delegate to this and any other
166168
# extensions) with a block. In the block, they must merge in their `context` values and then `yield`.
167169
def with_context(request)
168-
yield({http_request: request})
170+
yield({})
169171
end
170172

171173
ParsedRequest = Data.define(:query_string, :variables, :operation_name, :timeout_in_ms, :context)

elasticgraph-graphql/lib/elastic_graph/graphql/query_adapter/requested_fields.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class WithoutSchema
2121
def call(field:, query:, lookahead:, args:, context:)
2222
return query if field.type.unwrap_fully.indexed_aggregation?
2323

24-
RequestedFields.new(context.fetch(:elastic_graph_schema)).call(
24+
RequestedFields.new(context.elastic_graph_schema).call(
2525
field: field,
2626
query: query,
2727
lookahead: lookahead,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright 2024 - 2026 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/client"
10+
require "elastic_graph/graphql/query_details_tracker"
11+
require "graphql"
12+
13+
module ElasticGraph
14+
class GraphQL
15+
# `GraphQL::Query::Context` subclass used by ElasticGraph, providing typed accessors in
16+
# place of untyped context hash keys. Registered as the `context_class` on the `GraphQL::Schema`
17+
# built by `Schema`, via `new_class`, so that every query gets an instance closed over the
18+
# `Schema` and `DatastoreSearchRouter` in use for that schema.
19+
class QueryContext < ::GraphQL::Query::Context
20+
def self.elastic_graph_schema
21+
@elastic_graph_schema
22+
end
23+
24+
def self.datastore_search_router
25+
@datastore_search_router
26+
end
27+
28+
# Builds a `QueryContext` subclass closed over the given `elastic_graph_schema` and
29+
# `datastore_search_router`, suitable for registering as a `GraphQL::Schema#context_class`.
30+
def self.new_class(elastic_graph_schema:, datastore_search_router:)
31+
klass = ::Class.new(self) # : singleton(QueryContext)
32+
klass.instance_variable_set(:@elastic_graph_schema, elastic_graph_schema)
33+
klass.instance_variable_set(:@datastore_search_router, datastore_search_router)
34+
klass
35+
end
36+
37+
# `elastic_graph_schema` is only `nil` before `new_class` has closed over it, which never
38+
# happens for a `QueryContext` actually used to execute a query (see `Schema#initialize`).
39+
def elastic_graph_schema
40+
self.class.elastic_graph_schema # : Schema
41+
end
42+
43+
def datastore_search_router
44+
self.class.datastore_search_router # : DatastoreSearchRouter
45+
end
46+
47+
# @dynamic monotonic_clock_deadline, elastic_graph_client, http_request
48+
attr_reader :monotonic_clock_deadline, :elastic_graph_client, :http_request
49+
50+
# Registers the given ElasticGraph-managed values on this context. Intended to be called
51+
# exactly once, by `Schema#new_graphql_query` callers immediately after building a query and
52+
# before making it available to resolvers or other user-facing code. Not for use elsewhere.
53+
def register_elastic_graph_values(monotonic_clock_deadline: nil, elastic_graph_client: Client::ANONYMOUS, http_request: nil)
54+
@monotonic_clock_deadline = monotonic_clock_deadline
55+
@elastic_graph_client = elastic_graph_client
56+
@http_request = http_request
57+
end
58+
59+
# Lazily builds (and memoizes) the `QueryDetailsTracker` for this query.
60+
def elastic_graph_query_tracker
61+
@elastic_graph_query_tracker ||= QueryDetailsTracker.empty
62+
end
63+
64+
# Caches the result of the given block (a built `DatastoreQuery`) under `cache_key`, scoped to
65+
# this single query execution. See `Resolvers::QueryAdapter#build_query_from` for why this
66+
# memoization is beneficial.
67+
def cache_datastore_query(cache_key)
68+
datastore_query_cache[cache_key] ||= yield
69+
end
70+
71+
private
72+
73+
def datastore_query_cache
74+
@datastore_query_cache ||= {}
75+
end
76+
end
77+
end
78+
end

elasticgraph-graphql/lib/elastic_graph/graphql/query_executor.rb

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
# frozen_string_literal: true
88

99
require "elastic_graph/graphql/client"
10-
require "elastic_graph/graphql/query_details_tracker"
1110
require "elastic_graph/support/hash_util"
1211

1312
module ElasticGraph
@@ -40,6 +39,7 @@ def execute(
4039
timeout_in_ms: nil,
4140
operation_name: nil,
4241
context: {},
42+
http_request: nil,
4343
start_time_in_ms: @monotonic_clock.now_in_ms
4444
)
4545
# Before executing the query, prune any null-valued variable fields. This means we
@@ -50,20 +50,18 @@ def execute(
5050
# due to a null-valued field referencing an undefined schema element.
5151
variables = ElasticGraph::Support::HashUtil.recursively_prune_nils_from(variables)
5252

53-
query_tracker = QueryDetailsTracker.empty
54-
5553
query, result = build_and_execute_query(
5654
query_string: query_string,
5755
variables: variables,
5856
operation_name: operation_name,
5957
client: client,
60-
context: context.merge({
61-
monotonic_clock_deadline: timeout_in_ms&.+(start_time_in_ms),
62-
elastic_graph_query_tracker: query_tracker,
63-
elastic_graph_client: client
64-
}.compact)
58+
context: context,
59+
monotonic_clock_deadline: timeout_in_ms&.+(start_time_in_ms),
60+
http_request: http_request
6561
)
6662

63+
query_tracker = query.context.elastic_graph_query_tracker
64+
6765
unless result.to_h.fetch("errors", []).empty?
6866
@logger.error <<~EOS
6967
Query #{query.selected_operation_name}[1] for client #{client.description} resulted in errors[2].
@@ -128,13 +126,18 @@ def execute(
128126

129127
# Note: this is designed so that `elasticgraph-query_registry` can hook into this method. It needs to be able
130128
# to override how the query is built and executed.
131-
def build_and_execute_query(query_string:, variables:, operation_name:, context:, client:)
129+
def build_and_execute_query(query_string:, variables:, operation_name:, context:, client:, monotonic_clock_deadline:, http_request:)
132130
query = @schema.new_graphql_query(
133131
query_string,
134132
variables: variables,
135133
operation_name: operation_name,
136134
context: context
137135
)
136+
query.context.register_elastic_graph_values(
137+
monotonic_clock_deadline: monotonic_clock_deadline,
138+
elastic_graph_client: client,
139+
http_request: http_request
140+
)
138141

139142
[query, execute_query(query, client: client)]
140143
end

elasticgraph-graphql/lib/elastic_graph/graphql/resolvers/graphql_adapter_builder.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def object_type_hash
5454
resolver_lambda =
5555
if resolver.method(:resolve).parameters.include?([:keyreq, :lookahead])
5656
lambda do |object, args, context|
57-
schema_field = context.fetch(:elastic_graph_schema).field_named(type_name, field_name)
57+
schema_field = context.elastic_graph_schema.field_named(type_name, field_name)
5858

5959
# Extract the `:lookahead` extra that we have configured all fields to provide.
6060
# See https://graphql-ruby.org/api-doc/1.10.8/GraphQL/Execution/Lookahead.html for more info.
@@ -80,7 +80,7 @@ def object_type_hash
8080
end
8181
else
8282
lambda do |object, args, context|
83-
schema_field = context.fetch(:elastic_graph_schema).field_named(type_name, field_name)
83+
schema_field = context.elastic_graph_schema.field_named(type_name, field_name)
8484
# Convert args to the form they were defined in the schema, undoing the normalization
8585
# the GraphQL gem does to convert them to Ruby keyword args form.
8686
args = schema_field.args_to_schema_form(args)
@@ -109,7 +109,7 @@ def object_type_hash
109109

110110
# In order to support unions and interfaces, we must implement `resolve_type`.
111111
def resolve_type(supertype, object, context)
112-
schema = context.fetch(:elastic_graph_schema)
112+
schema = context.elastic_graph_schema
113113
# If `__typename` is available, use that to resolve. It will be present on embedded abstract
114114
# types, and also on root documents indexed in a shared interface/union index.
115115
# (See `Inventor` in `config/schema/widgets.rb` for an example of an embedded abstract type.)
@@ -124,7 +124,8 @@ def resolve_type(supertype, object, context)
124124
# (See `Part` in `config/schema/widgets.rb` for an example of this kind of type union.)
125125
# This branch is only reached for individually-indexed types (no `__typename`
126126
# in the document), so the set always contains exactly one type.
127-
schema.document_types_stored_in(object.index_definition_name).first.graphql_type
127+
type = schema.document_types_stored_in(object.index_definition_name).first # : Schema::Type
128+
type.graphql_type
128129
end
129130
end
130131
end

0 commit comments

Comments
 (0)