Repository navigation
Pass coerced custom scalar values through to input objects and Map arguments - #856
Open
oryan-block wants to merge 2 commits into
Open
oryan-block wants to merge 2 commits into
oryan-block wants to merge 2 commits into
Conversation
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>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes #436
Fixes #424
Checklist
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
LocalDateon an input class with no jsr310 module registered:Uploads in input objects (#466, and #198 before it) fail the same way, because Jackson tries to serialize the
Part:Map arguments are worse, because nothing fails. In #424 a scalar value inside a
Map<String, Any?>argument arrives as its serialized form, so a bean-likeMoneyscalar comes through as{amount=5, currency=CAD}instead of aMoney.graphql-java has already coerced the argument by the time the resolver runs, so the input object is a
Mapholding the scalar instances.MethodFieldResolverthen ran that throughObjectMapper#convertValueto get the parameter type.convertValueserializes 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
ArgumentConverteris built once per argument, andMethodFieldResolvercallsArgumentConverter#convertwhere it calledconvertValue. Jackson stays in the path, since that's why #471 was closed.For typed parameters (input classes, Kotlin data classes, lists of inputs,
Optional,ObjectNode, top-level scalars that need converting),convertValuestill 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 aTokenBuffer. 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 withwriteEmbeddedObject. The buffer is then read withObjectMapper#readerFor(type)and aDeserializationProblemHandler. ItshandleMissingInstantiatorreturns the embedded instance for an interface or abstract property such asPart, andhandleWeirdNativeValueconverts the value on its own when the property has a different class. Classes that fail to convert are cached per argument. Other values are checked every time, because whether a value converts can depend on what's in it. 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.Untyped parameters (
Object, or aMap,Collection, array orOptionalof it, soMap<String, Any?>, a rawLinkedHashMap,List<Map<String, Any?>>,Optional<Map<String, Any?>>) skipconvertValue. They're read from the buffer withUntypedObjectDeserializer, and every leaf other than aString,Number,Booleanor 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 andStringproperties in inputs that master rejects with a clear error. I also didn't add aSchemaParserOptionsflag to keep the old Map behaviour. Declaring a typed parameter already does that, and a flag would be new API for a behaviour #424 explicitly asks for.Left out:
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.MaporObjectNodeinput still fails the schema build with "Expected type 'LocalDate' to be a GraphQLInputType". That's Custom scalars are not properly scanned when only used in input types #355, fixed by Register input field types that have no Java property #842. The Map tests reference the scalars from another input for now, and a Map-only variant can be added once Register input field types that have no Java property #842 merges.The new
CustomScalarInputTesthas 8 tests, all running queries throughGraphQL#executeand checking the data or errors. They cover aLocalDate(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, in anOptionaland in anObjectNode, plus the opaque value on its own. Uploads go through the existingUploadscalar into a Kotlin data class (Part?,List<Part>) and a Java-style POJO, and the test checks the file contents. The Map test coversMap<String, Any?>, a rawLinkedHashMap,List<Map>andOptional<Map>with aUUID, aLocalDate, a bean-likeMoneyand a nested input. Guards check that values theObjectMappercan convert still go through it:Moneyinto aMoneyDto, the opaque class into anOpaqueDto, a@JsonDeserializecustom deserializer with and without aLocalDateforcing the retry, anObjectNodewith a bean scalar, and a top-level DTO argument. Another uses a mapper configured with customLocalDateandPart(de)serializers andNON_NULL. The rest cover the error naming the missingnamewith the jsr310 error suppressed, a class whose values only sometimes convert, andStringtargets gettingtoString(). All 8 fail on master (603d40e). I also checked that droppinghandleWeirdNativeValueorhandleMissingInstantiator, or embedding by serializer class in the typed retry, makes the tests fail.mvn clean verifypasses with 266 tests.Behaviour change: a top-level untyped parameter now gets the values graphql-java coerced. Strings, numbers, booleans and enums come through as before (enums as names). Every other scalar value arrives as its instance. So a bean-like scalar is no longer a nested
LinkedHashMap, aUUIDis aUUIDinstead of aString, ajava.util.Dateis aDateinstead of aLong,URL,LocaleandCharacterare instances instead of strings, andjava.timevalues are instances with or without jsr310 (previously an error, or arrays and epoch numbers with jsr310). Resolvers that cast these toString,MaporLongwill throwClassCastException. To keep the old form, declare a typed parameter such as an input class orObjectNode. Inclusion settings likeNON_NULLstill apply to the Map. The rest only affects inputs that fail on master: anObjectNodeparameter gets aPOJONodefor a value that can't be converted, and aStringproperty receiving one gets itstoString(), which isClass@hashfor a class that doesn't override it. When both paths fail, the error message differs from master's, which is attached as suppressed. Typed inputs that would fail on master pay one failedconvertValueper call before the retry. I think this wants a minor release with the Map rule in the release notes.🤖 Generated with Claude Code