feat(graphql): check flattened projection root types at compile time - #303
Closed
tpham-mysten wants to merge 9 commits into
Closed
feat(graphql): check flattened projection root types at compile time#303tpham-mysten wants to merge 9 commits into
tpham-mysten wants to merge 9 commits into
Conversation
A flattened field is populated by handing the complete response value to the field type's `extract`, so nothing tied the flattened type's `root_type` to the root type of the struct it was flattened into. A projection rooted at `Epoch` could be flattened into a struct rooted at `Object`, compile cleanly, and fail only at runtime with a confusing null error. Check the pairing at compile time. A flattened projection is extracted unconditionally, with no `__typename` guard, so its root must be valid for every concrete type the outer position could be: the outer type itself, any interface it implements, or any union it belongs to. A proc macro cannot resolve a field's type path to the `root_type` that type declared, so the check is split across two compiler phases. The derive stamps `RESPONSE_ROOT_TYPE` on every response type, then at each flatten site emits a `const` block holding the acceptable root names, which the schema supplies at expansion time, and compares them against `<FieldTy>::RESPONSE_ROOT_TYPE`, which rustc resolves during const evaluation. The block is anchored at the field's span so the error points at the offending field, and evaluates to `()` on success, so nothing reaches the binary. `schema.rs` was discarding `implements_interfaces`; keep it so the acceptable set can be computed. Flattening a type that does not derive `Response` now requires `#[field(flatten, skip_schema_validation)]`, which gives that attribute a meaning on flattened fields where it was previously accepted and ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc
Collaborator
|
Good spot, yes, this seems like a good addition, thanks @tpham-mysten. Only two small suggestions:
|
…Query` Rename `spread_targets` to `flatten_roots` and drop the separate "spread" vocabulary from the codegen and tests, so the concept is called `flatten` throughout. Treat a field type that does not derive `Response` as rooted at `Query` rather than requiring `skip_schema_validation` to opt out. The generated check declares a blanket trait supplying `RESPONSE_ROOT_TYPE = "Query"`; an inherent associated const takes priority over a trait one, so a derived type still resolves to its own declaration. Flattening a hand-written `extract` type into a `Query`-rooted response now needs no attribute, and `skip_schema_validation` remains as an explicit opt-out. Broaden coverage: - unit tests for `flatten_roots` over interfaces, containing unions, root and interface types, normalization, and unknown types - runtime tests for an interface projection flattened into two implementing types, flatten interleaved with path fields, three levels of nesting, a union projection flattened into a member type, and inner error propagation - compile-fail tests for the reverse direction, a concrete projection flattened into an interface-rooted response, and for a type that declares no root type flattened somewhere other than `Query` The test schema gains an interface and its implementors as separate types, so existing schema-validation diagnostics keep their current field lists. Annotate the generated trait `#[allow(dead_code)]`: it is unused whenever the field type declares its own root type, which would otherwise warn once per flattened field in downstream crates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc
Name the pieces for what they do rather than how they are implemented: `generate_flatten_assertion` becomes `generate_flatten_root_type_check`, and `Schema::flatten_roots` becomes `find_flatten_roots`, matching the verb-first `find_similar` already in `validation`. Record at the call site why this check is emitted as code instead of decided in the macro, since every neighbouring field check is decided in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc
`find_allowed_flatten_roots` says what the result is used for: the set a flattened field's declared root must belong to, which is also the set the error message lists. The doc comment carries the direction, since the argument is the type being flattened into and the result is what may be flattened in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc
…lice The generated check hand-rolled a `const fn` byte comparison and looped over a slice of names, because `str` cannot be compared in a const context. The allowed names are known when the macro runs, so emit them as an or-pattern of byte string literals and let `matches!` do the work, replacing roughly twenty-five lines of generated code with one expression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc
The rejection message told the reader to set `root_type`, which is impossible advice for a type that does not derive `Response` and so has nowhere to put it. State the derive requirement first, and name the root type assumed in its absence, so the message fits a hand-written `extract` as well as a derived projection with the wrong root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc
Two cases had no test: a type missing `extract` flattened into a `Query`-rooted response, which reports only the missing method, and the same type flattened elsewhere, which reports that alongside the root type rejection. Anchor the generated `extract` call at the field's type so the missing-method error points at the offending field. It previously pointed at `#[derive(Response)]`, which on a response struct with many fields does not say which one is at fault. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc
The rejection message had to cover both a field type that never derived `Response` and one that derived it with an incompatible `root_type`, so it described the rule instead of the reader's mistake. Have the blanket impl supply a sentinel rather than `Query`. A type that did not derive `Response` is still accepted exactly where `Query` is, so nothing about which programs compile changes, but the check can now name which of the two mistakes was made and say what to do about each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc
A projection deriving `Response` without a `root_type` is rooted at `Query`, the same root a type without the derive is accepted at. The two are told apart, and only the missing-derive side of that was covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc
tpham-mysten
marked this pull request as ready for review
August 18, 2026 23:35
tpham-mysten
requested review from
a team,
amnn,
evan-wall-mysten,
nickvikeras and
wlmyng
August 18, 2026 23:36
Contributor
Author
|
Thanks @amnn! Both done.
|
Contributor
Author
|
Accidentally closed the PR 😕 I have sent a new one on #304 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft on top of #300. Checks at compile time that a flattened field's root type is compatible with the root type it is flattened into.
Problem
Nothing relates a flattened type's
root_typeto the root it is flattened into, so this compiles and fails only at runtime:graphql_query!rejects the equivalent fragment spread, butClient::querytakes a plain&str, so that only covers queries written through it.Rule
A flattened field is extracted unconditionally, so its root must hold for every concrete type the outer position could be: the outer type itself, any interface it implements, or any union it belongs to. For
Objectthat is{Object, Node, IAddressable, IObject}. The reverse is rejected, sinceIObject's other implementors do not carryObject's fields.How
A proc macro sees the token
EpochMetadata, not a type, so it cannot look up that type'sroot_type. The check spans two compiler phases: the derive stampsRESPONSE_ROOT_TYPEon every response type and emits aconstblock holding the accepted names, which const evaluation compares once rustc has resolved the field's type. Errors are spanned onto the field, nothing reaches the binary, and there are no new dependencies.A type that does not derive
Responsedeclares no root and is accepted exactly whereQueryis, so hand-writtenextracttypes keep working unchanged. A sentinel root lets the error distinguish a missing derive from a mismatchedroot_type.Test plan
Unit tests for
find_allowed_flatten_roots. Runtime tests for interface, union, nested, and mixed flattening. Compile tests for each way a flattened field's type can be wrong: no derive, noextract, a defaulted or mismatchedroot_type, and the reverse direction.🤖 Generated with Claude Code
https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc