Skip to content

Commit 478fb65

Browse files
committed
Support Kotlin value classes in binder
This commit adds support for Kotlin Value classes when binding arguments to `@SchemaMapping` methods. Complete Framework support is not available yet, but this change unlocks typical Kotlin usage in GraphQL applications. Closes gh-1186
1 parent d87dffd commit 478fb65

File tree

2 files changed

+119
-1
lines changed

2 files changed

+119
-1
lines changed

spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
import java.util.Optional;
2626

2727
import graphql.schema.DataFetchingEnvironment;
28+
import kotlin.jvm.JvmClassMappingKt;
29+
import kotlin.reflect.KClass;
30+
import kotlin.reflect.KFunction;
31+
import kotlin.reflect.KParameter;
32+
import kotlin.reflect.KType;
33+
import kotlin.reflect.full.KClasses;
34+
import kotlin.reflect.jvm.KCallablesJvm;
35+
import kotlin.reflect.jvm.ReflectJvmMapping;
2836

2937
import org.springframework.beans.BeanInstantiationException;
3038
import org.springframework.beans.BeanUtils;
@@ -36,6 +44,7 @@
3644
import org.springframework.beans.TypeMismatchException;
3745
import org.springframework.core.CollectionFactory;
3846
import org.springframework.core.Conventions;
47+
import org.springframework.core.KotlinDetector;
3948
import org.springframework.core.MethodParameter;
4049
import org.springframework.core.ResolvableType;
4150
import org.springframework.core.convert.ConversionService;
@@ -280,7 +289,7 @@ private Object bindViaConstructorAndSetters(Constructor<?> constructor,
280289
Map<String, Object> dataToBind = new HashMap<>(rawMap);
281290
String[] paramNames = BeanUtils.getParameterNames(constructor);
282291
Class<?>[] paramTypes = constructor.getParameterTypes();
283-
Object[] constructorArguments = new Object[paramTypes.length];
292+
Object[] constructorArguments = new Object[paramNames.length];
284293

285294
for (int i = 0; i < paramNames.length; i++) {
286295
String name = paramNames[i];
@@ -290,8 +299,12 @@ private Object bindViaConstructorAndSetters(Constructor<?> constructor,
290299

291300
constructorArguments[i] = bindRawValue(
292301
name, dataToBind.get(name), !dataToBind.containsKey(name), targetType, paramTypes[i], bindingResult);
302+
293303
dataToBind.remove(name);
294304
}
305+
if (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(constructor.getDeclaringClass())) {
306+
KotlinDelegate.rebindKotlinArguments(constructorArguments, constructor);
307+
}
295308

296309
Object target;
297310
try {
@@ -419,4 +432,37 @@ void rejectArgumentValue(
419432
}
420433
}
421434

435+
// remove in favor of https://github.com/spring-projects/spring-framework/issues/33630
436+
private static final class KotlinDelegate {
437+
438+
public static void rebindKotlinArguments(Object[] arguments, Constructor<?> constructor) {
439+
KFunction<?> function = ReflectJvmMapping.getKotlinFunction(constructor);
440+
if (function == null) {
441+
return;
442+
}
443+
int index = 0;
444+
for (KParameter parameter : function.getParameters()) {
445+
switch (parameter.getKind()) {
446+
case VALUE, EXTENSION_RECEIVER -> {
447+
Object rawValue = arguments[index];
448+
if (!(parameter.isOptional() && rawValue == null)) {
449+
KType type = parameter.getType();
450+
if (!(type.isMarkedNullable() && rawValue == null) && type.getClassifier() instanceof KClass<?> kClass
451+
&& KotlinDetector.isInlineClass(JvmClassMappingKt.getJavaClass(kClass))) {
452+
KFunction<?> argConstructor = KClasses.getPrimaryConstructor(kClass);
453+
if (argConstructor != null) {
454+
if (!KCallablesJvm.isAccessible(argConstructor)) {
455+
KCallablesJvm.setAccessible(argConstructor, true);
456+
}
457+
arguments[index] = argConstructor.call(rawValue);
458+
}
459+
}
460+
}
461+
}
462+
}
463+
index++;
464+
}
465+
}
466+
}
467+
422468
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2025-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.data
18+
19+
import com.fasterxml.jackson.core.type.TypeReference
20+
import com.fasterxml.jackson.databind.json.JsonMapper
21+
import graphql.schema.DataFetchingEnvironment
22+
import graphql.schema.DataFetchingEnvironmentImpl
23+
import org.assertj.core.api.Assertions.assertThat
24+
import org.junit.jupiter.api.Test
25+
import org.springframework.core.ResolvableType
26+
import org.springframework.format.support.DefaultFormattingConversionService
27+
import org.springframework.lang.Nullable
28+
29+
class GraphQlArgumentBinderKotlinTests {
30+
31+
private val mapper = JsonMapper.builder().build()
32+
33+
private val binder = GraphQlArgumentBinder(DefaultFormattingConversionService())
34+
35+
@Test
36+
fun bindValueClass() {
37+
val targetType = ResolvableType.forClass(MyDataClassWithValueClass::class.java)
38+
val result = bind(binder, "{\"first\": \"firstValue\", \"second\": \"secondValue\"}", targetType) as MyDataClassWithValueClass
39+
assertThat(result.first).isEqualTo("firstValue")
40+
assertThat(result.second).isEqualTo(MyValueClass("secondValue"))
41+
}
42+
43+
@Test
44+
fun bindValueClassWithDefaultValue() {
45+
val targetType = ResolvableType.forClass(MyDataClassWithDefaultValueClass::class.java)
46+
val result = bind(binder, "{\"first\": \"firstValue\"}", targetType) as MyDataClassWithDefaultValueClass
47+
assertThat(result.first).isEqualTo("firstValue")
48+
49+
}
50+
51+
@Nullable
52+
@Throws(Exception::class)
53+
private fun bind(binder: GraphQlArgumentBinder, json: String, targetType: ResolvableType): Any? {
54+
val typeRef: TypeReference<Map<String, Any>> = object : TypeReference<Map<String, Any>>() {}
55+
val map = this.mapper.readValue("{\"key\":$json}", typeRef)
56+
val environment: DataFetchingEnvironment =
57+
DataFetchingEnvironmentImpl.newDataFetchingEnvironment()
58+
.arguments(map)
59+
.build()
60+
return binder.bind(environment, "key", targetType)
61+
}
62+
63+
data class MyDataClassWithValueClass(val first: String, val second: MyValueClass)
64+
65+
data class MyDataClassWithDefaultValueClass(val first: String, val second: MyValueClass = MyValueClass("secondValue"))
66+
67+
@JvmInline
68+
value class MyValueClass(val value: String) {
69+
70+
}
71+
72+
}

0 commit comments

Comments
 (0)