Skip to content

Resolve nested input objects and input field directives before directive wiring - #843

Merged
oryan-block merged 4 commits into
masterfrom
bugfix/321
Oct 17, 2026
Merged

oryan-block merged 4 commits into
masterfrom
bugfix/321

Conversation

@oryan-block

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

Copy link
Copy Markdown
Collaborator

Fixes #321
Fixes #385
Fixes #731

Checklist

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

Description

If an input object is only referenced from another input object through a list or non-null wrapper, it's still a GraphQLTypeReference while the SchemaDirectiveWirings run, unless the SDL happens to declare it first. Here's PhilippS93's schema from #385 with the nested input wrapped:

input ChangeUserInput {
    aliases: [NameInput!]
    guardian: GuardianInput!
}

input GuardianInput {
    name: NameInput!
}

input NameInput {
    forename: String! @size(min: 3, max: 25)
}

Anything that walks argument types from onField and calls GraphQLTypeUtil.unwrapAll on those fields gets ClassCastException: GraphQLTypeReference cannot be cast to GraphQLUnmodifiedType. That includes the samples' RangeDirective and graphql-java-extended-validation's DirectivesAndTypeWalker (#321). With extended-validation it happens at schema build. Then there's #731: inside onInputObjectField, the legacy env.directive and field.getDirective("range") are null on the @range input fields while env.appliedDirective is set. So a walker that reads the legacy directive off input fields skips them even when the type is resolved.

SchemaParser.determineInputType passed ListType and NonNullType to determineType, which is the output path. That function never builds input objects. It only looks through the ones already built and otherwise returns a type reference. #731 is a separate bug: createInputObject built each input field's legacy directives from definition.directives (the input object's) instead of fieldDefinition.directives. It's a regression from 2a33e6b (#708), which fixed the same thing for arguments only.

Now both branches call determineInputType again, so the wrapped input object gets built right there and wirings see a GraphQLInputObjectType whatever the SDL order. A field only stays a reference where it points back at an input object that's still being built, i.e. a cycle. Input fields now get their legacy directives from their own definition.

Directive definition arguments take the same path, and they needed one more change. To build an input object we need the definitions of the directives applied to it, but schemaDirectives (a lateinit) isn't assigned yet while createDirectives builds the arguments. So on master, directive @meta(info: MetaInput) already failed with UninitializedPropertyAccessException once MetaInput had any applied directive, even @deprecated. The change above would have broken info: MetaInput! and [MetaInput!] the same way. Now createDirectives first builds every directive with input objects only as type references. SchemaDirectives.find then rebuilds each one with the real input objects the first time it's looked up. So input objects built for directive arguments see the real definitions of the directives applied to them. Only a directive applied inside its own argument types still sees its first build there. Most of the createDirectives diff is the existing code moving into createDirective.

SchemaParserTest has a new case that records the unwrapped type of each input field in onInputObjectField. subs: [SubFilter!] and other: OtherFilter! are declared after their parent, and SubFilter.parent points back at it. subs and other are resolved and parent stays a reference. On master it's the other way round. For #731, the existing EmailDirective test now also records the input field's message in onInputObjectField, in both the applied and the legacy view. On master the legacy directive there is null. Two DirectiveTest cases cover input objects used by directive arguments, each in both declaration orders. The first checks that the argument types are GraphQLInputObjectType at wiring time, for @meta(info: ConfigInput!) and for the @option applied inside ConfigInput. It fails on master. The second applies @limit(bound: BoundInput!) inside BoundInput and checks that BoundInput is only wired once. It passes on master too. It's there for the new lookup: with a fresh referencingInputObjects per directive it fails with graphql-java's AssertException. None of the tests call deprecated API, so they build with -Werror.

Outside the tests, PhilippS93's schema went through the real extended-validation 24.0 ValidationSchemaWiring on graphql-java 26.1, using the installed jar. Master throws the ClassCastException above at schema build. This branch builds, and an invalid mutation gets all three nested errors (/changeUser/input/name/forename, /changeUser/input/aliases[1]/forename and /changeUser/input/guardian/name/forename size must be between 3 and 25). A valid one gets none.

I didn't fix cycles. Types are immutable, so one field of a cycle has to stay a reference. graphql-java wires directives on the finished schema, so its wirings never see that reference, but ours do. That means DirectivesAndTypeWalker still throws on a recursive input like input Tree { and: [Tree!] }, same as on master. Fixing it means holding off all directive wiring until the schema is assembled, which also touches objects, interfaces, unions and enums. That should be a separate issue. The samples repo needs a follow-up too before #385 has a working example. Its RangeDirective only goes one level deep. Against current graphql-java it also fails with FloatValue cannot be cast to Double, because it calls getArgumentValue().getValue() on a literal. The An output field must be in context error PhilippS93 got from calling getFieldDataFetcher() in onInputObjectField is how graphql-java's API behaves, so there's nothing to fix here.

#434 (closed) had the same symptom for a top-level non-null argument and was fixed back then. Its test still passes. dnagarajan89's BookInput! case at the end of #385 is the same case. What still broke for them is that the samples' RangeDirective reads the legacy getDirective off input fields, and that's #731.

Behaviour change: input objects behind a list or non-null wrapper (input fields, extend input fields, directive arguments) are now GraphQLInputObjectType at wiring time. They're also built and wired inside their parent instead of later in SDL order, so a wiring that depends on call order could notice. In a cycle, a different field now stays a reference. In input A { b: B! } input B { a: A }, the wiring of B.a used to see A and now sees a type reference, while A.b is now resolved. inputField.getDirective(...), inputField.directives and env.directive in onInputObjectField now return only the field's own directives, so code that read the input object's directives off its fields will get null. Input fields with @deprecated now have it in the legacy list too, as arguments already did. Schemas where a directive argument's input object has directives now build instead of throwing. Because of these changes this should go in a minor release.

🤖 Generated with Claude Code

An input object that another input object only referenced through a
list or non-null wrapper, like `subs: [Sub!]` or `other: Sub2!`, was
still a GraphQLTypeReference when the directive wirings ran, unless
the SDL happened to declare it first. determineInputType handed list
and non-null types to determineType, the output path, which never
builds input objects and falls back to a type reference. Wirings that
walk nested input types, like graphql-java-extended-validation's
DirectivesAndTypeWalker, then failed on GraphQLTypeUtil.unwrapAll with
a ClassCastException or skipped the nested directives. Those branches
now recurse into determineInputType, so the wrapped type gets built on
the spot. A field only stays a type reference where it points back at
an input object that is still being built, i.e. a cycle.

That changes which field of a cycle stays a reference. Types are now
built depth-first through the wrappers, so it's the field pointing
back. Before, it was the list or non-null field, so in
`input A { b: B! } input B { a: A }` the wiring of B.a used to see A
and now sees a type reference, while A.b is now resolved. Unlike
graphql-java, which wires directives on the finished schema, wirings
here still see that reference, so DirectivesAndTypeWalker still fails
on recursive or cyclic input types, as it did before.

Directive definition arguments go through the same path. Building an
input object needs the directive definitions for its own applied
directives, and the definitions weren't assigned yet, so a directive
argument whose input object carried any directive failed with an
UninitializedPropertyAccessException. Nullable arguments already
failed this way, and the change above would have made list and
non-null arguments fail too. There's now a first set of definitions
that only refers to input objects by type reference, and each one is
replaced by the real definition the first time it's looked up. So an
input object built for a directive argument sees the real definitions
of the directives applied to it, and their input object arguments
aren't type references at wiring time. Only a directive applied
within its own argument types sees its first definition there.

Input fields also took their legacy directives from the parent input
object's definition instead of their own (a regression from 2a33e6b,
which fixed this for arguments only). So inputField.getDirective()
and env.directive inside onInputObjectField were null while the
applied directive was set, and every input field carried the input
object's own directives. The wiring of input fields and enum values
now also records INPUT_FIELD_DEFINITION and ENUM_VALUE as their
location instead of FIELD_DEFINITION.

Fixes #321, fixes #385, fixes #731.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oryan-block and others added 3 commits October 17, 2026 09:23
schemaDirectives is now a map by name, so finishing a directive replaces
its entry instead of rebuilding the whole set, and findDirective is a
plain lookup. The comments say in plainer words why directives are built
twice.

The WiringWrapper location change for input fields and enum values is
reverted. directiveLocation isn't read anywhere, so it changed nothing.

Tests: the #731 case is now an onInputObjectField override in the
existing EmailDirective test. The two SchemaParserTest cases are one
test with a list, a non-null and a back-pointing field. The two tests
for input objects used by directive arguments are one, which records
argument types at wiring time, in both declaration orders. Dropped the
query-executing validation test: the type and legacy directive checks
already cover what it walked, and the probe runs the issue's schema
through extended-validation itself. None of the tests call deprecated
API anymore, so the build passes with -Werror.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SchemaDirectives holds the directives by name, the ones not rebuilt with
their input objects yet, and the input objects being built for them.
find() rebuilds a directive the first time it's looked up. This replaces
the finishDirective function that createDirectives had to set and reset,
and a new object per parse means nothing carries over between parses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@oryan-block
oryan-block merged commit 47cf27c into master Oct 17, 2026
7 checks passed
@oryan-block
oryan-block deleted the bugfix/321 branch October 17, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant