Skip to content

Pass coerced custom scalar values through to input objects and Map arguments - #856

Merged
oryan-block merged 7 commits into
masterfrom
bugfix/436
Oct 17, 2026
Merged

oryan-block merged 7 commits into
masterfrom
bugfix/436

Conversation

@oryan-block

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

Copy link
Copy Markdown
Collaborator

Fixes #436
Fixes #424

Checklist

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

Description

A custom scalar inside an input object fails to reach the resolver if Jackson can't round-trip its value. In #436 that's a LocalDate on an input class with no jsr310 module registered:

Java 8 date/time type java.time.LocalDate not supported by default ... (through reference chain: java.util.LinkedHashMap["date"])

#198 is the same problem with ExtendedScalars.Date, which is also a LocalDate. Uploads in input objects fail too, and #466 reports:

Cannot construct instance of javax.servlet.http.Part (no Creators, like default construct, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information

Map arguments are worse, because nothing fails. In #424 custom scalars inside a raw LinkedHashMap argument arrive in their serialized form instead of the coerced values. With a bean-like Money scalar, for example (that one is from this PR's tests, not the issue), the resolver gets {amount=5, currency=CAD} instead of a Money.

graphql-java has already coerced the argument by the time the resolver runs, so the input object is a Map holding the scalar instances. MethodFieldResolver then ran that through ObjectMapper#convertValue to get the parameter type. convertValue serializes the whole value and reads it back, so every scalar instance has to survive a round trip through Jackson's serializers and deserializers.

The new internal ArgumentConverter is built once per argument, and MethodFieldResolver calls ArgumentConverter#convert where it called convertValue. Jackson stays in the path. #471 dropped it, but its review asked not to, since that would be too big a breaking change, and it was closed because #466 had a workaround.

For typed parameters (input classes, Kotlin data classes, lists of inputs, Optional, ObjectNode, top-level scalars that need converting), convertValue still runs first. So anything that converts today gives the same result, including custom deserializers, objectMapperConfigurer, naming strategies and the serializer workaround from #466. Only if it throws, the value is rewritten into a TokenBuffer. Maps, lists, strings, numbers, booleans and enums become tokens as before. A leaf whose value can't be converted to its own class is written with writeEmbeddedObject. The buffer is then read with ObjectMapper#readerFor(type) and a DeserializationProblemHandler. Its handleMissingInstantiator returns the embedded instance for an interface or abstract property such as Part, and handleWeirdNativeValue converts the value on its own when the property has a different class. Within one call, a class whose value fails to convert isn't tried again for the rest of the input, e.g. a list of uploads. Nothing is remembered across calls, so the same input always gives the same result. If the retry fails too, its error is thrown with the original one attached as suppressed, since the retry's error names the real problem. If no value qualifies for embedding, the original error is rethrown unchanged.

This only changes typed inputs that fail on master. An ObjectNode parameter gets a POJONode for a value that can't be converted, and a String property that receives one gets its toString(), which is Class@hash for a class that doesn't override it. Those inputs also cost more on every call: the failed convertValue, then one convertValue per custom scalar value in the input to see whether it converts on its own, though a class that fails is only tried once.

Untyped parameters (Object, or a Map, Collection, array or Optional of it, so Map<String, Any?>, a raw LinkedHashMap, List<Map<String, Any?>>, Optional<Map<String, Any?>>) skip convertValue. They're read from the buffer with UntypedObjectDeserializer, and every leaf other than a String, Number, Boolean or enum is embedded. So the resolver gets exactly what graphql-java coerced, whatever serializers or modules the mapper has. That's what #424 asks for.

I didn't add a second pass for a scalar whose class Jackson can round-trip but that's assigned to an interface or abstract property. That's Jackson's ordinary abstract type mapping error, master reports it clearly, and @JsonDeserialize(as = ...) fixes it. Embedding those values would hand wrong values to custom deserializers and String properties in inputs that master rejects with a clear error. I also didn't add a SchemaParserOptions flag to keep the old Map behaviour. Declaring the parameter as ObjectNode already gets the serialized form, and a flag would be new API for a behaviour #424 explicitly asks for.

Left out:

  • Untyped properties nested inside a typed input (e.g. var extra: Map<String, Any?> on an input class) still get serialized scalar values when conversion succeeds, as on master. Only top-level untyped parameters take the embedded path.

The new CustomScalarInputTest has 4 tests, all running queries through GraphQL#execute and checking the data or errors. They cover a LocalDate (no jsr310) and an opaque class that can't be rebuilt from its serialized properties in an input object, sent as a literal and as a variable, in a list and in an Optional, plus the LocalDate in an ObjectNode and the opaque value on its own. Uploads go through the existing Upload scalar into a Kotlin data class (Part?, List<Part>) and a Java-style POJO, and the test checks the file contents. The Map test covers Map<String, Any?>, a raw LinkedHashMap, List<Map> and Optional<Map> with a UUID, a LocalDate, a bean-like Money and a nested input. Its schema uses those scalars only in Map inputs, which builds since #842. Guards check that values the ObjectMapper can convert still go through it: Money into a MoneyDto, the opaque class into an OpaqueDto, a @JsonDeserialize custom deserializer that reads the serialized form of a Box scalar, with and without a LocalDate forcing the retry, an ObjectNode with a bean scalar, and a top-level DTO argument. The deserializer case also sends the same input before and after a Box that can't be converted, and checks both get the same result. A mapper configured with custom LocalDate and Part (de)serializers and NON_NULL is checked with an input class and with a Map. The rest cover the error naming the missing name with the jsr310 error suppressed and String targets getting toString(). All 4 fail with master's convertValue call in place of the converter. I also checked that dropping handleWeirdNativeValue or handleMissingInstantiator, embedding every value in the typed retry, remembering non-converting classes across calls, or running untyped parameters through convertValue makes the tests fail. mvn clean verify passes with 292 tests, on graphql-java 26.1 and 25.1.

Behaviour change: a resolver parameter declared as Object, or as a Map, Collection, array or Optional of it (e.g. Map<String, Any?> or a raw LinkedHashMap), now gets custom scalar values as the instances graphql-java coerced, not their serialized form. Strings, numbers, booleans and enums come through as before. So a bean-like scalar is no longer a nested Map, and a UUID is a UUID instead of a String. Resolvers that cast these values to String or Map will throw ClassCastException. To get the serialized form as before, declare the parameter as ObjectNode instead. Inclusion settings like NON_NULL still apply. I think this wants a minor release with this paragraph in the release notes.

🤖 Generated with Claude Code

oryan-block and others added 2 commits October 12, 2026 15:47
Arguments that need converting go through ObjectMapper.convertValue,
which serializes the values graphql-java already coerced and
deserializes them again. Custom scalar values Jackson can't do that
for, such as an upload or a LocalDate without the jsr310 module, failed
inside input objects (#436, #466), and a Map argument got bean-like
scalar values as a nested Map (#424).

Arguments are still converted by the ObjectMapper first, so anything
that converts today gets the same result. If that fails, the values
that don't convert to their own class are written as embedded objects,
which Jackson's deserializers return as they are, with a problem
handler for interfaces such as Part. If that fails too, its error is
thrown, with the original one suppressed.

Nothing that reads an untyped value, such as those of a
Map<String, Any?>, depends on the serialized form, so there the scalar
values are kept unless the mapper has a serializer for them, e.g. UUID,
or java.time with the jsr310 module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Map, Object or other untyped argument kept a scalar value only if the
ObjectMapper had no serializer of its own for it, so a UUID still
arrived as a String, a Date as a Long, and a LocalDate as an array once
the jsr310 module was registered. Nothing that reads an untyped value
depends on its serialized form, so these now get every coerced value
other than strings, numbers, booleans and enums, whatever modules the
mapper has (#424).

Only the classes whose values failed to convert to their own class are
remembered now. Whether a value converts can depend on what it holds,
so a value that converted no longer stops a later one of the same class
from being embedded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oryan-block and others added 5 commits October 17, 2026 11:50
An untyped value is now always read through the buffer, which gives the same result as convertValue when there's nothing to embed.

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

Since #842, a scalar used only in a Map input is registered, so the Map test no longer needs another input to reach its scalars. The typed cases share one schema and resolver, and the UUID scalar comes from the end-to-end fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The classes were remembered per argument, so after one request with a value that couldn't be converted, later values of the same class were embedded even when they converted. A deserializer that reads the serialized form then got nothing, and the same request gave a different result. The memo now lasts one convert call. The guard test checks the same request before and after such a value, with a tree-reading deserializer in place of the GeoPoint one.

The writer is built once and the reader on first use, instead of on every read.

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

Copy link
Copy Markdown

@oryan-block
oryan-block merged commit 0140de6 into master Oct 17, 2026
7 checks passed
@oryan-block
oryan-block deleted the bugfix/436 branch October 17, 2026 21:21
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