Skip to content

feat(graphql): check flattened projection root types at compile time - #303

Closed
tpham-mysten wants to merge 9 commits into
amnn/gql-flattenfrom
joy/gql-flatten-root-type-check
Closed

feat(graphql): check flattened projection root types at compile time#303
tpham-mysten wants to merge 9 commits into
amnn/gql-flattenfrom
joy/gql-flatten-root-type-check

Conversation

@tpham-mysten

@tpham-mysten tpham-mysten commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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_type to the root it is flattened into, so this compiles and fails only at runtime:

#[derive(Response)]
#[response(root_type = "Epoch")]
struct EpochMetadata { /* reads epochId */ }

#[derive(Response)]
#[response(root_type = "Object")]
struct ObjectResponse {
    #[field(flatten)]
    metadata: EpochMetadata,
}

graphql_query! rejects the equivalent fragment spread, but Client::query takes 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 Object that is {Object, Node, IAddressable, IObject}. The reverse is rejected, since IObject's other implementors do not carry Object's fields.

How

A proc macro sees the token EpochMetadata, not a type, so it cannot look up that type's root_type. The check spans two compiler phases: the derive stamps RESPONSE_ROOT_TYPE on every response type and emits a const block 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 Response declares no root and is accepted exactly where Query is, so hand-written extract types keep working unchanged. A sentinel root lets the error distinguish a missing derive from a mismatched root_type.

Test plan

$ cargo test -p sui-graphql-macros
$ RUSTFLAGS="-D warnings" cargo test -p sui-graphql-macros
$ cargo clippy -p sui-graphql-macros --all-targets && cargo fmt --check

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, no extract, a defaulted or mismatched root_type, and the reverse direction.

🤖 Generated with Claude Code

https://claude.ai/code/session_017cshe8ffhPkc5cpUS4NLnc

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
@amnn

amnn commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Good spot, yes, this seems like a good addition, thanks @tpham-mysten.

Only two small suggestions:

  • We should call the concept flatten uniformly, and not introduce the separate spread terminology.
  • It seems like it would be fine to allow responses that don't define a root_type by just assuming that the root type is Query in that case?

tpham-mysten and others added 8 commits August 18, 2026 11:54
…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
tpham-mysten marked this pull request as ready for review August 18, 2026 23:35
@tpham-mysten
tpham-mysten requested a review from bmwill as a code owner August 18, 2026 23:35
@tpham-mysten

Copy link
Copy Markdown
Contributor Author

Thanks @amnn! Both done.

  1. Renamed to use flatten throughout: spread_targets to find_allowed_flatten_roots, generate_spread_assertion to generate_flatten_root_type_check, and the test files to match.

  2. A type that doesn't declare a root_type is now treated as Query. That covers both a Response that omits the attribute and a hand-written extract type with no derive at all, so ExtractOnly needs no attribute now. Also, users can useskip_schema_validation to opt out of the check.

@tpham-mysten

Copy link
Copy Markdown
Contributor Author

Accidentally closed the PR  😕 I have sent a new one on #304

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.

2 participants