Skip to content

Run directive wirings on the assembled schema - #895

Merged
oryan-block merged 5 commits into
masterfrom
bugfix/875
Oct 22, 2026
Merged

oryan-block merged 5 commits into
masterfrom
bugfix/875

Conversation

@oryan-block

@oryan-block oryan-block commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #875

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

A SchemaDirectiveWiring on a recursive input field got a GraphQLTypeReference as the field's type instead of the input object:

directive @check on INPUT_FIELD_DEFINITION

type Query {
    trees(filter: Tree): Int
}

input Tree {
    and: [Tree!] @check
    name: String @check
}

So GraphQLTypeUtil.unwrapAll(element.type) in onInputObjectField threw ClassCastException: GraphQLTypeReference cannot be cast to GraphQLUnmodifiedType and the schema didn't build. Types are immutable, so one field of a cycle has to stay a reference while the types are built, and we ran the wirings right then.

graphql-java runs them only once the schema is assembled, so its wirings never see references. This does the same: the create* methods in SchemaParser no longer wire anything, and parseSchemaObjects builds the schema, which resolves the references in place, then runs the wirings over the types in the same order as before (DirectiveWiringHelper#wireTypes). Only when a wiring is registered, without one nothing changes.

The built types hold each other directly now, so a type a wiring changes would sit next to the old one still held by its neighbours. So a couple of things had to follow:

  • When a wiring changes any type, every type is rebuilt pointing at the others by name again, including inside types the wiring builds in code, and the next build resolves them to the wired types. Otherwise a wiring doing it.type(GraphQLNonNull.nonNull(element.type)) on friend: Person in type Person fails with "This schema is not forming an Directed Acyclic Graph" or "All types within a GraphQL schema must have unique names". graphql-java's own SchemaGenerator fails on that one, but it worked here before, so I kept it working.
  • Directive definitions now point at input objects by name, like they already did for enums. Otherwise input Filter @described @example(value: { name: "a" }) with directive @example(value: Filter) contains itself through its own applied directive, and a wiring changing Filter failed with "All types within a GraphQL schema must have unique names". It also means the two-step SchemaDirectives build is gone, it only existed so wirings saw real input objects in directive arguments.

I first did it like graphql-java with SchemaTransformer, but it keeps the whole path to every element it visits, which gets close to quadratic on a built schema. A reviewer's synthetic schema with 1,500 types went from ~0.1s to ~1.5s.

  • Types without an SDL definition (introspection, types a wiring builds in code) aren't wired, same as graphql-java.
  • The unmapped input field check now runs on the wired input objects, so fields a wiring removes aren't reported.

Release notes material, these only apply when a wiring is registered unless said otherwise:

  • Wirings see the real types everywhere, not just on recursive inputs. Before, every output field pointing at an object, interface, union or enum, and every input field of an enum type, was a GraphQLTypeReference too.
  • The schema is built and validated before the wirings run, so an invalid schema throws InvalidSchemaException from parseSchemaObjects() and a wiring can't make an invalid schema valid anymore.
  • Building is slower with wirings, since the schema is built and validated one more time. With a wiring registered on edealer's schema (~600 types), it went from ~75ms to ~90ms. edealer registers no SchemaDirectiveWiring so it's not affected.
  • When no wiring changes anything, the types in SchemaObjects are the resolved ones. When one does, they point at each other by name until toSchema(), like the output fields already did. This only matters if you read parseSchemaObjects() directly.
  • Without wirings too: input object argument types of directive definitions and applied directives are type references in SchemaObjects until the schema is built.
  • The unmapped input field check reports after the other schema errors now, since it runs at the end.

Tests: the #843 test in SchemaParserTest asserted that the recursive field stays a type reference, now it's the input object. New cases in DirectiveTest for the directive cycle, for wirings changing types that refer to each other (self-referencing output and input fields, through a union, an interface and an enum) and building a type in code, and one in UnmappedInputFieldsTest for a field removed by a wiring. Each of them failed on an earlier version of this branch, and the directive cycle one passes on master.

mvn clean verify passes on JDK 25 and on JDK 11 against graphql-java 25.1. Also checked through the public API with the issue's schema: master fails with the ClassCastException, this branch builds and trees returns 4. A second schema with all the shapes above builds on master and gives an identical printed schema and results here, and it also builds and runs on graphql-java 25.1. The existing directives probe scenario is identical too. And 700 random schemas with wirings that add, remove and retype fields, set descriptions and wrap types in non-null or list give the same printed schema as master, on graphql-java 26.1 and 25.1.

…cycles can be rebuilt

Also skip types without a definition when wiring, and check unmapped input fields on the wired input objects.
…change self-referencing field types

Also drop the input objects parameter of determineType, which can no longer match.
@sonarqubecloud

Copy link
Copy Markdown

@oryan-block
oryan-block merged commit d47bac5 into master Oct 22, 2026
7 checks passed
@oryan-block
oryan-block deleted the bugfix/875 branch October 22, 2026 13:13
oryan-block added a commit that referenced this pull request Oct 22, 2026
Wirings run on the assembled schema since #895, so custom scalars are built once, put in the dictionary and referenced by
name like the other types, and they're wired with the other types. That replaces building them on first use and the
directive cycle handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Directive wirings see a type reference on recursive input fields

1 participant