diff --git a/src/main/java/org/springframework/data/mapping/model/KotlinCopyMethod.java b/src/main/java/org/springframework/data/mapping/model/KotlinCopyMethod.java index 51bc3f551..6d4a0b650 100644 --- a/src/main/java/org/springframework/data/mapping/model/KotlinCopyMethod.java +++ b/src/main/java/org/springframework/data/mapping/model/KotlinCopyMethod.java @@ -20,11 +20,13 @@ import kotlin.reflect.KClass; import kotlin.reflect.KFunction; import kotlin.reflect.KParameter; import kotlin.reflect.KParameter.Kind; +import kotlin.reflect.KType; import kotlin.reflect.full.KClasses; import kotlin.reflect.jvm.ReflectJvmMapping; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -37,7 +39,8 @@ import org.springframework.data.mapping.SimplePropertyHandler; import org.springframework.util.Assert; /** - * Value object to represent a Kotlin {@code copy} method. + * Value object to represent a Kotlin {@code copy} method. The lookup requires a {@code copy} method that matches the + * primary constructor of the class regardless of whether the primary constructor is the persistence constructor. * * @author Mark Paluch * @since 2.1 @@ -59,13 +62,14 @@ class KotlinCopyMethod { this.publicCopyMethod = publicCopyMethod; this.syntheticCopyMethod = syntheticCopyMethod; this.copyFunction = ReflectJvmMapping.getKotlinFunction(publicCopyMethod); - this.parameterCount = copyFunction.getParameters().size(); + this.parameterCount = copyFunction != null ? copyFunction.getParameters().size() : 0; } /** * Attempt to lookup the Kotlin {@code copy} method. Lookup happens in two stages: Find the synthetic copy method and * then attempt to resolve its public variant. * + * @param property the property that must be included in the copy method. * @param type the class. * @return {@link Optional} {@link KotlinCopyMethod}. */ @@ -155,7 +159,6 @@ class KotlinCopyMethod { return true; } - @SuppressWarnings("unchecked") private static Optional findPublicCopyMethod(Method defaultKotlinMethod) { Class type = defaultKotlinMethod.getDeclaringClass(); @@ -167,10 +170,7 @@ class KotlinCopyMethod { return Optional.empty(); } - List constructorArguments = primaryConstructor.getParameters() // - .stream() // - .filter(it -> it.getKind() == Kind.VALUE) // - .collect(Collectors.toList()); + List constructorArguments = getComponentArguments(primaryConstructor); return Arrays.stream(type.getDeclaredMethods()).filter(it -> it.getName().equals("copy") // && !it.isSynthetic() // @@ -207,7 +207,7 @@ class KotlinCopyMethod { KParameter constructorParameter = constructorArguments.get(constructorArgIndex); - if (!constructorParameter.getName().equals(parameter.getName()) + if (constructorParameter.getName() == null || !constructorParameter.getName().equals(parameter.getName()) || !constructorParameter.getType().equals(parameter.getType())) { return false; } @@ -220,14 +220,70 @@ class KotlinCopyMethod { private static Optional findSyntheticCopyMethod(Class type) { + KClass kotlinClass = JvmClassMappingKt.getKotlinClass(type); + KFunction primaryConstructor = KClasses.getPrimaryConstructor(kotlinClass); + + if (primaryConstructor == null) { + return Optional.empty(); + } + return Arrays.stream(type.getDeclaredMethods()) // .filter(it -> it.getName().equals("copy$default") // && Modifier.isStatic(it.getModifiers()) // && it.getReturnType().equals(type)) .filter(Method::isSynthetic) // + .filter(it -> matchesPrimaryConstructor(it.getParameterTypes(), primaryConstructor)) .findFirst(); } + /** + * Verify that the {@code parameterTypes} match arguments of the {@link KFunction primaryConstructor}. + */ + private static boolean matchesPrimaryConstructor(Class[] parameterTypes, KFunction primaryConstructor) { + + List constructorArguments = getComponentArguments(primaryConstructor); + + int defaultingArgs = KotlinDefaultMask.from(primaryConstructor, kParameter -> false).getDefaulting().length; + + if (parameterTypes.length != 1 /* $this */ + constructorArguments.size() + defaultingArgs + 1 /* object marker */) { + return false; + } + + // $this comes first + if (!isAssignableFrom(parameterTypes[0], primaryConstructor.getReturnType())) { + return false; + } + + for (int i = 0; i < constructorArguments.size(); i++) { + + KParameter kParameter = constructorArguments.get(i); + + if (!isAssignableFrom(parameterTypes[i + 1], kParameter.getType())) { + return false; + } + } + + return true; + } + + private static List getComponentArguments(KFunction primaryConstructor) { + return primaryConstructor.getParameters() // + .stream() // + .filter(it -> it.getKind() == Kind.VALUE) // + .collect(Collectors.toList()); + } + + private static boolean isAssignableFrom(Class target, KType source) { + + Type parameterType = ReflectJvmMapping.getJavaType(source); + + if (parameterType instanceof Class) { + return target.isAssignableFrom((Class) parameterType); + } + + return false; + } + /** * Value object to represent Kotlin {@literal copy$default} invocation metadata. * diff --git a/src/test/java/org/springframework/data/mapping/model/ClassGeneratingPropertyAccessorFactoryEntityTypeTests.java b/src/test/java/org/springframework/data/mapping/model/ClassGeneratingPropertyAccessorFactoryEntityTypeTests.java index bce0c0239..2f5748181 100755 --- a/src/test/java/org/springframework/data/mapping/model/ClassGeneratingPropertyAccessorFactoryEntityTypeTests.java +++ b/src/test/java/org/springframework/data/mapping/model/ClassGeneratingPropertyAccessorFactoryEntityTypeTests.java @@ -18,10 +18,12 @@ package org.springframework.data.mapping.model; import static org.assertj.core.api.Assertions.*; import java.io.Serializable; +import java.time.LocalDateTime; import org.junit.jupiter.api.Test; import org.springframework.data.annotation.Id; import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.context.SampleMappingContext; import org.springframework.data.mapping.context.SamplePersistentProperty; import org.springframework.data.repository.core.EntityInformation; @@ -32,6 +34,7 @@ import org.springframework.data.repository.core.support.PersistentEntityInformat * * @author John Blum * @author Oliver Gierke + * @author Mark Paluch */ public class ClassGeneratingPropertyAccessorFactoryEntityTypeTests { @@ -53,6 +56,17 @@ public class ClassGeneratingPropertyAccessorFactoryEntityTypeTests { assertThat(getEntityInformation(Person.class).getId(jonDoe)).isEqualTo(jonDoe.name); } + @Test // #2324 + public void shouldGeneratePropertyAccessorForKotlinClassWithMultipleCopyMethods() { + + ClassGeneratingPropertyAccessorFactory factory = new ClassGeneratingPropertyAccessorFactory(); + PersistentPropertyAccessor propertyAccessor = factory.getPropertyAccessor( + mappingContext.getRequiredPersistentEntity(WithCustomCopyMethod.class), + new WithCustomCopyMethod("", "", "", 1, LocalDateTime.MAX, LocalDateTime.MAX, "")); + + assertThat(propertyAccessor).isNotNull(); + } + private EntityInformation getEntityInformation(Class type) { PersistentEntity entity = mappingContext.getRequiredPersistentEntity(type); diff --git a/src/test/java/org/springframework/data/mapping/model/KotlinCopyMethodUnitTests.java b/src/test/java/org/springframework/data/mapping/model/KotlinCopyMethodUnitTests.java new file mode 100644 index 000000000..8df0fd724 --- /dev/null +++ b/src/test/java/org/springframework/data/mapping/model/KotlinCopyMethodUnitTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2021 the original author or authors. + * + * 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 + * + * https://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 org.springframework.data.mapping.model; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.mapping.context.SampleMappingContext; + +/** + * Unit tests for {@link KotlinCopyMethod}. + * + * @author Mark Paluch + */ +class KotlinCopyMethodUnitTests { + + SampleMappingContext mappingContext = new SampleMappingContext(); + + @Test // #2324 + void shouldLookupPrimaryConstructor() { + + Optional optional = KotlinCopyMethod.findCopyMethod(DataClassKt.class); + + assertThat(optional).hasValueSatisfying(actual -> { + // $this, 1 component, 1 defaulting mask, 1 Object marker + assertThat(actual.getSyntheticCopyMethod().getParameterCount()).isEqualTo(4); + + // $this, 1 component + assertThat(actual.getCopyFunction().getParameters()).hasSize(2); + }); + } + + @Test // #2324 + void shouldLookupPrimaryConstructorWhenTwoCopyMethodsArePresent() { + + Optional optional = KotlinCopyMethod.findCopyMethod(WithCustomCopyMethod.class); + + assertThat(optional).hasValueSatisfying(actual -> { + // $this, 7 components, 1 defaulting mask, 1 Object marker + assertThat(actual.getSyntheticCopyMethod().getParameterCount()).isEqualTo(10); + + // $this, 7 components + assertThat(actual.getCopyFunction().getParameters()).hasSize(8); + + assertThat( + actual.shouldUsePublicCopyMethod(mappingContext.getRequiredPersistentEntity(WithCustomCopyMethod.class))) + .isFalse(); + }); + } + + @Test // #2324 + void shouldUsePublicKotlinMethodForSinglePropertyEntities() { + + KotlinCopyMethod copyMethod = KotlinCopyMethod.findCopyMethod(DataClassKt.class).get(); + + assertThat(copyMethod.shouldUsePublicCopyMethod(mappingContext.getRequiredPersistentEntity(DataClassKt.class))) + .isTrue(); + } +} diff --git a/src/test/kotlin/org/springframework/data/mapping/model/DataClasses.kt b/src/test/kotlin/org/springframework/data/mapping/model/DataClasses.kt index 84a26de1c..364ae60c5 100644 --- a/src/test/kotlin/org/springframework/data/mapping/model/DataClasses.kt +++ b/src/test/kotlin/org/springframework/data/mapping/model/DataClasses.kt @@ -15,6 +15,7 @@ */ package org.springframework.data.mapping.model +import java.time.LocalDateTime import java.util.* /** @@ -31,3 +32,30 @@ data class ExtendedDataClassKt(val id: Long, val name: String) { data class SingleSettableProperty constructor(val id: UUID = UUID.randomUUID()) { val version: Int? = null } + +data class WithCustomCopyMethod( + val id: String?, + val userId: String, + val status: String, + val attempts: Int, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, + val sessionId: String? +) { + + fun copy( + status: String, + updatedAt: LocalDateTime, + sessionId: String, + attempts: Int = this.attempts + ) = WithCustomCopyMethod( + this.id, + this.userId, + status, + attempts, + this.createdAt, + updatedAt, + sessionId + ) + +}