Skip to content

Let input types share a dictionary class - #851

Merged
oryan-block merged 5 commits into
masterfrom
bugfix/465
Oct 17, 2026
Merged

oryan-block merged 5 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

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 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 now 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. I didn't deprecate getDictionary() either, since graphql-spring-boot has no other way to read the entries yet.

The new SchemaParserDictionaryTest builds real schemas. The first test runs a query with the exact code from the issue (two top-level inputs mapped to ObjectNode), plus a nested input and a list of nested inputs under them that are also mapped to ObjectNode. The second maps two directive input types to Map through the graphql-spring-boot path, SchemaParserDictionary().add(...) and getDictionary(), and checks the applied directive values. The third queries an input type and an object type with an interface that share a class, with __typename checked. The input is only reachable through a directive, so its entry is actually read. 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 master all four fail: three 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, also with -Dgraphql-java.version=25.1.

Behaviour change: several input object types can now share a dictionary class. Other types sharing a class are still rejected, but by build() 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 2 commits October 12, 2026 15:23
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>
oryan-block and others added 3 commits October 17, 2026 11:49
@sonarqubecloud

Copy link
Copy Markdown

@oryan-block
oryan-block merged commit a151a5e into master Oct 17, 2026
7 checks passed
@oryan-block
oryan-block deleted the bugfix/465 branch October 17, 2026 18:39
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