OneOf Inhabitability#4564
Conversation
|
@jbellenger is attempting to deploy a commit to the The GraphQL Foundation Team on Vercel. A member of the Team first needs to authorize it. |
64e8899 to
bd3e1bf
Compare
benjie
left a comment
There was a problem hiding this comment.
Great tests! (It's 10:30pm so I've not tried to review the code... 😅)
bd3e1bf to
ba19546
Compare
ba19546 to
c5a600f
Compare
Rework the circular references validation to detect input object types that cannot be provided a finite value. This covers: - Self-recursive OneOf types (e.g. input A @OneOf { a: A }) - Mixed OneOf/non-OneOf cycles with no escape path - Standard non-null circular references (existing behavior preserved) Rename algorithms to match spec terminology: - InputObjectHasUnbreakableCycle (was InputObjectCanBeProvidedAFiniteValue) - InputFieldTypeHasUnbreakableCycle (was FieldTypeCanBeProvidedAFiniteValue)
- add memoization to improve validation performance - graceful handling of empty oneof defintions - add missing test coverage
Update error reporting for uninhabited cycles to match the previous implementation, which would report 1 error per distinct cycle. Also, update the errors to use the spec language "cannot be provided a finite value"
c4706c3 to
fa0f160
Compare
|
I finally think I understand everything here and this is awesome. I pushed some changes/reorg added another memoization layer. |
6ef16f3 to
2def7ac
Compare
|
to add some detail
it's all somewhat slower than baseline |
2def7ac to
ad79f2a
Compare
The previous InputObjectHasUnbreakableCycle check ran once for every Input Object. Even with memoization, a type whose result was still unknown had to collect the Input Objects reachable from that root before it could prove which nodes were breakable. Shared OneOf and non-null Input Object subgraphs could therefore be walked from multiple roots before the memo settled. Instead, initialize one state for every Input Object while validating the schema. detectInputObjectUnbreakableCycles then performs a single shallow pass over those states, recording only the edges that can force an unbreakable cycle: OneOf fields whose type is another Input Object, and non-OneOf fields whose type is a non-null Input Object. Fields with scalar, enum, list, or nullable escapes never become targets. Types with an immediate escape are pushed onto a worklist, and the worklist propagates that breakability through reverse dependents until only states with unbreakable cycles remain. Once that fixed point is reached, detectInputObjectUnbreakableCycles reports cycles directly from the remaining states. Reporting cannot safely happen during the initial edge-discovery pass because a state that first appears cyclic may later be proven breakable by worklist propagation from one of its targets. For example, consider a chain like T16 @OneOf { a: T15, b: T15 }, ... T1 @OneOf { a: T0, b: T0 }, T0 @OneOf { self: T0 }. The old per-root check can rediscover the same T15..T0 tail while checking different roots. The global pass calls getFields once per Input Object, records each Tn -> Tn-1 dependency once, and then propagates results through the dependent lists. That avoids repeated reachable-subgraph collection on schemas with shared input-object tails, which is the validateSchema regression this fixes.
ad79f2a to
ac64e04
Compare
|
Do we want (A) an error per input object cycles, or do we want (B) one error per input object listing all the cycles it has? Let's assume that both options filter so that we never report the same cycle more than once, but see the change in the last commit, we do (B), which would apply to non-OneOf cycles as well? In v16 (and prior to that commit ) we did (A), and have for a while with regard to non-OneOf cycles, but I think whatever we do should be consistent for both. I feel like @benjie weighed in on this at the graphql-wg discussion about it? |
|
@yaacovCR Thanks for the polish you've put on this! Re reporting, I think the wg discussion was here: https://youtu.be/0JP4x-Uf2qw?si=qhrpHaOaKFxYKsoO&t=997 My takeaway was that we'd want one error per cause, which I think means option B. This would also have the nice property of providing complete diagnostic information to an agent. |
|
Shoot, I edited my comment. I think A and B were technically the same but I think it's right now. |
|
Hmmm I guess the preference I read was toward A ie keep doing what we were doing, we should get a different error per cycle as the cycle was the cause not the input object per se…. I’m going to roll back that last commit….. |
to demonstrate that OneOf behavior is parallel to non-OneOf
ac64e04 to
b967e36
Compare
|
Basically, we can table that debate for this PR as it also changes how errors have been reported with regard to circular Input Objects for quite some time. If we want to change how multiple cycles starting at the same Input Object works, we should do so in lockstep across non-OneOf and OneOf cycles, and do so in a separate PR. I am pretty much agnostic, leaning toward grouping together; at the bottom line, we have all of the information in both variations both with regards to the messages and erroring location, the question is only the grouping. |
Big plus one. |
## v17.0.0 (2026-06-15) #### New Feature 🚀 * [#4819](#4819) feat: graduate directives on directives ([@yaacovCR](https://github.com/yaacovCR)) #### Bug Fix 🐞 * [#4799](#4799) fix: raise request error on invalid fragment variables ([@yaacovCR](https://github.com/yaacovCR)) * [#4800](#4800) fix: apply directives when SDL contains type definitions and extensions with directives ([@yaacovCR](https://github.com/yaacovCR)) * [#4564](#4564) OneOf Inhabitability ([@jbellenger](https://github.com/jbellenger)) * [#4814](#4814) fix(KnownDirectivesRule): locations for input field arguments in extensions ([@yaacovCR](https://github.com/yaacovCR)) * [#4726](#4726) feat(validation): reject directive definition cycles ([@yaacovCR](https://github.com/yaacovCR)) * [#4815](#4815) Revert "feat(validation): reject directive definition cycles (#4726)" ([@yaacovCR](https://github.com/yaacovCR)) #### Docs 📝 <details> <summary> 11 PRs were merged </summary> * [#4790](#4790) docs: minor diagnostics doc comment improvements ([@yaacovCR](https://github.com/yaacovCR)) * [#4791](#4791) fix: docs: polish diagnostics comments further ([@yaacovCR](https://github.com/yaacovCR)) * [#4793](#4793) docs: further improve general execution and tracing docs ([@yaacovCR](https://github.com/yaacovCR)) * [#4802](#4802) docs: correct extension field comments - v17 ([@yaacovCR](https://github.com/yaacovCR)) * [#4805](#4805) docs: publish fixed extensions comments ([@yaacovCR](https://github.com/yaacovCR)) * [#4807](#4807) docs: add prettier for jsdoc examples ([@yaacovCR](https://github.com/yaacovCR)) * [#4435](#4435) Subscriptions docs suggestions ([@Urigo](https://github.com/Urigo)) * [#4811](#4811) docs: fix a few indentations inside string literals ([@yaacovCR](https://github.com/yaacovCR)) * [#4813](#4813) internal: docs update ([@yaacovCR](https://github.com/yaacovCR)) * [#4820](#4820) docs: document `@experimental_disableErrorPropagation` ([@yaacovCR](https://github.com/yaacovCR)) * [#4817](#4817) docs: post 17.rc-0 update ([@yaacovCR](https://github.com/yaacovCR)) </details> #### Polish 💅 <details> <summary> 2 PRs were merged </summary> * [#4809](#4809) internal: use prettier for non-generated website files ([@yaacovCR](https://github.com/yaacovCR)) * [#4812](#4812) polish: fix stream test cases ([@yaacovCR](https://github.com/yaacovCR)) </details> #### Internal 🏠 <details> <summary> 6 PRs were merged </summary> * [#4795](#4795) chore: move website publishing from 16.x.x to 17.x.x ([@yaacovCR](https://github.com/yaacovCR)) * [#4796](#4796) internal: fix broken npm/deno deployments ([@yaacovCR](https://github.com/yaacovCR)) * [#4797](#4797) ci: update GitHub Actions versions ([@yaacovCR](https://github.com/yaacovCR)) * [#4806](#4806) internal: update frontmatter ([@yaacovCR](https://github.com/yaacovCR)) * [#4808](#4808) intenral: fix ci badge ([@yaacovCR](https://github.com/yaacovCR)) * [#4810](#4810) internal: add prettier:examples to lint-staged ([@yaacovCR](https://github.com/yaacovCR)) </details> #### Committers: 3 * James Bellenger([@jbellenger](https://github.com/jbellenger)) * Uri Goldshtein([@Urigo](https://github.com/Urigo)) * Yaacov Rydzinski ([@yaacovCR](https://github.com/yaacovCR))


This PR implements the validation described at graphql/graphql-spec#1211
More discussion is at the link above, but the core issue is around uninhabited OneOf types -- the property that a type can be defined in a way that a value cannot be generated for it.
The simplest example of an uninhabited type is this recursive OneOf:
While this type definition is currently valid by the rules of OneOf types and circular input types, it violates the spirit of the Circular References because it does not allow construction of a finite value.
This PR updates the existing cycle validation to consider OneOf types. I've tried to follow the existing conventions, though I'm also new here and might have missed something. Any feedback on style or substance is welcome.