Skip to content

Commit 2095a2f

Browse files
oryan-blockclaude
andcommitted
Only remember non-converting classes within one conversion
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>
1 parent 19a2f21 commit 2095a2f

2 files changed

Lines changed: 52 additions & 49 deletions

File tree

‎src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import com.fasterxml.jackson.databind.jsontype.TypeSerializer
1616
import com.fasterxml.jackson.databind.util.TokenBuffer
1717
import java.io.IOException
1818
import java.lang.reflect.Type
19-
import java.util.concurrent.ConcurrentHashMap
2019

2120
/**
2221
* Converts an argument value that graphql-java already coerced to the type of the resolver method's parameter.
@@ -33,7 +32,6 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {
3332

3433
private val javaType = mapper.typeFactory.constructType(type)
3534
private val isUntyped = javaType.isUntyped()
36-
private val nonConvertingClasses: MutableSet<Class<*>> = ConcurrentHashMap.newKeySet()
3735

3836
fun convert(value: Any?): Any? {
3937
if (value == null) {
@@ -49,7 +47,19 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {
4947
return try {
5048
mapper.convertValue<Any?>(value, javaType)
5149
} catch (e: IllegalArgumentException) {
52-
val embedded = embed(value) { !convertsToOwnClass(it) } ?: throw e
50+
// a class whose value failed to convert isn't tried again in this value, since that can be expensive, e.g. for
51+
// a list of uploads. It isn't remembered across calls, so the same input always gives the same result
52+
val nonConvertingClasses = HashSet<Class<*>>()
53+
val embedded = embed(value) { scalar ->
54+
when {
55+
scalar.javaClass in nonConvertingClasses -> true
56+
convertsToOwnClass(scalar) -> false
57+
else -> {
58+
nonConvertingClasses += scalar.javaClass
59+
true
60+
}
61+
}
62+
} ?: throw e
5363
try {
5464
read(embedded)
5565
} catch (retryError: IllegalArgumentException) {
@@ -75,30 +85,19 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {
7585
return copy(value).takeIf { embedsAny }
7686
}
7787

78-
// failing to convert can be expensive, e.g. an upload, so those classes are remembered. Whether a value converts
79-
// can depend on what it holds, so values of the other classes are checked every time
80-
private fun convertsToOwnClass(value: Any): Boolean {
81-
if (value.javaClass in nonConvertingClasses) {
82-
return false
83-
}
84-
return try {
85-
mapper.convertValue(value, value.javaClass)
86-
true
87-
} catch (e: IllegalArgumentException) {
88-
nonConvertingClasses += value.javaClass
89-
false
90-
}
88+
private fun convertsToOwnClass(value: Any): Boolean = try {
89+
mapper.convertValue(value, value.javaClass)
90+
true
91+
} catch (e: IllegalArgumentException) {
92+
false
9193
}
9294

9395
// ObjectMapper.convertValue, but read with the handler for embedded values
9496
private fun read(value: Any): Any? {
9597
val buffer = TokenBuffer(mapper, false).forceUseOfBigDecimal(mapper.isEnabled(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS))
9698
return try {
97-
mapper.writer().without(SerializationFeature.WRAP_ROOT_VALUE).writeValue(buffer, value)
98-
mapper.readerFor(javaType)
99-
.without(DeserializationFeature.UNWRAP_ROOT_VALUE)
100-
.withHandler(embeddedValueHandler)
101-
.readValue<Any?>(buffer.asParser())
99+
writer.writeValue(buffer, value)
100+
reader.readValue<Any?>(buffer.asParser())
102101
} catch (e: IOException) {
103102
throw IllegalArgumentException(e.message, e)
104103
}
@@ -122,6 +121,16 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {
122121
mapper.convertValue(valueToConvert, targetType)
123122
}
124123

124+
private val writer = mapper.writer().without(SerializationFeature.WRAP_ROOT_VALUE)
125+
126+
// built on first use, since readerFor looks up the deserializer right away, at schema build, and most arguments
127+
// never need the reader
128+
private val reader by lazy {
129+
mapper.readerFor(javaType)
130+
.without(DeserializationFeature.UNWRAP_ROOT_VALUE)
131+
.withHandler(embeddedValueHandler)
132+
}
133+
125134
// written to the buffer as an embedded object, which it keeps as it is
126135
private class EmbeddedValue(private val value: Any) : JsonSerializable {
127136
override fun serialize(gen: JsonGenerator, serializers: SerializerProvider) = gen.writeEmbeddedObject(value)

‎src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt‎

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@ class CustomScalarInputTest {
4646
opaqueOnly: event(input: { opaque: "alone" })
4747
variable: event(input: ${'$'}event)
4848
file: event(input: { file: ${'$'}file })
49-
boxOfText: event(input: { box: "text", date: "2020-01-02" })
50-
boxOfDate: event(input: { box: "date:2021-03-04" })
5149
events(inputs: [{ date: "2020-01-02" }, { opaque: "second" }])
5250
optionalEvent(input: { date: "2020-01-02", opaque: "optional" })
5351
message(input: { name: "greeting", file: ${'$'}file, files: ${'$'}files })
@@ -58,14 +56,11 @@ class CustomScalarInputTest {
5856
"""
5957
}
6058

61-
// a box of text converts, so only the date is embedded, but the box of a date after it has to be embedded too
6259
assertEquals(data - "opaqueLabel", mapOf(
6360
"literal" to "2020-01-02 literal",
6461
"opaqueOnly" to "alone",
6562
"variable" to "2021-03-04 variable",
6663
"file" to "a.txt: Hello",
67-
"boxOfText" to "2020-01-02 text",
68-
"boxOfDate" to "2021-03-04",
6964
"events" to listOf("2020-01-02", "second"),
7065
"optionalEvent" to "2020-01-02 optional",
7166
"message" to "greeting: Hello big world",
@@ -149,8 +144,10 @@ class CustomScalarInputTest {
149144
val data = assertNoGraphQlErrors(graphQL(inputSchema)) {
150145
"""
151146
query {
152-
payment(input: { price: "5", reference: "ref", location: "1.5,2.5" })
153-
withDate: payment(input: { price: "5", reference: "ref", location: "1.5,2.5", date: "2020-01-02" })
147+
payment(input: { price: "5", reference: "ref", box: "text" })
148+
withDate: payment(input: { price: "5", reference: "ref", box: "text", date: "2020-01-02" })
149+
boxOfDate: payment(input: { box: "date:2021-03-04" })
150+
afterBoxOfDate: payment(input: { price: "5", reference: "ref", box: "text", date: "2020-01-02" })
154151
priceNode(input: { price: "5" })
155152
price(price: "5")
156153
}
@@ -164,9 +161,13 @@ class CustomScalarInputTest {
164161
"""
165162
}
166163

164+
// a box of a date can't be converted, so the deserializer gets it embedded and has no serialized form to read.
165+
// That doesn't change how a later box of text is read
167166
assertEquals(data, mapOf(
168-
"payment" to "MoneyDto(amount=5, currency=CAD) OpaqueDto(value=ref) GeoPoint(lat=1.5, lng=2.5) null",
169-
"withDate" to "MoneyDto(amount=5, currency=CAD) OpaqueDto(value=ref) GeoPoint(lat=1.5, lng=2.5) 2020-01-02",
167+
"payment" to "MoneyDto(amount=5, currency=CAD) OpaqueDto(value=ref) text",
168+
"withDate" to "MoneyDto(amount=5, currency=CAD) OpaqueDto(value=ref) 2020-01-02 text",
169+
"boxOfDate" to "<missing>",
170+
"afterBoxOfDate" to "MoneyDto(amount=5, currency=CAD) OpaqueDto(value=ref) 2020-01-02 text",
170171
"priceNode" to """{"price":{"amount":5,"currency":"CAD"}}""",
171172
"price" to "MoneyDto(amount=5, currency=CAD)"
172173
))
@@ -190,7 +191,7 @@ class CustomScalarInputTest {
190191
private fun graphQL(schema: String, options: SchemaParserOptions = SchemaParserOptions.defaultOptions()): GraphQL {
191192
val graphQLSchema = SchemaParser.newParser()
192193
.schemaString(schema)
193-
.scalars(localDateScalar, opaqueScalar, boxScalar, moneyScalar, geoPointScalar, customScalarUUID, uploadScalar)
194+
.scalars(localDateScalar, opaqueScalar, boxScalar, moneyScalar, customScalarUUID, uploadScalar)
194195
.resolvers(resolver)
195196
.options(options)
196197
.build()
@@ -203,7 +204,6 @@ class CustomScalarInputTest {
203204
scalar Opaque
204205
scalar Box
205206
scalar Money
206-
scalar GeoPoint
207207
scalar Upload
208208
209209
type Query {
@@ -221,7 +221,6 @@ class CustomScalarInputTest {
221221
input EventInput {
222222
date: LocalDate
223223
opaque: Opaque
224-
box: Box
225224
file: Upload
226225
}
227226
@@ -245,8 +244,8 @@ class CustomScalarInputTest {
245244
input PaymentInput {
246245
price: Money
247246
reference: Opaque
248-
location: GeoPoint
249247
date: LocalDate
248+
box: Box
250249
}
251250
"""
252251

@@ -265,15 +264,15 @@ class CustomScalarInputTest {
265264
val received = mutableMapOf<String, Any>()
266265

267266
fun event(input: EventInput): String =
268-
listOfNotNull(input.date, input.opaque?.value, input.box?.content, input.file?.let { "${it.name}: ${it.content()}" }).joinToString(" ")
267+
listOfNotNull(input.date, input.opaque?.value, input.file?.let { "${it.name}: ${it.content()}" }).joinToString(" ")
269268

270269
fun events(inputs: List<EventInput>): List<String> = inputs.map { event(it) }
271270
fun optionalEvent(input: Optional<EventInput>): String = input.map { event(it) }.orElse("empty")
272271
fun message(input: MessageInput): String = "${input.name}: ${(listOfNotNull(input.file) + input.files).joinToString(" ") { it.content() }}"
273272
fun label(input: LabelInput): String = "${input.date} ${input.opaque}"
274273
fun dateNode(input: ObjectNode): String = (input["date"] as POJONode).pojo.let { "${it.javaClass.simpleName} $it" }
275274
fun priceNode(input: ObjectNode): String = input.toString()
276-
fun payment(input: PaymentInput): String = "${input.price} ${input.reference} ${input.location} ${input.date}"
275+
fun payment(input: PaymentInput): String = listOfNotNull(input.price, input.reference, input.date, input.box).joinToString(" ")
277276
fun price(price: MoneyDto): String = price.toString()
278277

279278
fun map(input: Map<String, Any?>) = receive("map", input)
@@ -294,7 +293,6 @@ class CustomScalarInputTest {
294293
class EventInput {
295294
var date: LocalDate? = null
296295
var opaque: Opaque? = null
297-
var box: Box? = null
298296
var file: Part? = null
299297
}
300298

@@ -305,10 +303,10 @@ class CustomScalarInputTest {
305303
class PaymentInput {
306304
var price: MoneyDto? = null
307305
var reference: OpaqueDto? = null
308-
309-
@field:JsonDeserialize(using = GeoPointDeserializer::class)
310-
var location: GeoPoint? = null
311306
var date: LocalDate? = null
307+
308+
@field:JsonDeserialize(using = BoxContentDeserializer::class)
309+
var box: String? = null
312310
}
313311

314312
// serialized as {"value": ...}, which Jackson can't create it from, like the coerced values of many custom scalars
@@ -322,16 +320,13 @@ class CustomScalarInputTest {
322320

323321
data class MoneyDto(val amount: Int, val currency: String)
324322

323+
// only converts if its content does
325324
data class Box(val content: Any)
326325

327-
data class GeoPoint(val lat: Double, val lng: Double)
328-
329-
// reads the serialized form, and an embedded GeoPoint wouldn't fail it
330-
class GeoPointDeserializer : StdDeserializer<GeoPoint>(GeoPoint::class.java) {
331-
override fun deserialize(p: JsonParser, context: DeserializationContext): GeoPoint {
332-
val node = p.codec.readTree<JsonNode>(p)
333-
return GeoPoint(node.path("lat").asDouble(), node.path("lng").asDouble())
334-
}
326+
// reads the serialized form, and an embedded Box wouldn't fail it
327+
class BoxContentDeserializer : StdDeserializer<String>(String::class.java) {
328+
override fun deserialize(p: JsonParser, context: DeserializationContext): String =
329+
p.codec.readTree<JsonNode>(p).path("content").textValue() ?: "<missing>"
335330
}
336331

337332
class NextDayDeserializer : StdDeserializer<LocalDate>(LocalDate::class.java) {
@@ -359,7 +354,6 @@ class CustomScalarInputTest {
359354
private val opaqueScalar = scalar("Opaque") { Opaque(it) }
360355
private val boxScalar = scalar("Box") { Box(if (it.startsWith("date:")) LocalDate.parse(it.removePrefix("date:")) else it) }
361356
private val moneyScalar = scalar("Money") { Money(it.toInt(), "CAD") }
362-
private val geoPointScalar = scalar("GeoPoint") { value -> value.split(",").map { it.toDouble() }.let { GeoPoint(it[0], it[1]) } }
363357

364358
private fun <T : Any> scalar(name: String, parse: (String) -> T): GraphQLScalarType = GraphQLScalarType.newScalar()
365359
.name(name)

0 commit comments

Comments
 (0)