Repository navigation
Discover unions and interfaces used only by interface fields - #844
Merged
Merged
Conversation
The nested do/while loops ran each step only once the steps before it had nothing left, and started over after anything new was found. One loop with a step per block says the same thing top to bottom, and makes room for more steps without nesting deeper. No change in behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An implementation may narrow an interface field to one member of the field's union (or one implementor of its interface), which is valid GraphQL. The scanner only discovers types through object fields and "implements" clauses, so a union referenced only from an interface field was never added to the definitions. The schema build then failed in createInterfaceObject with "Expected type 'TestUnion' to be a GraphQLOutputType, but it wasn't!", and the union was logged as unused. Once nothing else is left to scan, the discovery loop now also walks the field types of every discovered interface and adds any union or interface that hasn't been found yet. The existing loops then require classes for the union's members and the interface's implementors, as they do for types reached through object fields. Running this step last keeps the discovery order unchanged for every type reachable some other way, so a union whose members are only found by scanning dictionary types is still found after those types are scanned. Unions only used by interface fields no longer need a dummy field or includeUnusedTypes(true) to be built. The step is skipped when includeUnusedTypes is on. The unused types loop already picks these unions and interfaces up one at a time, scanning in between, so a member that is only reachable through another member's class is still found there. Checking all members at once would reject schemas that build today with the includeUnusedTypes workaround. Fixes #597, the same problem as #382. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oryan-block
force-pushed
the
bugfix/597
branch
from
October 10, 2026 21:18
bc20e37 to
29c87af
Compare
The tests for unions and interfaces used only by interface fields made SchemaClassScannerTest the largest test file, so they now live in SchemaClassScannerInterfaceFieldTest, like SchemaClassScannerDirectiveTest. The test for a union on the field of a parent interface is dropped. The scanner finds that interface through the object's own implements clause, the same path as the test for interfaces discovered later in the scan. The ordering tests no longer run a query, since building the schema is what they check. A new test covers a union that is only found through a member of another such union, which needs a second pass of the step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
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.



Fixes #597
Checklist
Description
A union that's only used by an interface field can't be built. Take the schema from #597:
interface TestInterface { testUnion: TestUnion }is implemented byTestInterfaceImpl, which narrows the field totestUnion: TestUnion1. There's alsounion TestUnion = TestUnion1 | TestUnion2, and no object type returnsTestUnion. Narrowing a field like that is valid GraphQL, butmakeExecutableSchema()fails withExpected type 'TestUnion' to be a GraphQLOutputType, but it wasn't!and the scanner logsTestUnionas "defined but can never be accessed". Today you get around it by adding a dummy object field that returnsTestUnion, or withincludeUnusedTypes(true)plus dictionary entries.SchemaClassScanneronly finds types in two ways: through the fields of object types (by scanning resolver classes), and throughimplements. It never looks at the field definitions of interfaces. SoTestUnionnever gets into the scanner's dictionary, andSchemaParser.createInterfaceObjectcan't find it when it buildsTestInterface.testUnion.Now the discovery loop also walks the field types of every discovered interface, with list and non-null wrappers removed. Any union or interface it hasn't found yet gets added. After that, the existing loops require classes for the union's members and the interface's implementors, the same as for types reached through object fields. I left objects, enums and scalars out on purpose. An implementation can't narrow those, so if the interface has any implementor, they're already found through its class.
The new step only runs once nothing else is left to scan. Running it earlier breaks schemas that build today. Say a union member is only found by scanning another dictionary type, like
Holder { image: Image }withHolderin the dictionary. Then the union-member check runs beforeHolderis scanned and rejectsImage. Running it last keeps the discovery order the same for every type that can be reached some other way. The step is also skipped withincludeUnusedTypes(true). The unused types loop already picks these unions up one at a time and scans in between. So a member that's only reachable through another member's class is still found there, and checking all members at once would break the current workaround in that case.I added nine tests to
SchemaClassScannerTest. The first six fail on master with theGraphQLOutputTypeerror:TestUnion2interface NamedNode implements Node)The other three cover the ordering above. Two are for union members and interface implementors that are only found by scanning dictionary types. The third is the
includeUnusedTypesworkaround with a member that's only reachable through another member's class. These pass on master too and are there so the order doesn't regress.extend interfaceis still broken, and I kept it out of this PR.definitionsByNameandinterfaceDefinitionsByNamekeep only the last definition with a given name, so the extension replaces the base and the built interface only has the extension's fields. That's an older bug, but it interacts with this change in one spot. Withinterface EItem { id: ID }andextend interface EItem { content: EContent }, master fails to build because it can't findEContent. Now it builds, butEItemhas noid. It needs its own issue. Also, an interface with no implementors at all, whose field returns an object type found nowhere else, still fails in the parser. I don't think that schema is usable anyway.#382 is the same problem with single-member unions (
union Result = Nodeused asresults: [Result!]!oninterface SearchResponse). It's already closed as a duplicate of #597. The test uses its schema, plus theQueryand enum definitions the issue leaves out.Behaviour change: schemas with a union or interface used only by interface fields now build. Every member of such a union needs a class, found through resolvers or passed in the dictionary, the same as unions reached through object fields. Without one you now get
Object type 'TestUnion2' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'TestUnion2' in the parser's dictionary.instead of theGraphQLOutputTypeerror. It doesn't say which union, but it names the missing type and tells you how to fix it, so I left the message alone. The dummy field workaround in #597 already needed a class forTestUnion2, so the reporter's setup should build without the dummy field now. These unions also stop showing up in the "can never be accessed" warning and ingetUnusedDefinitions(). Nothing changes withincludeUnusedTypes(true). Apart from theextend interfacecase above, I don't think any schema that built before behaves differently. The parser always failed on an interface field whose type wasn't discovered.🤖 Generated with Claude Code