Skip to content

Introduce QueryContext to replace magic GraphQL context keys - #1341

Merged
myronmarston merged 2 commits into
mainfrom
worktree-gh-1339
Aug 14, 2026
Merged

Introduce QueryContext to replace magic GraphQL context keys#1341
myronmarston merged 2 commits into
mainfrom
worktree-gh-1339

Conversation

@myronmarston

Copy link
Copy Markdown
Collaborator

Summary

  • Replaces untyped GraphQL 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 new ElasticGraph::GraphQL::QueryContext < GraphQL::Query::Context.
  • QueryContext.new_class closes an anonymous subclass over elastic_graph_schema/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 immediately after building the query.
  • :visibility_profile stays in the context hash since it's needed at schema-boot time, before a QueryContext exists.
  • No behavior change; no dedicated unit spec (existing seam-level specs already give 100% coverage).

Closes #1339

Test plan

  • script/lint
  • script/type_check
  • script/run_gem_specs elasticgraph-graphql (100% coverage)
  • script/run_gem_specs elasticgraph-apollo (100% coverage)
  • script/run_gem_specs elasticgraph-query_registry (100% coverage)
  • script/run_gem_specs elasticgraph-query_interceptor (100% coverage)
  • script/quick_build

…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.
PR-1341 followup (review comment from Ellis): raise a helpful
ConfigError instead of silently returning nil if custom resolver
code still reads a key that moved to a typed accessor.
@myronmarston
myronmarston enabled auto-merge August 14, 2026 02:28
@myronmarston
myronmarston merged commit c640d33 into main Aug 14, 2026
22 checks passed
@myronmarston
myronmarston deleted the worktree-gh-1339 branch August 14, 2026 02:53
myronmarston added a commit that referenced this pull request Aug 18, 2026
Closes #1340. Stacked on #1341.

A resolver that builds its datastore query from a lookahead can discover
that a *descendant* field is invalid —
`approximatePercentile(percentile: 150)`, say. It can't raise
`GraphQL::ExecutionError` there: that fails the recording field's entire
subtree instead of the one bad leaf.

So it records a **lookahead error**, and ElasticGraph fails the flagged
field when execution reaches it.

## The API

```ruby
# From any resolver, while it is being resolved:
context.record_lookahead_error(node, message, extensions: nil)

# For a resolver flagging *itself*, to report it the same way a descendant would be reported:
context.matching_lookahead_error
```

That's the whole surface. `Resolvers::GraphQLAdapterBuilder`'s resolver
lambdas check for a matching record before resolving, and return a
`GraphQL::ExecutionError` built from the record instead of running the
registered resolver — halting just that field or subtree, leaving
siblings and other list elements alone.

## How a record is matched

Each record is keyed by the flagged node's **absolute, index-free
response path**, found by walking down from `context.query.lookahead` to
the node's AST nodes. A field matches when its `context.current_path`,
with list indices dropped, equals that path.
`docs/adr/0001-lookahead-error-record-key.md` records the rationale and
the alternatives considered.

Two consequences worth calling out:

- **One record can match many response positions** — every bucket of an
aggregation, for instance — each producing its own error at its own
path. That's what the GraphQL spec asks for, and it's what lets a client
tell an errored `null` from a real one. Nothing is lost: a lookahead
error is a property of the query, not of the data.
- **Datastore query memoization needs no involvement.** A cache hit
implies identical `lookahead.ast_nodes` (which hash by identity), hence
the same document position, hence the same index-free path as the record
made on the miss.

## Misuse is caught at the call site

`record_lookahead_error` raises `Errors::ConfigError` if the node isn't
selected at or beneath the field being resolved. That covers a node from
an unrelated part of the query and a typo'd `Lookahead#selection` name
(which returns a null object) alike, so there's no after-the-fact bug
detection to maintain.

## Replaces the `approximatePercentile` workaround

`Aggregation::QueryAdapter#computation_for` records the error rather
than silently omitting the computation, and
`Aggregation::Resolvers::AggregatedValues#resolve` no longer needs its
own parallel validation and error-path logic.

## Testing

- `spec/unit/elastic_graph/graphql/lookahead_error_recording_spec.rb`
exercises the mechanism end-to-end through `register_graphql_resolver` +
`graphql_query_executor.execute` against static data (no datastore),
covering: multi-alias leaf halting, object-subtree halting,
self-flagging, a record execution never reaches, and both misuse cases.
- `spec/acceptance/aggregations_spec.rb` covers the real
`approximatePercentile` path against a live datastore, grouped so that
multiple buckets each get their own error.
- `script/run_specs`, `script/type_check`, `script/lint`,
`script/spellcheck` all clean; 100% line and branch coverage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Introduce a QueryContext subclass to replace magic GraphQL context keys

2 participants