Repository navigation
Pass coerced custom scalar values through to input objects and Map arguments - #856
Merged
Merged
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>
2 tasks done
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>
|
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:#198 is the same problem with
ExtendedScalars.Date, which is also aLocalDate. Uploads in input objects fail too, and #466 reports:Map arguments are worse, because nothing fails. In #424 custom scalars inside a raw
LinkedHashMapargument arrive in their serialized form instead of the coerced values. With a bean-likeMoneyscalar, for example (that one is from this PR's tests, not the issue), the resolver gets{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. #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),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. 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
ObjectNodeparameter gets aPOJONodefor a value that can't be converted, and aStringproperty that receives one gets itstoString(), which isClass@hashfor a class that doesn't override it. Those inputs also cost more on every call: the failedconvertValue, then oneconvertValueper 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 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 the parameter asObjectNodealready gets the serialized form, 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.The new
CustomScalarInputTesthas 4 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 and in anOptional, plus theLocalDatein anObjectNodeand 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. Its schema uses those scalars only in Map inputs, which builds since #842. Guards check that values theObjectMappercan convert still go through it:Moneyinto aMoneyDto, the opaque class into anOpaqueDto, a@JsonDeserializecustom deserializer that reads the serialized form of aBoxscalar, with and without aLocalDateforcing the retry, anObjectNodewith a bean scalar, and a top-level DTO argument. The deserializer case also sends the same input before and after aBoxthat can't be converted, and checks both get the same result. A mapper configured with customLocalDateandPart(de)serializers andNON_NULLis checked with an input class and with a Map. The rest cover the error naming the missingnamewith the jsr310 error suppressed andStringtargets gettingtoString(). All 4 fail with master'sconvertValuecall in place of the converter. I also checked that droppinghandleWeirdNativeValueorhandleMissingInstantiator, embedding every value in the typed retry, remembering non-converting classes across calls, or running untyped parameters throughconvertValuemakes the tests fail.mvn clean verifypasses with 292 tests, on graphql-java 26.1 and 25.1.Behaviour change: a resolver parameter declared as
Object, or as aMap,Collection, array orOptionalof it (e.g.Map<String, Any?>or a rawLinkedHashMap), 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 nestedMap, and aUUIDis aUUIDinstead of aString. Resolvers that cast these values toStringorMapwill throwClassCastException. To get the serialized form as before, declare the parameter asObjectNodeinstead. Inclusion settings likeNON_NULLstill apply. I think this wants a minor release with this paragraph in the release notes.🤖 Generated with Claude Code