Skip to content

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

Open
oryan-block wants to merge 1 commit into
masterfrom
bugfix/321
Open

oryan-block wants to merge 1 commit into
masterfrom
bugfix/321

Conversation

@oryan-block

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. DirectiveWiringHelper also passes INPUT_FIELD_DEFINITION and ENUM_VALUE instead of FIELD_DEFINITION for input fields and enum values. WiringWrapper.directiveLocation isn't read anywhere though, so that part changes nothing you can see.

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 definitions that refer to input objects only by type reference. findDirective then swaps each one for the real definition 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 definition there. Most of the createDirectives diff is the existing code moving into createDirective.

SchemaParserTest has two new cases that record the unwrapped type of each input field in onInputObjectField. The first covers [SubFilter!], OtherFilter! and an extend input field, all declared after the parent. The second has a self-reference and a two-type cycle. It checks which fields stay references at wiring time and that they're all resolved in the final schema. DirectiveTest checks both the legacy and the applied view of #731's schema, at wiring time and in the final schema. It also runs an onField walker like the samples' RangeDirective over the schema above plus dnagarajan89's addBook(bookInput: BookInput!), with one invalid and one valid query. The other new tests cover input objects used by directive arguments. On master both SchemaParserTest cases fail, the #731 test gets value=[null, null], and the walker test and the first directive-argument test throw the ClassCastException above. Outside the tests, PhilippS93's schema was also run through the real extended-validation 24.0 ValidationSchemaWiring. Master throws at schema build, and this branch reports all three nested size errors.

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. It's in the walker test anyway because 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>
@sonarqubecloud

Copy link
Copy Markdown

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