diff --git a/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/internal/DefaultInputObjectMapper.kt b/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/internal/DefaultInputObjectMapper.kt index a54c1f3b1..9d37c4467 100644 --- a/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/internal/DefaultInputObjectMapper.kt +++ b/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/internal/DefaultInputObjectMapper.kt @@ -30,6 +30,7 @@ import org.springframework.core.convert.converter.GenericConverter import org.springframework.core.convert.support.DefaultConversionService import org.springframework.util.CollectionUtils import org.springframework.util.ReflectionUtils +import java.lang.reflect.Constructor import java.lang.reflect.Type import java.util.Optional import kotlin.reflect.KClass @@ -142,6 +143,10 @@ class DefaultInputObjectMapper(customInputObjectMapper: InputObjectMapper? = nul return inputMap as T } + if (targetClass.isRecord) { + return handleRecordClass(inputMap, targetClass) + } + val ctor = ReflectionUtils.accessibleConstructor(targetClass) val instance = ctor.newInstance() val setterAccessor = setterAccessor(instance) @@ -175,6 +180,24 @@ class DefaultInputObjectMapper(customInputObjectMapper: InputObjectMapper? = nul return instance } + private fun handleRecordClass(inputMap: Map, targetClass: Class): T { + val recordComponents = targetClass.recordComponents + val args = arrayOfNulls(recordComponents.size) + for ((index, component) in recordComponents.withIndex()) { + if (component.name in inputMap) { + args[index] = maybeConvert(inputMap[component.name], component.genericType) + } + } + @Suppress("UNCHECKED_CAST") + val ctor = targetClass.declaredConstructors.first() as Constructor + ctor.trySetAccessible() + try { + return ctor.newInstance(*args) + } catch (exc: ReflectiveOperationException) { + throw DgsInvalidInputArgumentException("Failed to construct record, class=${targetClass.simpleName}", exc) + } + } + private fun fieldAccessor(instance: T?): ConfigurablePropertyAccessor { val accessor = PropertyAccessorFactory.forDirectFieldAccess(instance as Any) diff --git a/graphql-dgs/src/test/java/com/netflix/graphql/dgs/internal/java/test/inputobjects/RecordInput.java b/graphql-dgs/src/test/java/com/netflix/graphql/dgs/internal/java/test/inputobjects/RecordInput.java new file mode 100644 index 000000000..73cdad731 --- /dev/null +++ b/graphql-dgs/src/test/java/com/netflix/graphql/dgs/internal/java/test/inputobjects/RecordInput.java @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.graphql.dgs.internal.java.test.inputobjects; + +public record RecordInput(String foo, boolean bar, Integer baz) { } diff --git a/graphql-dgs/src/test/kotlin/com/netflix/graphql/dgs/internal/InputObjectMapperTest.kt b/graphql-dgs/src/test/kotlin/com/netflix/graphql/dgs/internal/InputObjectMapperTest.kt index af2860c71..4a26aefa1 100644 --- a/graphql-dgs/src/test/kotlin/com/netflix/graphql/dgs/internal/InputObjectMapperTest.kt +++ b/graphql-dgs/src/test/kotlin/com/netflix/graphql/dgs/internal/InputObjectMapperTest.kt @@ -24,6 +24,7 @@ import com.netflix.graphql.dgs.internal.java.test.inputobjects.JInputObjectWithK import com.netflix.graphql.dgs.internal.java.test.inputobjects.JInputObjectWithMap import com.netflix.graphql.dgs.internal.java.test.inputobjects.JInputObjectWithOptional import com.netflix.graphql.dgs.internal.java.test.inputobjects.JInputObjectWithSet +import com.netflix.graphql.dgs.internal.java.test.inputobjects.RecordInput import org.assertj.core.api.Assertions.COLLECTION import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy @@ -274,11 +275,26 @@ internal class InputObjectMapperTest { @Test fun `mapping to a Kotlin class with a value class field works`() { - val result = inputObjectMapper.mapToKotlinObject(mapOf("foo" to ValueClass("the-value"), "bar" to 12345), InputWithValueClass::class) + val result = inputObjectMapper.mapToKotlinObject( + mapOf("foo" to ValueClass("the-value"), "bar" to 12345), + InputWithValueClass::class + ) assertThat(result.foo).isEqualTo(ValueClass("the-value")) assertThat(result.bar).isEqualTo(12345) } + @Test + fun `The mapper supports mapping to records`() { + val result = inputObjectMapper.mapToJavaObject(mapOf("foo" to "foo-value", "bar" to true, "baz" to 12345), RecordInput::class.java) + assertThat(result).isEqualTo(RecordInput("foo-value", true, 12345)) + } + + @Test + fun `The mapper supports mapping to classes with record fields`() { + val result = inputObjectMapper.mapToKotlinObject(mapOf("record" to mapOf("foo" to "foo-value", "bar" to true, "baz" to 12345)), KotlinClassWithRecord::class) + assertThat(result).isEqualTo(KotlinClassWithRecord(record = RecordInput("foo-value", true, 12345))) + } + data class KotlinInputObject(val simpleString: String?, val someDate: LocalDateTime, val someObject: KotlinSomeObject) data class KotlinNestedInputObject(val input: KotlinInputObject) data class KotlinDoubleNestedInputObject(val inputL1: KotlinNestedInputObject) @@ -298,4 +314,6 @@ internal class InputObjectMapperTest { @JvmInline value class ValueClass(val value: String) + + data class KotlinClassWithRecord(val record: RecordInput) }