Skip to content

Let input types share a dictionary class - #851

Open
oryan-block wants to merge 4 commits into
masterfrom
bugfix/465
Open

oryan-block wants to merge 4 commits into
masterfrom
bugfix/465

Conversation

@oryan-block

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

Copy link
Copy Markdown
Collaborator

Fixes #465

Checklist

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

Description

Stacked on #842, merge that first.

Mapping two input types to the same class in the dictionary fails before the schema is even built. The example from #465:

schema.dictionary("EntityCreateInput", ObjectNode::class.java)
schema.dictionary("EntityUpdateInput", ObjectNode::class.java)

java.lang.IllegalArgumentException: value already present: class com.fasterxml.jackson.databind.node.ObjectNode

The same happens with two inputs mapped to Map, or an input type and an object type mapped to one class.

SchemaParserDictionary stored its entries in a BiMap, and BiMap#put rejects a value that's already there. That's the right rule for output types. Object, interface, union and enum classes go into the type to class bimap that interface and union type resolution use to find the type of an object, so one class can only stand for one type. Input types never go into that bimap, though, so there's no reason for them to be held to it.

Now SchemaParserDictionary keeps its entries in an insertion-ordered linkedMapOf, and add() accepts several names for one class. SchemaClassScanner takes a plain Map<String, Class<*>>. The one-class-per-type rule moved into SchemaClassScanner's init, and it now skips input object types. If dictionary entries that aren't input types share a class, the build fails with a SchemaClassScannerError that names the class and those types, for example "Class in supplied dictionary 'com.example.Foo' specified type names 'A', 'B', but only input object types can share a class!". That check runs after the existing "type definition not found" check, so a missing name is still reported first.

getDictionary() keeps its signature and still returns a BiMap<String, Class<*>>, because graphql-spring-boot calls builder.dictionary(dictionary.getDictionary()). It now returns a copy made by the new BiMap#unmodifiableCopyOf, which keeps every key and maps a shared class back to its first key in the inverse. So the forward map has every entry, and inverse() and values() can be smaller than the map. The Javadoc says so.

I didn't exempt scalars too. A dictionary class on a scalar is never used, since TypeClassMatcher turns it into a ScalarMatch and drops the class, so allowing scalars to share one wouldn't enable anything. Enums can't share a class because they go into the bimap. Directive input objects still need a dictionary entry, as before. Whether they should is a separate question, closer to #843. I didn't deprecate getDictionary() either, since graphql-spring-boot has no other way to read the entries yet. #842 says it'd close #465 by hand with a comment. That's no longer needed, so that sentence should come out of #842's description.

The new SchemaParserDictionaryTest builds real schemas and the positive tests run queries against them. It covers the exact code from the issue (two top-level inputs mapped to ObjectNode), a nested input and a list of nested inputs under an ObjectNode input, both mapped to ObjectNode, two directive input types mapped to Map, the graphql-spring-boot path through SchemaParserDictionary().add(...) and getDictionary(), and an input type and an object type with an interface sharing a class, where the input is only reachable through a directive so its entry is actually read, with __typename checked. A negative test checks that two object types sharing a class fail with the exact message, and that an input type mapped to the same class isn't listed in it. On the base branch all six fail: five with "value already present" and the negative one with an IllegalArgumentException instead of a SchemaClassScannerError. BiMapTest has two new tests for unmodifiableCopyOf, covering key order, values, the first-key-wins inverse and that the copy can't be changed. clean verify passes.

Behaviour change: several input object types can now share a dictionary class. Other types sharing a class are still rejected, but by build() or scan() with a SchemaClassScannerError instead of by dictionary() or add() with an IllegalArgumentException. Dictionary entries the scan never reads now log the existing "Dictionary mapping was provided but never used" WARN, which includes the issue's own code, since those top-level inputs are already bound through resolver parameters. getDictionary() returns a snapshot instead of a live view. Entries keep insertion order instead of HashMap order, so if several dictionary names are missing from the schema, the first one added is reported. Probably worth a minor version and a line in the release notes.

🤖 Generated with Claude Code

oryan-block and others added 4 commits October 9, 2026 21:34
When the scanner couldn't find a Java property for an input field, e.g.
because the input is bound to a Map or ObjectNode, the getter name
doesn't match the schema field (getFoo_Back for foo_back) or the field
is private, it only logged "Cannot find definition for field ..." and
never registered the field's type. Nested input objects and custom
scalars only reachable that way were left out of the scan, and the
parser then failed with "Expected type 'X' to be a GraphQLInputType,
but it wasn't!", which pointed users at the wrong problem.

The scanner now still registers what such a field needs. A nested input
object is registered without a class and its fields are scanned the
same way; a class found for it later is set and scanned as usual, so it
doesn't clash. A custom scalar is registered like any other. An enum
can't be built without its Java class, so if none is found for it
anywhere, the scan fails with a message naming the enum, the input type,
the field and the class, and how to fix it. The garbled warning is
replaced by one that's only logged when an input class (not a Map or
JsonNode) lacks a matching property.

Input fields of a Map or JsonNode are no longer matched against the
methods and fields it inherits from the JDK or Jackson, so fields named
like values, elements or fields work with ObjectNode. Getters declared
by a Map subclass are still used. A class taken from the dictionary for
an input field is matched against the field's unwrapped type, so a list
field whose element type is in the dictionary no longer fails with "Java
class is not a List".

A misspelled property on an input class whose nested input type is only
reachable through it used to fail the build with the misleading error
above. It now builds with the warning and, like any other misspelled
property, fails when a client sends the field, or the value is dropped
if the ObjectMapper ignores unknown properties. There's still no
build-time check that input class properties match the schema (#422).

The parser's "Expected type ... to be a GraphQLInputType" error is
replaced by one that tells apart a type that isn't defined, a type of
the wrong kind (an output type used as an input or vice versa) and a
type that was defined but never reached while scanning, with a hint for
reaching it that depends on the kind of type. It names the field or
argument using the type and its source location. Schemas passed through
schemaString() are still parsed as one document without a source name,
so their line numbers count from the start of all strings (#392).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SchemaParserDictionary stored its entries in a BiMap, so mapping two type
names to one class, e.g. two input types to ObjectNode or Map, threw
"value already present" as soon as the second was added. Input types
never go into the scanner's type => class bimap, so nothing after that
needs their classes to be unique. Directive input types still need a
dictionary entry, and entries for nested inputs under a Map or ObjectNode
are a common workaround, so both hit this.

The dictionary is now a plain map. Every other kind of type still needs
a class of its own, but the scanner checks that when the schema is built
and names the class and the types sharing it. getDictionary() keeps its
signature, since graphql-spring-boot passes its result to the builder.
If several types share a class, the inverse of the returned copy only
maps it back to the first of them.

Names other than input types that share a class now fail in build() with
a SchemaClassScannerError, instead of in dictionary() with an
IllegalArgumentException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test for an input type and an object type sharing a class bound the
input type through a resolver parameter, so its dictionary entry was never
read. It now only uses the input type in a directive, which needs the entry.

The test for object types sharing a class also maps an input type to that
class, to check it is left out of the error.

The scanner comment no longer says every other kind of type needs a class
of its own. Scalars don't, but they keep the old rule.

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

Development

Successfully merging this pull request may close these issues.

Unable to set a custom type

1 participant