diff --git a/pom.xml b/pom.xml index 8174e38303..c99cf2a2b8 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 3.2.0-SNAPSHOT + 3.2.0-GH-2806-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. @@ -33,6 +33,7 @@ 2.11.7 1.4.24 spring.data.commons + 1.8 @@ -126,6 +127,12 @@ test + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin} + + diff --git a/src/main/asciidoc/object-mapping.adoc b/src/main/asciidoc/object-mapping.adoc index c2213c3ac0..804212fac6 100644 --- a/src/main/asciidoc/object-mapping.adoc +++ b/src/main/asciidoc/object-mapping.adoc @@ -436,3 +436,27 @@ You can exclude properties by annotating these with `@Transient`. 2. How to represent properties in your data store? Using the same field/column name for different values typically leads to corrupt data so you should annotate least one of the properties using an explicit field/column name. 3. Using `@AccessType(PROPERTY)` cannot be used as the super-property cannot be set. + +[[mapping.kotlin.value.classes]] +=== Kotlin Value Classes + +Kotlin Value Classes are designed for a more expressive domain model to make underlying concepts explicit. +Spring Data can read and write types that define properties using Value Classes. + +Consider the following domain model: + +==== +[source,kotlin] +---- +@JvmInline +value class EmailAddress(val theAddress: String) <1> + +data class Contact(val id: String, val name:String, val emailAddress: EmailAddress) <2> +---- + +<1> A simple value class with a non-nullable value type. +<2> Data class defining a property using the `EmailAddress` value class. +==== + +NOTE: Non-nullable properties using non-primitive value types are flattened in the compiled class to the value type. +Nullable primitive value types or nullable value-in-value types are represented with their wrapper type and that affects how value types are represented in the database. diff --git a/src/main/java/org/springframework/data/mapping/model/BeanWrapper.java b/src/main/java/org/springframework/data/mapping/model/BeanWrapper.java index 607200434b..3bd3ee60bc 100644 --- a/src/main/java/org/springframework/data/mapping/model/BeanWrapper.java +++ b/src/main/java/org/springframework/data/mapping/model/BeanWrapper.java @@ -168,8 +168,8 @@ static Object setProperty(PersistentProperty property, T bean, @Nullable KCallable copy = copyMethodCache.computeIfAbsent(type, it -> getCopyMethod(it, property)); if (copy == null) { - throw new UnsupportedOperationException(String.format( - "Kotlin class %s has no .copy(…) method for property %s", type.getName(), property.getName())); + throw new UnsupportedOperationException(String.format("Kotlin class %s has no .copy(…) method for property %s", + type.getName(), property.getName())); } return copy.callBy(getCallArgs(copy, property, bean, value)); @@ -179,7 +179,6 @@ private static Map getCallArgs(KCallable callable, Pe T bean, @Nullable Object value) { Map args = new LinkedHashMap<>(2, 1); - List parameters = callable.getParameters(); for (KParameter parameter : parameters) { @@ -190,7 +189,8 @@ private static Map getCallArgs(KCallable callable, Pe if (parameter.getKind() == Kind.VALUE && parameter.getName() != null && parameter.getName().equals(property.getName())) { - args.put(parameter, value); + + args.put(parameter, KotlinValueUtils.getCopyValueHierarchy(parameter).wrap(value)); } } return args; diff --git a/src/main/java/org/springframework/data/mapping/model/ClassGeneratingPropertyAccessorFactory.java b/src/main/java/org/springframework/data/mapping/model/ClassGeneratingPropertyAccessorFactory.java index 99d815b41c..b6e26ca4ca 100644 --- a/src/main/java/org/springframework/data/mapping/model/ClassGeneratingPropertyAccessorFactory.java +++ b/src/main/java/org/springframework/data/mapping/model/ClassGeneratingPropertyAccessorFactory.java @@ -18,11 +18,15 @@ import static org.springframework.asm.Opcodes.*; import static org.springframework.data.mapping.model.BytecodeUtil.*; +import kotlin.reflect.KParameter; +import kotlin.reflect.KParameter.Kind; + import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Member; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; import java.security.ProtectionDomain; import java.util.ArrayList; import java.util.Collections; @@ -48,12 +52,16 @@ import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.SimpleAssociationHandler; import org.springframework.data.mapping.SimplePropertyHandler; +import org.springframework.data.mapping.model.KotlinCopyMethod.KotlinCopyByProperty; +import org.springframework.data.mapping.model.KotlinValueUtils.ValueBoxing; import org.springframework.data.util.Optionals; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.ConcurrentLruCache; import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; /** * A factory that can generate byte code to speed-up dynamic property access. Uses the {@link PersistentEntity}'s @@ -76,6 +84,9 @@ public class ClassGeneratingPropertyAccessorFactory implements PersistentPropert private volatile Map, Class>> propertyAccessorClasses = new HashMap<>( 32); + private final ConcurrentLruCache, Function> wrapperCache = new ConcurrentLruCache<>( + 256, KotlinValueBoxingAdapter::getWrapper); + @Override public PersistentPropertyAccessor getPropertyAccessor(PersistentEntity entity, T bean) { @@ -96,7 +107,14 @@ public PersistentPropertyAccessor getPropertyAccessor(PersistentEntity) constructor.newInstance(args); + + PersistentPropertyAccessor accessor = (PersistentPropertyAccessor) constructor.newInstance(args); + + if (KotlinDetector.isKotlinType(entity.getType())) { + return new KotlinValueBoxingAdapter<>(entity, accessor, wrapperCache); + } + + return accessor; } catch (Exception e) { throw new IllegalArgumentException(String.format("Cannot create persistent property accessor for %s", entity), e); } finally { @@ -1431,7 +1449,7 @@ static boolean supportsMutation(PersistentProperty property) { * Check whether the owning type of {@link PersistentProperty} declares a {@literal copy} method or {@literal copy} * method with parameter defaulting. * - * @param type must not be {@literal null}. + * @param property must not be {@literal null}. * @return */ private static boolean hasKotlinCopyMethod(PersistentProperty property) { @@ -1444,4 +1462,70 @@ private static boolean hasKotlinCopyMethod(PersistentProperty property) { return false; } + + /** + * Adapter to encapsulate Kotlin's value class boxing when properties are nullable. + * + * @param entity the entity that could use value class properties. + * @param delegate the property accessor to delegate to. + * @param wrapperCache cache for wrapping functions. + * @param + * @since 3.2 + */ + record KotlinValueBoxingAdapter (PersistentEntity entity, PersistentPropertyAccessor delegate, + ConcurrentLruCache, Function> wrapperCache) + implements + PersistentPropertyAccessor { + + @Override + public void setProperty(PersistentProperty property, @Nullable Object value) { + delegate.setProperty(property, wrapperCache.get(property).apply(value)); + } + + /** + * Create a wrapper function if the {@link PersistentProperty} uses value classes. + * + * @param property the persistent property to inspect. + * @return a wrapper function to wrap a value class component into the hierarchy of value classes or + * {@link Function#identity()} if wrapping is not necessary. + * @see KotlinValueUtils#getCopyValueHierarchy(KParameter) + */ + static Function getWrapper(PersistentProperty property) { + + Optional kotlinCopyMethod = KotlinCopyMethod.findCopyMethod(property.getOwner().getType()) + .filter(it -> it.supportsProperty(property)); + + if (kotlinCopyMethod.isPresent() + && kotlinCopyMethod.filter(it -> it.forProperty(property).isPresent()).isPresent()) { + KotlinCopyMethod copyMethod = kotlinCopyMethod.get(); + + Optional kParameter = kotlinCopyMethod.stream() + .flatMap(it -> it.getCopyFunction().getParameters().stream()) // + .filter(kf -> kf.getKind() == Kind.VALUE) // + .filter(kf -> StringUtils.hasText(kf.getName())) // + .filter(kf -> kf.getName().equals(property.getName())) // + .findFirst(); + + ValueBoxing vh = kParameter.map(KotlinValueUtils::getCopyValueHierarchy).orElse(null); + KotlinCopyByProperty kotlinCopyByProperty = copyMethod.forProperty(property).get(); + Method copy = copyMethod.getSyntheticCopyMethod(); + + Parameter parameter = copy.getParameters()[kotlinCopyByProperty.getParameterPosition()]; + + return o -> ClassUtils.isAssignableValue(parameter.getType(), o) || vh == null ? o : vh.wrap(o); + } + + return Function.identity(); + } + + @Override + public Object getProperty(PersistentProperty property) { + return delegate.getProperty(property); + } + + @Override + public T getBean() { + return delegate.getBean(); + } + } } diff --git a/src/main/java/org/springframework/data/mapping/model/InstanceCreatorMetadataDiscoverer.java b/src/main/java/org/springframework/data/mapping/model/InstanceCreatorMetadataDiscoverer.java index ceabbc9d24..fd06866ebf 100644 --- a/src/main/java/org/springframework/data/mapping/model/InstanceCreatorMetadataDiscoverer.java +++ b/src/main/java/org/springframework/data/mapping/model/InstanceCreatorMetadataDiscoverer.java @@ -15,6 +15,11 @@ */ package org.springframework.data.mapping.model; +import kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KCallable; +import kotlin.reflect.KClass; +import kotlin.reflect.KProperty; + import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Constructor; @@ -24,6 +29,7 @@ import java.util.List; import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.KotlinDetector; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.data.annotation.PersistenceCreator; @@ -55,7 +61,8 @@ class InstanceCreatorMetadataDiscoverer { * @return */ @Nullable - public static > InstanceCreatorMetadata

discover(PersistentEntity entity) { + public static > InstanceCreatorMetadata

discover( + PersistentEntity entity) { Constructor[] declaredConstructors = entity.getType().getDeclaredConstructors(); Method[] declaredMethods = entity.getType().getDeclaredMethods(); @@ -78,6 +85,34 @@ public static > InstanceCreatorMetadata

di } } + if (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(entity.getType())) { + + KClass kClass = JvmClassMappingKt.getKotlinClass(entity.getType()); + // We use box-impl as factory for classes. + if (kClass.isValue()) { + + String propertyName = ""; + for (KCallable member : kClass.getMembers()) { + if (member instanceof KProperty) { + propertyName = member.getName(); + break; + } + } + + for (Method declaredMethod : entity.getType().getDeclaredMethods()) { + if (declaredMethod.getName().equals("box-impl") && declaredMethod.isSynthetic() + && declaredMethod.getParameterCount() == 1) { + + Annotation[][] parameterAnnotations = declaredMethod.getParameterAnnotations(); + List> types = entity.getTypeInformation().getParameterTypes(declaredMethod); + + return new FactoryMethod<>(declaredMethod, + new Parameter<>(propertyName, (TypeInformation) types.get(0), parameterAnnotations[0], entity)); + } + } + } + } + return PreferredConstructorDiscoverer.discover(entity); } diff --git a/src/main/java/org/springframework/data/mapping/model/KotlinClassGeneratingEntityInstantiator.java b/src/main/java/org/springframework/data/mapping/model/KotlinClassGeneratingEntityInstantiator.java index 8c29fab1c4..f5570c49f6 100644 --- a/src/main/java/org/springframework/data/mapping/model/KotlinClassGeneratingEntityInstantiator.java +++ b/src/main/java/org/springframework/data/mapping/model/KotlinClassGeneratingEntityInstantiator.java @@ -15,23 +15,14 @@ */ package org.springframework.data.mapping.model; -import kotlin.reflect.KFunction; -import kotlin.reflect.KParameter; -import kotlin.reflect.jvm.ReflectJvmMapping; - import java.lang.reflect.Constructor; import java.util.Arrays; -import java.util.List; -import java.util.stream.IntStream; import org.springframework.data.mapping.InstanceCreatorMetadata; -import org.springframework.data.mapping.Parameter; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.PreferredConstructor; import org.springframework.data.util.KotlinReflectionUtils; -import org.springframework.data.util.ReflectionUtils; -import org.springframework.lang.Nullable; /** * Kotlin-specific extension to {@link ClassGeneratingEntityInstantiator} that adapts Kotlin constructors with @@ -52,102 +43,21 @@ protected EntityInstantiator doCreateEntityInstantiator(PersistentEntity e if (KotlinReflectionUtils.isSupportedKotlinClass(entity.getType()) && creator instanceof PreferredConstructor constructor) { - PreferredConstructor> defaultConstructor = new DefaultingKotlinConstructorResolver( - entity) - .getDefaultConstructor(); + PreferredConstructor> kotlinJvmConstructor = KotlinInstantiationDelegate + .resolveKotlinJvmConstructor(constructor); - if (defaultConstructor != null) { + if (kotlinJvmConstructor != null) { - ObjectInstantiator instantiator = createObjectInstantiator(entity, defaultConstructor); + ObjectInstantiator instantiator = createObjectInstantiator(entity, kotlinJvmConstructor); - return new DefaultingKotlinClassInstantiatorAdapter(instantiator, constructor); + return new DefaultingKotlinClassInstantiatorAdapter(instantiator, constructor, + kotlinJvmConstructor.getConstructor()); } } return super.doCreateEntityInstantiator(entity); } - /** - * Resolves a {@link PreferredConstructor} to a synthetic Kotlin constructor accepting the same user-space parameters - * suffixed by Kotlin-specifics required for defaulting and the {@code kotlin.jvm.internal.DefaultConstructorMarker}. - * - * @since 2.0 - * @author Mark Paluch - */ - static class DefaultingKotlinConstructorResolver { - - private final @Nullable PreferredConstructor defaultConstructor; - - @SuppressWarnings("unchecked") - DefaultingKotlinConstructorResolver(PersistentEntity entity) { - - Constructor hit = resolveDefaultConstructor(entity); - InstanceCreatorMetadata> creator = entity.getInstanceCreatorMetadata(); - - if ((hit != null) && creator instanceof PreferredConstructor persistenceConstructor) { - this.defaultConstructor = new PreferredConstructor<>(hit, - persistenceConstructor.getParameters().toArray(new Parameter[0])); - } else { - this.defaultConstructor = null; - } - } - - @Nullable - private static Constructor resolveDefaultConstructor(PersistentEntity entity) { - - if (!(entity.getInstanceCreatorMetadata() instanceof PreferredConstructor persistenceConstructor)) { - return null; - } - - Constructor hit = null; - Constructor constructor = persistenceConstructor.getConstructor(); - - for (Constructor candidate : entity.getType().getDeclaredConstructors()) { - - // use only synthetic constructors - if (!candidate.isSynthetic()) { - continue; - } - - // candidates must contain at least two additional parameters (int, DefaultConstructorMarker). - // Number of defaulting masks derives from the original constructor arg count - int syntheticParameters = KotlinDefaultMask.getMaskCount(constructor.getParameterCount()) - + /* DefaultConstructorMarker */ 1; - - if ((constructor.getParameterCount() + syntheticParameters) != candidate.getParameterCount()) { - continue; - } - - java.lang.reflect.Parameter[] constructorParameters = constructor.getParameters(); - java.lang.reflect.Parameter[] candidateParameters = candidate.getParameters(); - - if (!candidateParameters[candidateParameters.length - 1].getType().getName() - .equals("kotlin.jvm.internal.DefaultConstructorMarker")) { - continue; - } - - if (parametersMatch(constructorParameters, candidateParameters)) { - hit = candidate; - break; - } - } - - return hit; - } - - private static boolean parametersMatch(java.lang.reflect.Parameter[] constructorParameters, - java.lang.reflect.Parameter[] candidateParameters) { - - return IntStream.range(0, constructorParameters.length) - .allMatch(i -> constructorParameters[i].getType().equals(candidateParameters[i].getType())); - } - - @Nullable - PreferredConstructor getDefaultConstructor() { - return defaultConstructor; - } - } - /** * Entity instantiator for Kotlin constructors that apply parameter defaulting. Kotlin constructors that apply * argument defaulting are marked with {@link kotlin.jvm.internal.DefaultConstructorMarker} and accept additional @@ -169,23 +79,13 @@ private static boolean parametersMatch(java.lang.reflect.Parameter[] constructor static class DefaultingKotlinClassInstantiatorAdapter implements EntityInstantiator { private final ObjectInstantiator instantiator; - private final KFunction constructor; - private final List kParameters; - private final Constructor synthetic; + private final KotlinInstantiationDelegate delegate; - DefaultingKotlinClassInstantiatorAdapter(ObjectInstantiator instantiator, PreferredConstructor constructor) { - - KFunction kotlinConstructor = ReflectJvmMapping.getKotlinFunction(constructor.getConstructor()); - - if (kotlinConstructor == null) { - throw new IllegalArgumentException( - "No corresponding Kotlin constructor found for " + constructor.getConstructor()); - } + DefaultingKotlinClassInstantiatorAdapter(ObjectInstantiator instantiator, + PreferredConstructor defaultConstructor, Constructor constructorToInvoke) { this.instantiator = instantiator; - this.constructor = kotlinConstructor; - this.kParameters = kotlinConstructor.getParameters(); - this.synthetic = constructor.getConstructor(); + this.delegate = new KotlinInstantiationDelegate(defaultConstructor, constructorToInvoke); } @Override @@ -193,7 +93,8 @@ static class DefaultingKotlinClassInstantiatorAdapter implements EntityInstantia public , P extends PersistentProperty

> T createInstance(E entity, ParameterValueProvider

provider) { - Object[] params = extractInvocationArguments(entity.getInstanceCreatorMetadata(), provider); + Object[] params = allocateArguments(delegate.getRequiredParameterCount()); + delegate.extractInvocationArguments(params, entity.getInstanceCreatorMetadata(), provider); try { return (T) instantiator.newInstance(params); @@ -202,52 +103,5 @@ public , P extends PersistentPrope } } - private

, T> Object[] extractInvocationArguments( - @Nullable InstanceCreatorMetadata

entityCreator, ParameterValueProvider

provider) { - - if (entityCreator == null) { - throw new IllegalArgumentException("EntityCreator must not be null"); - } - - Object[] params = allocateArguments(synthetic.getParameterCount() - + KotlinDefaultMask.getMaskCount(synthetic.getParameterCount()) + /* DefaultConstructorMarker */1); - int userParameterCount = kParameters.size(); - - List> parameters = entityCreator.getParameters(); - - // Prepare user-space arguments - for (int i = 0; i < userParameterCount; i++) { - - Parameter parameter = parameters.get(i); - params[i] = provider.getParameterValue(parameter); - } - - KotlinDefaultMask defaultMask = KotlinDefaultMask.from(constructor, it -> { - - int index = kParameters.indexOf(it); - - Parameter parameter = parameters.get(index); - Class type = parameter.getType().getType(); - - if (it.isOptional() && (params[index] == null)) { - if (type.isPrimitive()) { - - // apply primitive defaulting to prevent NPE on primitive downcast - params[index] = ReflectionUtils.getPrimitiveDefault(type); - } - return false; - } - - return true; - }); - - int[] defaulting = defaultMask.getDefaulting(); - // append nullability masks to creation arguments - for (int i = 0; i < defaulting.length; i++) { - params[userParameterCount + i] = defaulting[i]; - } - - return params; - } } } 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 d973ded20d..0794dbdadf 100644 --- a/src/main/java/org/springframework/data/mapping/model/KotlinCopyMethod.java +++ b/src/main/java/org/springframework/data/mapping/model/KotlinCopyMethod.java @@ -31,6 +31,7 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.function.Predicate; import java.util.stream.Collectors; import org.springframework.core.ResolvableType; @@ -67,10 +68,9 @@ private KotlinCopyMethod(Method publicCopyMethod, Method syntheticCopyMethod) { } /** - * Attempt to lookup the Kotlin {@code copy} method. Lookup happens in two stages: Find the synthetic copy method and + * Attempt to look up 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}. */ @@ -171,13 +171,27 @@ private static Optional findPublicCopyMethod(Method defaultKotlinMethod) return Optional.empty(); } + boolean usesValueClasses = KotlinValueUtils.hasValueClassProperty(type); List constructorArguments = getComponentArguments(primaryConstructor); + Predicate isCopyMethod; - return Arrays.stream(type.getDeclaredMethods()).filter(it -> it.getName().equals("copy") // - && !it.isSynthetic() // - && !Modifier.isStatic(it.getModifiers()) // - && it.getReturnType().equals(type) // - && it.getParameterCount() == constructorArguments.size()) // + if (usesValueClasses) { + String methodName = defaultKotlinMethod.getName(); + Assert.isTrue(methodName.contains("$"), () -> "Cannot find $ marker in method name " + defaultKotlinMethod); + + String methodNameWithHash = methodName.substring(0, methodName.indexOf("$")); + isCopyMethod = it -> it.equals(methodNameWithHash); + } else { + isCopyMethod = it -> it.equals("copy"); + } + + return Arrays.stream(type.getDeclaredMethods()).filter(it -> { + return isCopyMethod.test(it.getName()) // + && !it.isSynthetic() // + && !Modifier.isStatic(it.getModifiers()) // + && it.getReturnType().equals(type) // + && it.getParameterCount() == constructorArguments.size(); + }) // .filter(it -> { KFunction kotlinFunction = ReflectJvmMapping.getKotlinFunction(it); @@ -228,13 +242,17 @@ private static Optional findSyntheticCopyMethod(Class type) { return Optional.empty(); } + boolean usesValueClasses = KotlinValueUtils.hasValueClassProperty(type); + + Predicate isCopyMethod = usesValueClasses ? (it -> it.startsWith("copy-") && it.endsWith("$default")) + : (it -> it.equals("copy$default")); + return Arrays.stream(type.getDeclaredMethods()) // - .filter(it -> it.getName().equals("copy$default") // + .filter(it -> isCopyMethod.test(it.getName()) // && Modifier.isStatic(it.getModifiers()) // && it.getReturnType().equals(type)) .filter(Method::isSynthetic) // - .filter(it -> matchesPrimaryConstructor(it.getParameterTypes(), primaryConstructor)) - .findFirst(); + .filter(it -> matchesPrimaryConstructor(it.getParameterTypes(), primaryConstructor)).findFirst(); } /** @@ -244,7 +262,7 @@ private static boolean matchesPrimaryConstructor(Class[] parameterTypes, KFun List constructorArguments = getComponentArguments(primaryConstructor); - int defaultingArgs = KotlinDefaultMask.from(primaryConstructor, kParameter -> false).getDefaulting().length; + int defaultingArgs = KotlinDefaultMask.forCopy(primaryConstructor, kParameter -> false).getDefaulting().length; if (parameterTypes.length != 1 /* $this */ + constructorArguments.size() + defaultingArgs + 1 /* object marker */) { return false; @@ -259,6 +277,12 @@ private static boolean matchesPrimaryConstructor(Class[] parameterTypes, KFun KParameter kParameter = constructorArguments.get(i); + if (KotlinValueUtils.isValueClass(kParameter.getType())) { + // sigh. This can require deep unwrapping because the public vs. the synthetic copy methods use different + // parameter types. + continue; + } + if (!isAssignableFrom(parameterTypes[i + 1], kParameter.getType())) { return false; } @@ -297,7 +321,7 @@ static class KotlinCopyByProperty { this.parameterPosition = findIndex(copyFunction, property.getName()); this.parameterCount = copyFunction.getParameters().size(); - this.defaultMask = KotlinDefaultMask.from(copyFunction, it -> property.getName().equals(it.getName())); + this.defaultMask = KotlinDefaultMask.forCopy(copyFunction, it -> property.getName().equals(it.getName())); } static int findIndex(KFunction function, String parameterName) { diff --git a/src/main/java/org/springframework/data/mapping/model/KotlinDefaultMask.java b/src/main/java/org/springframework/data/mapping/model/KotlinDefaultMask.java index d7aeadf5a3..41a6e591f9 100644 --- a/src/main/java/org/springframework/data/mapping/model/KotlinDefaultMask.java +++ b/src/main/java/org/springframework/data/mapping/model/KotlinDefaultMask.java @@ -54,12 +54,23 @@ public void forEach(IntConsumer maskCallback) { * Return the number of defaulting masks required to represent the number of {@code arguments}. * * @param arguments number of method arguments. - * @return the number of defaulting masks required. + * @return the number of defaulting masks required. Returns at least one to be used with {@code copy} methods. */ public static int getMaskCount(int arguments) { return ((arguments - 1) / Integer.SIZE) + 1; } + /** + * Return the number of defaulting masks required to represent the number of {@code optionalParameterCount}. + * In contrast to {@link #getMaskCount(int)}, this method can return zero if there are no optional parameters available. + * + * @param optionalParameterCount number of method arguments. + * @return the number of defaulting masks required. Returns zero if no optional parameters are available. + */ + static int getExactMaskCount(int optionalParameterCount) { + return optionalParameterCount == 0 ? 0 : getMaskCount(optionalParameterCount); + } + /** * Creates defaulting mask(s) used to invoke Kotlin {@literal default} methods that conditionally apply parameter * values. @@ -69,10 +80,47 @@ public static int getMaskCount(int arguments) { * @return {@link KotlinDefaultMask}. */ public static KotlinDefaultMask from(KFunction function, Predicate isPresent) { + return forCopy(function, isPresent); + } + + /** + * Creates defaulting mask(s) used to invoke Kotlin {@literal copy} methods that conditionally apply parameter values. + * + * @param function the {@link KFunction} that should be invoked. + * @param isPresent {@link Predicate} for the presence/absence of parameters. + * @return {@link KotlinDefaultMask}. + */ + static KotlinDefaultMask forCopy(KFunction function, Predicate isPresent) { + return from(function, isPresent, true); + } + + /** + * Creates defaulting mask(s) used to invoke Kotlin constructors where a defaulting mask isn't required unless there's + * one nullable argument. + * + * @param function the {@link KFunction} that should be invoked. + * @param isPresent {@link Predicate} for the presence/absence of parameters. + * @return {@link KotlinDefaultMask}. + */ + static KotlinDefaultMask forConstructor(KFunction function, Predicate isPresent) { + return from(function, isPresent, false); + } + + /** + * Creates defaulting mask(s) used to invoke Kotlin {@literal default} methods that conditionally apply parameter + * values. + * + * @param function the {@link KFunction} that should be invoked. + * @param isPresent {@link Predicate} for the presence/absence of parameters. + * @return {@link KotlinDefaultMask}. + */ + private static KotlinDefaultMask from(KFunction function, Predicate isPresent, + boolean requiresAtLeastOneMask) { List masks = new ArrayList<>(); int index = 0; int mask = 0; + boolean hasSeenParameter = false; List parameters = function.getParameters(); @@ -83,8 +131,12 @@ public static KotlinDefaultMask from(KFunction function, Predicate function, Predicate i).toArray()); } diff --git a/src/main/java/org/springframework/data/mapping/model/KotlinInstantiationDelegate.java b/src/main/java/org/springframework/data/mapping/model/KotlinInstantiationDelegate.java new file mode 100644 index 0000000000..c3b1bc09ac --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/model/KotlinInstantiationDelegate.java @@ -0,0 +1,250 @@ +/* + * Copyright 2023 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 kotlin.reflect.KFunction; +import kotlin.reflect.KParameter; +import kotlin.reflect.jvm.ReflectJvmMapping; + +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.stream.IntStream; + +import org.springframework.data.mapping.InstanceCreatorMetadata; +import org.springframework.data.mapping.Parameter; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.PreferredConstructor; +import org.springframework.data.mapping.model.KotlinValueUtils.ValueBoxing; +import org.springframework.data.util.ReflectionUtils; +import org.springframework.lang.Nullable; + +/** + * Delegate to allocate instantiation arguments and to resolve the actual constructor to call for inline/value class + * instantiation. This class captures all Kotlin-specific quirks, from default constructor resolution up to mangled + * inline-type handling so instantiator-components can delegate to this class for constructor translation and + * constructor argument extraction. + * + * @author Mark Paluch + * @since 3.1 + */ +class KotlinInstantiationDelegate { + + private final KFunction constructor; + private final List kParameters; + private final List> wrappers = new ArrayList<>(); + private final Constructor constructorToInvoke; + private final boolean hasDefaultConstructorMarker; + + public KotlinInstantiationDelegate(PreferredConstructor preferredConstructor, + Constructor constructorToInvoke) { + + KFunction kotlinConstructor = ReflectJvmMapping.getKotlinFunction(preferredConstructor.getConstructor()); + + if (kotlinConstructor == null) { + throw new IllegalArgumentException( + "No corresponding Kotlin constructor found for " + preferredConstructor.getConstructor()); + } + + this.constructor = kotlinConstructor; + this.kParameters = kotlinConstructor.getParameters(); + this.constructorToInvoke = constructorToInvoke; + this.hasDefaultConstructorMarker = hasDefaultConstructorMarker(constructorToInvoke.getParameters()); + + for (KParameter kParameter : kParameters) { + + ValueBoxing valueBoxing = KotlinValueUtils.getConstructorValueHierarchy(kParameter); + wrappers.add(valueBoxing::wrap); + } + } + + static boolean hasDefaultConstructorMarker(java.lang.reflect.Parameter[] parameters) { + + return parameters.length > 0 + && parameters[parameters.length - 1].getType().getName().equals("kotlin.jvm.internal.DefaultConstructorMarker"); + } + + /** + * @return number of constructor arguments. + */ + public int getRequiredParameterCount() { + + return hasDefaultConstructorMarker ? constructorToInvoke.getParameterCount() + : (constructorToInvoke.getParameterCount() + + KotlinDefaultMask.getMaskCount(constructorToInvoke.getParameterCount()) + + /* DefaultConstructorMarker */1); + } + + /** + * Extract the actual construction arguments for a direct constructor call. + * + * @param params + * @param entityCreator + * @param provider + * @return + * @param

+ */ + public

> Object[] extractInvocationArguments(Object[] params, + @Nullable InstanceCreatorMetadata

entityCreator, ParameterValueProvider

provider) { + + if (entityCreator == null) { + throw new IllegalArgumentException("EntityCreator must not be null"); + } + + int userParameterCount = kParameters.size(); + + List> parameters = entityCreator.getParameters(); + + // Prepare user-space arguments + for (int i = 0; i < userParameterCount; i++) { + + Parameter parameter = parameters.get(i); + params[i] = provider.getParameterValue(parameter); + } + + KotlinDefaultMask defaultMask = KotlinDefaultMask.forConstructor(constructor, it -> { + + int index = kParameters.indexOf(it); + + Parameter parameter = parameters.get(index); + Class type = parameter.getType().getType(); + + if (it.isOptional() && (params[index] == null)) { + if (type.isPrimitive()) { + + // apply primitive defaulting to prevent NPE on primitive downcast + params[index] = ReflectionUtils.getPrimitiveDefault(type); + } + return false; + } + + return true; + }); + + // late rewrapping to indicate potential absence of parameters for defaulting + for (int i = 0; i < userParameterCount; i++) { + params[i] = wrappers.get(i).apply(params[i]); + } + + int[] defaulting = defaultMask.getDefaulting(); + // append nullability masks to creation arguments + for (int i = 0; i < defaulting.length; i++) { + params[userParameterCount + i] = defaulting[i]; + } + + return params; + } + + /** + * Resolves a {@link PreferredConstructor} to a synthetic Kotlin constructor accepting the same user-space parameters + * suffixed by Kotlin-specifics required for defaulting and the {@code kotlin.jvm.internal.DefaultConstructorMarker}. + * + * @since 2.0 + * @author Mark Paluch + */ + + @SuppressWarnings("unchecked") + @Nullable + public static PreferredConstructor resolveKotlinJvmConstructor( + PreferredConstructor preferredConstructor) { + + Constructor hit = doResolveKotlinConstructor(preferredConstructor.getConstructor()); + + if (hit == preferredConstructor.getConstructor()) { + return preferredConstructor; + } + + if (hit != null) { + return new PreferredConstructor<>(hit, preferredConstructor.getParameters().toArray(new Parameter[0])); + } + + return null; + } + + @Nullable + private static Constructor doResolveKotlinConstructor(Constructor detectedConstructor) { + + Class entityType = detectedConstructor.getDeclaringClass(); + Constructor hit = null; + KFunction kotlinFunction = ReflectJvmMapping.getKotlinFunction(detectedConstructor); + + for (Constructor candidate : entityType.getDeclaredConstructors()) { + + // use only synthetic constructors + if (!candidate.isSynthetic()) { + continue; + } + + java.lang.reflect.Parameter[] detectedConstructorParameters = detectedConstructor.getParameters(); + java.lang.reflect.Parameter[] candidateParameters = candidate.getParameters(); + + if (!KotlinInstantiationDelegate.hasDefaultConstructorMarker(detectedConstructorParameters)) { + + // candidates must contain at least two additional parameters (int, DefaultConstructorMarker). + // Number of defaulting masks derives from the original constructor arg count + int syntheticParameters = KotlinDefaultMask.getMaskCount(detectedConstructor.getParameterCount()) + + /* DefaultConstructorMarker */ 1; + + if ((detectedConstructor.getParameterCount() + syntheticParameters) != candidate.getParameterCount()) { + continue; + } + } else { + + int optionalParameterCount = (int) kotlinFunction.getParameters().stream().filter(it -> it.isOptional()) + .count(); + int syntheticParameters = KotlinDefaultMask.getExactMaskCount(optionalParameterCount); + + if ((detectedConstructor.getParameterCount() + syntheticParameters) != candidate.getParameterCount()) { + continue; + } + } + + if (!KotlinInstantiationDelegate.hasDefaultConstructorMarker(candidateParameters)) { + continue; + } + + int userParameterCount = kotlinFunction != null ? kotlinFunction.getParameters().size() + : detectedConstructor.getParameterCount(); + if (parametersMatch(detectedConstructorParameters, candidateParameters, userParameterCount)) { + hit = candidate; + } + } + + return hit; + } + + private static boolean parametersMatch(java.lang.reflect.Parameter[] constructorParameters, + java.lang.reflect.Parameter[] candidateParameters, int userParameterCount) { + + return IntStream.range(0, userParameterCount) + .allMatch(i -> parametersMatch(constructorParameters[i], candidateParameters[i])); + } + + static boolean parametersMatch(java.lang.reflect.Parameter constructorParameter, + java.lang.reflect.Parameter candidateParameter) { + + if (constructorParameter.getType().equals(candidateParameter.getType())) { + return true; + } + + // candidate can be also a wrapper + Class componentOrWrapperType = KotlinValueUtils.getConstructorValueHierarchy(candidateParameter.getType()) + .getActualType(); + + return constructorParameter.getType().equals(componentOrWrapperType); + } +} diff --git a/src/main/java/org/springframework/data/mapping/model/KotlinValueUtils.java b/src/main/java/org/springframework/data/mapping/model/KotlinValueUtils.java new file mode 100644 index 0000000000..02ea07b779 --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/model/KotlinValueUtils.java @@ -0,0 +1,397 @@ +/* + * Copyright 2023 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 kotlin.jvm.JvmClassMappingKt; +import kotlin.jvm.internal.Reflection; +import kotlin.reflect.KCallable; +import kotlin.reflect.KClass; +import kotlin.reflect.KFunction; +import kotlin.reflect.KParameter; +import kotlin.reflect.KProperty; +import kotlin.reflect.KType; +import kotlin.reflect.KTypeParameter; +import kotlin.reflect.jvm.ReflectJvmMapping; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.core.KotlinDetector; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Utilities for Kotlin Value class support. + * + * @author Mark Paluch + * @since 3.2 + */ +class KotlinValueUtils { + + /** + * Returns whether the given {@link KType} is a {@link KClass#isValue() value} class. + * + * @param type the kotlin type to inspect. + * @return {@code true} the type is a value class. + */ + public static boolean isValueClass(KType type) { + return type.getClassifier()instanceof KClass kc && kc.isValue(); + } + + /** + * Returns whether the given class makes uses Kotlin {@link KClass#isValue() value} classes. + * + * @param type the kotlin type to inspect. + * @return {@code true} when at least one property uses Kotlin value classes. + */ + public static boolean hasValueClassProperty(Class type) { + + if (!KotlinDetector.isKotlinType(type)) { + return false; + } + + KClass kotlinClass = JvmClassMappingKt.getKotlinClass(type); + + for (KCallable member : kotlinClass.getMembers()) { + if (member instanceof KProperty kp && isValueClass(kp.getReturnType())) { + return true; + } + } + + return false; + } + + /** + * Creates a value hierarchy across value types from a given {@link KParameter} for COPY method usage. + * + * @param parameter the parameter that references the value class hierarchy. + * @return + */ + public static ValueBoxing getCopyValueHierarchy(KParameter parameter) { + return new ValueBoxing(BoxingRules.COPY, parameter); + } + + /** + * Creates a value hierarchy across value types from a given {@link KParameter} for constructor usage. + * + * @param parameter the parameter that references the value class hierarchy. + * @return the {@link ValueBoxing} type hierarchy. + */ + public static ValueBoxing getConstructorValueHierarchy(KParameter parameter) { + return new ValueBoxing(BoxingRules.CONSTRUCTOR, parameter); + } + + /** + * Creates a value hierarchy across value types from a given {@link KParameter} for constructor usage. + * + * @param cls the entrypoint of the type hierarchy. + * @return the {@link ValueBoxing} type hierarchy. + */ + public static ValueBoxing getConstructorValueHierarchy(Class cls) { + + KClass kotlinClass = JvmClassMappingKt.getKotlinClass(cls); + return new ValueBoxing(BoxingRules.CONSTRUCTOR, Reflection.typeOf(kotlinClass), kotlinClass, false); + } + + /** + * Boxing rules for value class wrappers. + */ + enum BoxingRules { + + /** + * When used in the constructor. Constructor boxing depends on nullability of the declared property, whether the + * component uses defaulting, nullability of the value component and whether the component is a primitive. + */ + CONSTRUCTOR { + @Override + public boolean shouldApplyBoxing(KType type, boolean optional, KParameter component) { + + Type javaType = ReflectJvmMapping.getJavaType(component.getType()); + boolean isPrimitive = javaType instanceof Class c && c.isPrimitive(); + + if (type.isMarkedNullable() || optional) { + return (isPrimitive && type.isMarkedNullable()) || component.getType().isMarkedNullable(); + } + + return false; + } + }, + + /** + * When used in a copy method. Copy method boxing depends on nullability of the declared property, nullability of + * the value component and whether the component is a primitive. + */ + COPY { + @Override + public boolean shouldApplyBoxing(KType type, boolean optional, KParameter component) { + + KType copyType = expandUnderlyingType(type); + + if (copyType.getClassifier()instanceof KClass kc && kc.isValue() || copyType.isMarkedNullable()) { + return true; + } + + return false; + } + + private static KType expandUnderlyingType(KType kotlinType) { + + if (!(kotlinType.getClassifier()instanceof KClass kc) || !kc.isValue()) { + return kotlinType; + } + + List> properties = getProperties(kc); + if (properties.isEmpty()) { + return kotlinType; + } + + KType underlyingType = properties.get(0).getReturnType(); + KType componentType = ValueBoxing.resolveType(underlyingType); + KType expandedUnderlyingType = expandUnderlyingType(componentType); + + if (!kotlinType.isMarkedNullable()) { + return expandedUnderlyingType; + } + + if (expandedUnderlyingType.isMarkedNullable()) { + return kotlinType; + } + + Type javaType = ReflectJvmMapping.getJavaType(expandedUnderlyingType); + boolean isPrimitive = javaType instanceof Class c && c.isPrimitive(); + + if (isPrimitive) { + return kotlinType; + } + + return expandedUnderlyingType; + } + + static List> getProperties(KClass kClass) { + + if (kClass.isValue()) { + + for (KCallable member : kClass.getMembers()) { + if (member instanceof KProperty kp) { + return Collections.singletonList(kp); + } + } + } + + List> properties = new ArrayList<>(); + for (KCallable member : kClass.getMembers()) { + if (member instanceof KProperty kp) { + properties.add(kp); + } + } + + return properties; + } + }; + + public abstract boolean shouldApplyBoxing(KType type, boolean optional, KParameter component); + + } + + /** + * Utility to represent Kotlin value class boxing. + */ + static class ValueBoxing { + + private final KClass kClass; + + private final KFunction wrapperConstructor; + + private final boolean applyBoxing; + + private final @Nullable ValueBoxing next; + + /** + * Creates a new {@link ValueBoxing} for a {@link KParameter}. + * + * @param rules boxing rules to apply. + * @param parameter the copy or constructor parameter. + */ + @SuppressWarnings("ConstantConditions") + private ValueBoxing(BoxingRules rules, KParameter parameter) { + this(rules, parameter.getType(), (KClass) parameter.getType().getClassifier(), parameter.isOptional()); + } + + private ValueBoxing(BoxingRules rules, KType type, KClass kClass, boolean optional) { + + KFunction wrapperConstructor = null; + ValueBoxing next = null; + boolean applyBoxing; + + if (kClass.isValue()) { + + wrapperConstructor = kClass.getConstructors().iterator().next(); + KParameter nested = wrapperConstructor.getParameters().get(0); + KType nestedType = nested.getType(); + + applyBoxing = rules.shouldApplyBoxing(type, optional, nested); + + KClass nestedClass; + + // bound flattening + if (nestedType.getClassifier()instanceof KTypeParameter ktp) { + nestedClass = getUpperBound(ktp); + } else { + nestedClass = (KClass) nestedType.getClassifier(); + } + + Assert.notNull(nestedClass, () -> String.format("Cannot resolve nested class from type %s", nestedType)); + + next = new ValueBoxing(rules, nestedType, nestedClass, nested.isOptional()); + } else { + applyBoxing = false; + } + + this.kClass = kClass; + this.wrapperConstructor = wrapperConstructor; + this.next = next; + this.applyBoxing = applyBoxing; + } + + private static KClass getUpperBound(KTypeParameter typeParameter) { + + for (KType upperBound : typeParameter.getUpperBounds()) { + + if (upperBound.getClassifier()instanceof KClass kc) { + return kc; + } + } + + throw new IllegalArgumentException("No upper bounds found"); + } + + static KType resolveType(KType type) { + + if (type.getClassifier()instanceof KTypeParameter ktp) { + + for (KType upperBound : ktp.getUpperBounds()) { + + if (upperBound.getClassifier()instanceof KClass kc) { + return upperBound; + } + } + } + + return type; + } + + /** + * @return the expanded component type that is used as value. + */ + public Class getActualType() { + + if (isValueClass() && hasNext()) { + return getNext().getActualType(); + } + + return JvmClassMappingKt.getJavaClass(kClass); + } + + /** + * @return the component or wrapper type to be used. + */ + public Class getParameterType() { + + if (hasNext() && getNext().appliesBoxing()) { + return next.getParameterType(); + } + + return JvmClassMappingKt.getJavaClass(kClass); + } + + /** + * @return {@code true} if the value hierarchy applies boxing. + */ + public boolean appliesBoxing() { + return applyBoxing; + } + + public boolean isValueClass() { + return kClass.isValue(); + } + + /** + * @return whether there is another item in the value hierarchy. + */ + public boolean hasNext() { + return next != null; + } + + /** + * Returns the next {@link ValueBoxing} or throws {@link IllegalStateException} if there is no next. Make sure to + * check {@link #hasNext()} prior to calling this method. + * + * @return the next {@link ValueBoxing}. + * @throws IllegalStateException if there is no next item. + */ + public ValueBoxing getNext() { + + if (next == null) { + throw new IllegalStateException("No next ValueBoxing available"); + } + + return next; + } + + /** + * Apply wrapping into the boxing wrapper type if applicable. + * + * @param o + * @return + */ + @Nullable + public Object wrap(@Nullable Object o) { + + if (applyBoxing) { + return o == null || kClass.isInstance(o) ? o : wrapperConstructor.call(next.wrap(o)); + } + + if (hasNext()) { + return next.wrap(o); + } + + return o; + } + + @Override + public String toString() { + + StringBuilder sb = new StringBuilder(); + + ValueBoxing hierarchy = this; + while (hierarchy != null) { + + if (sb.length() != 0) { + sb.append(" -> "); + } + + sb.append(hierarchy.kClass.getSimpleName()); + hierarchy = hierarchy.next; + } + + return sb.toString(); + } + + } + +} diff --git a/src/main/java/org/springframework/data/mapping/model/PreferredConstructorDiscoverer.java b/src/main/java/org/springframework/data/mapping/model/PreferredConstructorDiscoverer.java index 73e99d225b..2d1e321506 100644 --- a/src/main/java/org/springframework/data/mapping/model/PreferredConstructorDiscoverer.java +++ b/src/main/java/org/springframework/data/mapping/model/PreferredConstructorDiscoverer.java @@ -26,6 +26,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.ParameterNameDiscoverer; @@ -179,6 +180,15 @@ > PreferredConstructor discover(TypeInf Class rawOwningType = type.getType(); + // Kotlin can rewrite annotated constructors into synthetic ones so we need to inspect that first. + Optional> first = Arrays.stream(rawOwningType.getDeclaredConstructors()) // + .filter(it -> it.isSynthetic() && AnnotationUtils.findAnnotation(it, PersistenceCreator.class) != null) + .map(it -> buildPreferredConstructor(it, type, entity)).findFirst(); + + if(first.isPresent()){ + return first.get(); + } + return Arrays.stream(rawOwningType.getDeclaredConstructors()) // .filter(it -> !it.isSynthetic()) // Synthetic constructors should not be considered // Explicitly defined creator trumps all diff --git a/src/main/java/org/springframework/data/mapping/model/ReflectionEntityInstantiator.java b/src/main/java/org/springframework/data/mapping/model/ReflectionEntityInstantiator.java index 212145b3c4..ba841daf59 100644 --- a/src/main/java/org/springframework/data/mapping/model/ReflectionEntityInstantiator.java +++ b/src/main/java/org/springframework/data/mapping/model/ReflectionEntityInstantiator.java @@ -16,18 +16,22 @@ package org.springframework.data.mapping.model; import java.lang.reflect.Array; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import org.springframework.beans.BeanInstantiationException; import org.springframework.beans.BeanUtils; +import org.springframework.core.KotlinDetector; import org.springframework.data.mapping.FactoryMethod; import org.springframework.data.mapping.InstanceCreatorMetadata; import org.springframework.data.mapping.Parameter; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.PreferredConstructor; +import org.springframework.data.util.KotlinReflectionUtils; import org.springframework.util.ReflectionUtils; /** @@ -52,6 +56,19 @@ public , P extends PersistentPrope if (creator == null) { return instantiateClass(entity); } + + // workaround as classes using value classes cannot be instantiated through BeanUtils. + if (KotlinDetector.isKotlinReflectPresent() && KotlinReflectionUtils.isSupportedKotlinClass(entity.getType()) + && creator instanceof PreferredConstructor constructor) { + + PreferredConstructor> kotlinJvmConstructor = KotlinInstantiationDelegate + .resolveKotlinJvmConstructor(constructor); + + if (kotlinJvmConstructor != null) { + return instantiateKotlinClass(entity, provider, constructor, kotlinJvmConstructor); + } + } + int parameterCount = creator.getParameterCount(); Object[] params = parameterCount == 0 ? EMPTY_ARGS : new Object[parameterCount]; @@ -81,6 +98,29 @@ public , P extends PersistentPrope } } + @SuppressWarnings("unchecked") + private static , P extends PersistentProperty

> T instantiateKotlinClass( + E entity, ParameterValueProvider

provider, PreferredConstructor preferredConstructor, + PreferredConstructor> kotlinJvmConstructor) { + + Constructor ctor = kotlinJvmConstructor.getConstructor(); + KotlinInstantiationDelegate delegate = new KotlinInstantiationDelegate(preferredConstructor, ctor); + Object[] params = new Object[delegate.getRequiredParameterCount()]; + delegate.extractInvocationArguments(params, entity.getInstanceCreatorMetadata(), provider); + + try { + return (T) ctor.newInstance(params); + } catch (InstantiationException ex) { + throw new BeanInstantiationException(ctor, "Is it an abstract class?", ex); + } catch (IllegalAccessException ex) { + throw new BeanInstantiationException(ctor, "Is the preferredConstructor accessible?", ex); + } catch (IllegalArgumentException ex) { + throw new BeanInstantiationException(ctor, "Illegal arguments for preferredConstructor", ex); + } catch (InvocationTargetException ex) { + throw new BeanInstantiationException(ctor, "Constructor threw exception", ex.getTargetException()); + } + } + @SuppressWarnings("unchecked") private , P extends PersistentProperty

> T instantiateClass( E entity) { diff --git a/src/main/java/org/springframework/data/util/KotlinBeanInfoFactory.java b/src/main/java/org/springframework/data/util/KotlinBeanInfoFactory.java new file mode 100644 index 0000000000..8fc946944a --- /dev/null +++ b/src/main/java/org/springframework/data/util/KotlinBeanInfoFactory.java @@ -0,0 +1,86 @@ +/* + * Copyright 2023 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.util; + +import kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KCallable; +import kotlin.reflect.KClass; +import kotlin.reflect.KMutableProperty; +import kotlin.reflect.KProperty; +import kotlin.reflect.jvm.ReflectJvmMapping; + +import java.beans.BeanDescriptor; +import java.beans.BeanInfo; +import java.beans.IntrospectionException; +import java.beans.PropertyDescriptor; +import java.beans.SimpleBeanInfo; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.BeanInfoFactory; +import org.springframework.core.KotlinDetector; +import org.springframework.core.Ordered; + +/** + * {@link BeanInfoFactory} specific to Kotlin types using Kotlin reflection to determine bean properties. + * + * @author Mark Paluch + * @since 3.2 + * @see JvmClassMappingKt + * @see ReflectJvmMapping + */ +public class KotlinBeanInfoFactory implements BeanInfoFactory, Ordered { + + @Override + public BeanInfo getBeanInfo(Class beanClass) throws IntrospectionException { + + if (!KotlinDetector.isKotlinReflectPresent() || !KotlinDetector.isKotlinType(beanClass)) { + return null; + } + + KClass kotlinClass = JvmClassMappingKt.getKotlinClass(beanClass); + List pds = new ArrayList<>(); + + for (KCallable member : kotlinClass.getMembers()) { + + if (member instanceof KProperty property) { + + Method getter = ReflectJvmMapping.getJavaGetter(property); + Method setter = property instanceof KMutableProperty kmp ? ReflectJvmMapping.getJavaSetter(kmp) : null; + + pds.add(new PropertyDescriptor(property.getName(), getter, setter)); + } + } + return new SimpleBeanInfo() { + @Override + public BeanDescriptor getBeanDescriptor() { + return new BeanDescriptor(beanClass); + } + + @Override + public PropertyDescriptor[] getPropertyDescriptors() { + return pds.toArray(new PropertyDescriptor[0]); + } + }; + } + + @Override + public int getOrder() { + return LOWEST_PRECEDENCE - 10; // leave some space for customizations. + } + +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index fc6274377f..506aaa02df 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -1,3 +1,4 @@ org.springframework.data.web.config.SpringDataJacksonModules=org.springframework.data.web.config.SpringDataJacksonConfiguration org.springframework.data.util.CustomCollectionRegistrar=org.springframework.data.util.CustomCollections.VavrCollections, \ org.springframework.data.util.CustomCollections.EclipseCollections +org.springframework.beans.BeanInfoFactory=org.springframework.data.util.KotlinBeanInfoFactory diff --git a/src/test/java/org/springframework/data/mapping/model/InstanceCreatorMetadataDiscovererUnitTests.java b/src/test/java/org/springframework/data/mapping/model/InstanceCreatorMetadataDiscovererUnitTests.java new file mode 100644 index 0000000000..b9bd483fef --- /dev/null +++ b/src/test/java/org/springframework/data/mapping/model/InstanceCreatorMetadataDiscovererUnitTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023 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 org.junit.jupiter.api.Test; +import org.springframework.data.mapping.InstanceCreatorMetadata; +import org.springframework.data.mapping.model.AbstractPersistentPropertyUnitTests.SamplePersistentProperty; +import org.springframework.data.util.TypeInformation; + +/** + * Unit tests for {@link InstanceCreatorMetadata}. + * + * @author Mark Paluch + */ +class InstanceCreatorMetadataDiscovererUnitTests { + + @Test + void shouldDiscoverConstructorForKotlinValueType() { + + InstanceCreatorMetadata metadata = InstanceCreatorMetadataDiscoverer + .discover(new BasicPersistentEntity( + (TypeInformation) TypeInformation.of(MyNullableValueClass.class))); + + assertThat(metadata).isNotNull(); + assertThat(metadata.hasParameters()).isTrue(); + assertThat(metadata.getParameters().get(0).getName()).isEqualTo("id"); + } +} diff --git a/src/test/java/org/springframework/data/mapping/model/KotlinPropertyAccessorFactoryTests.java b/src/test/java/org/springframework/data/mapping/model/KotlinPropertyAccessorFactoryTests.java new file mode 100644 index 0000000000..0750748b1c --- /dev/null +++ b/src/test/java/org/springframework/data/mapping/model/KotlinPropertyAccessorFactoryTests.java @@ -0,0 +1,273 @@ +/* + * Copyright 2023 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 kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KClass; +import kotlin.reflect.jvm.internal.KotlinReflectionInternalError; + +import java.util.function.Function; +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.BeanUtils; +import org.springframework.data.mapping.Parameter; +import org.springframework.data.mapping.context.SampleMappingContext; +import org.springframework.data.mapping.context.SamplePersistentProperty; + +/** + * Kotlin-specific unit tests for {@link ClassGeneratingPropertyAccessorFactory} and + * {@link BeanWrapperPropertyAccessorFactory} + * + * @author John Blum + * @author Oliver Gierke + * @author Mark Paluch + */ +public class KotlinPropertyAccessorFactoryTests { + + private EntityInstantiators instantiators = new EntityInstantiators(); + private SampleMappingContext mappingContext = new SampleMappingContext(); + + @MethodSource("factories") + @ParameterizedTest // GH-1947 + void shouldGeneratePropertyAccessorForTypeWithValueClass(PersistentPropertyAccessorFactory factory) { + + BasicPersistentEntity entity = mappingContext + .getRequiredPersistentEntity(WithMyValueClass.class); + + Object instance = createInstance(entity, parameter -> "foo"); + + var propertyAccessor = factory.getPropertyAccessor(entity, instance); + var persistentProperty = entity.getRequiredPersistentProperty("id"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(persistentProperty)).isEqualTo("foo"); + + if (factory instanceof BeanWrapperPropertyAccessorFactory) { + + // Sigh. Reflection requires a wrapped value while copy accepts the inlined type. + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> propertyAccessor.setProperty(persistentProperty, "bar")); + return; + } + + propertyAccessor.setProperty(persistentProperty, "bar"); + assertThat(propertyAccessor.getProperty(persistentProperty)).isEqualTo("bar"); + } + + @MethodSource("factories") + @ParameterizedTest // GH-1947 + void shouldGeneratePropertyAccessorForTypeWithNullableValueClass(PersistentPropertyAccessorFactory factory) + throws ReflectiveOperationException { + + BasicPersistentEntity entity = mappingContext + .getRequiredPersistentEntity(WithNestedMyNullableValueClass.class); + + Object instance = createInstance(entity, parameter -> null); + + var propertyAccessor = factory.getPropertyAccessor(entity, instance); + + var expectedDefaultValue = BeanUtils + .instantiateClass(MyNullableValueClass.class.getDeclaredConstructor(String.class), "id"); + var barValue = BeanUtils.instantiateClass(MyNullableValueClass.class.getDeclaredConstructor(String.class), "bar"); + var property = entity.getRequiredPersistentProperty("baz"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(property)).isEqualTo(expectedDefaultValue); + + propertyAccessor.setProperty(property, barValue); + assertThat(propertyAccessor.getProperty(property)).isEqualTo(barValue); + } + + @MethodSource("factories") + @ParameterizedTest // GH-1947 + void shouldGeneratePropertyAccessorForDataClassWithNullableValueClass(PersistentPropertyAccessorFactory factory) + throws ReflectiveOperationException { + + BasicPersistentEntity entity = mappingContext + .getRequiredPersistentEntity(DataClassWithNullableValueClass.class); + + Object instance = createInstance(entity, parameter -> null); + + var propertyAccessor = factory.getPropertyAccessor(entity, instance); + + var expectedDefaultValue = BeanUtils + .instantiateClass(MyNullableValueClass.class.getDeclaredConstructor(String.class), "id"); + var barValue = BeanUtils.instantiateClass(MyNullableValueClass.class.getDeclaredConstructor(String.class), "bar"); + var fullyNullable = entity.getRequiredPersistentProperty("fullyNullable"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(fullyNullable)).isEqualTo(expectedDefaultValue); + + if (factory instanceof BeanWrapperPropertyAccessorFactory) { + + // see https://youtrack.jetbrains.com/issue/KT-57357 + assertThatExceptionOfType(KotlinReflectionInternalError.class) + .isThrownBy(() -> propertyAccessor.setProperty(fullyNullable, barValue)) + .withMessageContaining("This callable does not support a default call"); + return; + } + + propertyAccessor.setProperty(fullyNullable, barValue); + assertThat(propertyAccessor.getProperty(fullyNullable)).isEqualTo(barValue); + + var innerNullable = entity.getRequiredPersistentProperty("innerNullable"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(innerNullable)).isEqualTo("id"); + + propertyAccessor.setProperty(innerNullable, "bar"); + assertThat(propertyAccessor.getProperty(innerNullable)).isEqualTo("bar"); + } + + @MethodSource("factories") + @ParameterizedTest // GH-1947 + void nestedNullablePropertiesShouldBeSetCorrectly(PersistentPropertyAccessorFactory factory) { + + BasicPersistentEntity entity = mappingContext + .getRequiredPersistentEntity(DataClassWithNestedNullableValueClass.class); + + Object instance = createInstance(entity, parameter -> null); + + var propertyAccessor = factory.getPropertyAccessor(entity, instance); + var nullableNestedNullable = entity.getRequiredPersistentProperty("nullableNestedNullable"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(nullableNestedNullable)).isNull(); + + KClass nested = JvmClassMappingKt.getKotlinClass(MyNestedNullableValueClass.class); + KClass nullable = JvmClassMappingKt.getKotlinClass(MyNullableValueClass.class); + + MyNullableValueClass inner = nullable.getConstructors().iterator().next().call("new-value"); + MyNestedNullableValueClass outer = nested.getConstructors().iterator().next().call(inner); + + if (factory instanceof BeanWrapperPropertyAccessorFactory) { + + // see https://youtrack.jetbrains.com/issue/KT-57357 + assertThatExceptionOfType(KotlinReflectionInternalError.class) + .isThrownBy(() -> propertyAccessor.setProperty(nullableNestedNullable, outer)) + .withMessageContaining("This callable does not support a default call"); + return; + } + + propertyAccessor.setProperty(nullableNestedNullable, outer); + assertThat(propertyAccessor.getProperty(nullableNestedNullable)).isInstanceOf(MyNestedNullableValueClass.class) + .hasToString("MyNestedNullableValueClass(id=MyNullableValueClass(id=new-value))"); + + var nestedNullable = entity.getRequiredPersistentProperty("nestedNullable"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(nestedNullable)).isNull(); + + propertyAccessor.setProperty(nestedNullable, "inner"); + assertThat(propertyAccessor.getProperty(nestedNullable)).isEqualTo("inner"); + } + + @MethodSource("factories") + @ParameterizedTest // GH-1947 + void genericInlineClassesShouldWork(PersistentPropertyAccessorFactory factory) { + + BasicPersistentEntity entity = mappingContext + .getRequiredPersistentEntity(WithGenericValue.class); + + KClass genericClass = JvmClassMappingKt.getKotlinClass(MyGenericValue.class); + MyGenericValue inner = genericClass.getConstructors().iterator().next().call("initial-value"); + MyGenericValue outer = genericClass.getConstructors().iterator().next().call(inner); + + MyGenericValue newInner = genericClass.getConstructors().iterator().next().call("new-value"); + MyGenericValue newOuter = genericClass.getConstructors().iterator().next().call(newInner); + + Object instance = createInstance(entity, parameter -> parameter.getName().equals("recursive") ? outer : "aaa"); + + var propertyAccessor = factory.getPropertyAccessor(entity, instance); + var string = entity.getRequiredPersistentProperty("string"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(string)).isEqualTo("aaa"); + + if (factory instanceof BeanWrapperPropertyAccessorFactory) { + + // see https://youtrack.jetbrains.com/issue/KT-57357 + assertThatExceptionOfType(KotlinReflectionInternalError.class) + .isThrownBy(() -> propertyAccessor.setProperty(string, "string")) + .withMessageContaining("This callable does not support a default call"); + return; + } + + propertyAccessor.setProperty(string, "string"); + assertThat(propertyAccessor.getProperty(string)).isEqualTo("string"); + + var charseq = entity.getRequiredPersistentProperty("charseq"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(charseq)).isEqualTo("aaa"); + propertyAccessor.setProperty(charseq, "charseq"); + assertThat(propertyAccessor.getProperty(charseq)).isEqualTo("charseq"); + + var recursive = entity.getRequiredPersistentProperty("recursive"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(recursive)).isEqualTo(outer); + propertyAccessor.setProperty(recursive, newOuter); + + // huh? why is that? + assertThat(propertyAccessor.getProperty(recursive)).isEqualTo(newInner); + } + + @MethodSource("factories") + @ParameterizedTest // GH-1947 + void genericNullableInlineClassesShouldWork(PersistentPropertyAccessorFactory factory) { + + BasicPersistentEntity entity = mappingContext + .getRequiredPersistentEntity(WithGenericNullableValue.class); + + KClass genericClass = JvmClassMappingKt.getKotlinClass(MyGenericValue.class); + MyGenericValue inner = genericClass.getConstructors().iterator().next().call("initial-value"); + MyGenericValue outer = genericClass.getConstructors().iterator().next().call(inner); + + MyGenericValue newInner = genericClass.getConstructors().iterator().next().call("new-value"); + MyGenericValue newOuter = genericClass.getConstructors().iterator().next().call(newInner); + + Object instance = createInstance(entity, parameter -> outer); + + var propertyAccessor = factory.getPropertyAccessor(entity, instance); + var recursive = entity.getRequiredPersistentProperty("recursive"); + + assertThat(propertyAccessor).isNotNull(); + assertThat(propertyAccessor.getProperty(recursive)).isEqualTo(outer); + propertyAccessor.setProperty(recursive, newOuter); + assertThat(propertyAccessor.getProperty(recursive)).isEqualTo(newOuter); + } + + private Object createInstance(BasicPersistentEntity entity, + Function, Object> parameterProvider) { + return instantiators.getInstantiatorFor(entity).createInstance(entity, + new ParameterValueProvider() { + @Override + public T getParameterValue(Parameter parameter) { + return (T) parameterProvider.apply(parameter); + } + }); + } + + static Stream factories() { + return Stream.of(new ClassGeneratingPropertyAccessorFactory(), BeanWrapperPropertyAccessorFactory.INSTANCE); + } + +} diff --git a/src/test/java/org/springframework/data/mapping/model/ReflectionEntityInstantiatorUnitTests.java b/src/test/java/org/springframework/data/mapping/model/ReflectionEntityInstantiatorUnitTests.java index 301139c0bb..8f7653b1b8 100755 --- a/src/test/java/org/springframework/data/mapping/model/ReflectionEntityInstantiatorUnitTests.java +++ b/src/test/java/org/springframework/data/mapping/model/ReflectionEntityInstantiatorUnitTests.java @@ -68,6 +68,7 @@ void instantiatesTypeWithPreferredConstructorUsingParameterValueProvider() { PreferredConstructor constructor = PreferredConstructorDiscoverer.discover(Foo.class); + doReturn(Foo.class).when(entity).getType(); doReturn(constructor).when(entity).getInstanceCreatorMetadata(); var instance = INSTANCE.createInstance(entity, provider); @@ -78,7 +79,6 @@ void instantiatesTypeWithPreferredConstructorUsingParameterValueProvider() { } @Test // DATACMNS-300 - @SuppressWarnings({ "unchecked", "rawtypes" }) void throwsExceptionOnBeanInstantiationException() { doReturn(PersistentEntity.class).when(entity).getType(); diff --git a/src/test/kotlin/org/springframework/data/mapping/model/InlineClasses.kt b/src/test/kotlin/org/springframework/data/mapping/model/InlineClasses.kt new file mode 100644 index 0000000000..1e555c9f60 --- /dev/null +++ b/src/test/kotlin/org/springframework/data/mapping/model/InlineClasses.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2023 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 org.springframework.data.annotation.PersistenceCreator +import java.time.LocalDate + +/** + * @author Mark Paluch + */ +@JvmInline +value class MyValueClass(val id: String) + +/** + * Simple class. Value class is flattened into `String`. However, `copy` requires a boxed value when called via reflection. + */ +data class WithMyValueClass(val id: MyValueClass) { + + // ByteCode explanation + + // --------- + // default constructor, detected by Discoverers.KOTLIN + // private WithMyValueClass(java.lang.String arg0) {} + // --------- + + // --------- + // synthetic constructor that we actually want to use + // public synthetic WithMyValueClass(java.lang.String arg0, kotlin.jvm.internal.DefaultConstructorMarker arg1) {} + // --------- + + // --------- + // public static WithMyValueClass copy-R7yrDNU$default(WithMyValueClass var0, String var1, int var2, Object var3) { + // --------- +} + +@JvmInline +value class MyNullableValueClass(val id: String? = "id") + +@JvmInline +value class MyNestedNullableValueClass(val id: MyNullableValueClass) + +@JvmInline +value class MyGenericValue(val id: T) + +@JvmInline +value class MyGenericBoundValue(val id: T) + +data class WithGenericValue( + // ctor: WithGenericValue(CharSequence string, CharSequence charseq, Object recursive, DefaultConstructorMarker $constructor_marker) + val string: MyGenericBoundValue, + val charseq: MyGenericBoundValue, + val recursive: MyGenericValue> + + // copy: copy-XQwFSJ0$default(WithGenericValue var0, CharSequence var1, CharSequence var2, MyGenericValue var3, int var4, Object var5) { +) + +data class WithGenericNullableValue(val recursive: MyGenericValue>?) + +@JvmInline +value class PrimitiveNullableValue(val id: Int?) + +data class WithDefaultPrimitiveValue( + val pvd: PrimitiveValue = PrimitiveValue(1) +) + +/** + * Copy method for nullable value component type uses wrappers while constructor uses the component type. + */ +data class WithPrimitiveNullableValue( + // ctor: public WithPrimitiveNullableValue(Integer nv, PrimitiveNullableValue nvn, Integer nvd, PrimitiveNullableValue nvdn, DefaultConstructorMarker $constructor_marker) { + val nv: PrimitiveNullableValue, + val nvn: PrimitiveNullableValue?, + val nvd: PrimitiveNullableValue = PrimitiveNullableValue(1), + val nvdn: PrimitiveNullableValue? = PrimitiveNullableValue(1), + + // copy: copy-lcs_1S0$default(WithPrimitiveNullableValue var0, PrimitiveNullableValue var1, PrimitiveNullableValue var2, PrimitiveNullableValue var3, PrimitiveNullableValue var4, int var5, Object var6) +) + +@JvmInline +value class PrimitiveValue(val id: Int) + +data class WithPrimitiveValue( + + // ctor: int,org.springframework.data.mapping.model.KotlinValueUtilsUnitTests$PrimitiveValue,int,org.springframework.data.mapping.model.KotlinValueUtilsUnitTests$PrimitiveValue,kotlin.jvm.internal.DefaultConstructorMarker + val nv: PrimitiveValue, + val nvn: PrimitiveValue?, + val nvd: PrimitiveValue = PrimitiveValue(1), + val nvdn: PrimitiveValue? = PrimitiveValue(1), + + // copy: copy-XQwFSJ0$default(WithPrimitiveValue var0, int var1, PrimitiveValue var2, int var3, PrimitiveValue var4, int var5, Object var6) +) + +@JvmInline +value class StringValue(val id: String) + +data class WithStringValue( + + // ctor: WithStringValue(String nv, String nvn, String nvd, String nvdn) + val nv: StringValue, + val nvn: StringValue?, + val nvd: StringValue = StringValue("1"), + val nvdn: StringValue? = StringValue("1"), + + // copy: copy-QB2wzyg$default(WithStringValue var0, String var1, String var2, String var3, String var4, int var5, Object var6) +) + + +data class Inner(val id: LocalDate, val foo: String) + +@JvmInline +value class Outer(val id: Inner = Inner(LocalDate.MAX, "")) + +data class WithCustomInner( + + // ctor: private WithCustomInner(Inner nv) + val nv: Outer + + // copy: copy(org.springframework.data.mapping.model.WithCustomInner,org.springframework.data.mapping.model.Inner,int,java.lang.Object) +) + +class WithNestedMyNullableValueClass( + var id: MyNestedNullableValueClass? = MyNestedNullableValueClass( + MyNullableValueClass("foo") + ), var baz: MyNullableValueClass? = MyNullableValueClass("id") + + // ByteCode explanation + + // --------- + // private WithNestedMyNullableValueClass(MyNestedNullableValueClass id, MyNullableValueClass baz) {} + // --------- + + // --------- + // default constructor, detected by Discoverers.KOTLIN + // note that these constructors use boxed variants ("MyNestedNullableValueClass") + + // public synthetic WithNestedMyNullableValueClass(MyNestedNullableValueClass id, MyNullableValueClass baz, DefaultConstructorMarker $constructor_marker) {} + // --------- + + // --------- + // translated by KotlinInstantiationDelegate.resolveKotlinJvmConstructor as we require a constructor that we can use to + // provide the defaulting mask. This constructor only gets generated when parameters are nullable, otherwise + // Kotlin doesn't create this constructor + + // public synthetic WithNestedMyNullableValueClass(MyNestedNullableValueClass var1, MyNullableValueClass var2, int var3, DefaultConstructorMarker var4) {} + // --------- +) + +/** + * Nullability on the domain class means that public and static copy methods uses both the boxed type. + */ +data class DataClassWithNullableValueClass( + + val fullyNullable: MyNullableValueClass? = MyNullableValueClass("id"), + val innerNullable: MyNullableValueClass = MyNullableValueClass("id") + + // ByteCode + + // private final MyNullableValueClass fullyNullable; + // private final String innerNullable; + + // --------- + // private DataClassWithNullableValueClass(MyNullableValueClass fullyNullable, String innerNullable) + // --------- + + // --------- + // public DataClassWithNullableValueClass(MyNullableValueClass var1, MyNullableValueClass var2, int var3, DefaultConstructorMarker var4) { + // --------- + + // --------- + // public final DataClassWithNullableValueClass copy-bwh045w (@Nullable MyNullableValueClass fullyNullable, @NotNull String innerNullable) { + // --------- + + // --------- + // public static DataClassWithNullableValueClass copy-bwh045w$default(DataClassWithNullableValueClass var0, MyNullableValueClass var1, MyNullableValueClass var2, int var3, Object var4) {} + // --------- +) + +/** + * Nullability on a nested level (domain class property isn't nullable, the inner value in the value class is) means that public copy method uses the value component type while the static copy method uses the boxed type. + * + * This creates a skew in getter vs. setter. The setter would be required to set the boxed value while the getter returns plain String. Sigh. + */ +data class DataClassWithNestedNullableValueClass( + val nullableNestedNullable: MyNestedNullableValueClass?, + val nestedNullable: MyNestedNullableValueClass + + // ByteCode + + // --------- + // public DataClassWithNestedNullableValueClass(MyNestedNullableValueClass nullableNestedNullable, String nestedNullable, DefaultConstructorMarker $constructor_marker) { + // --------- + + // --------- + // public final DataClassWithNestedNullableValueClass copy-W2GYjxM(@Nullable MyNestedNullableValueClass nullableNestedNullable, @NotNull String nestedNullable) { … } + // --------- + + // --------- + // public static DataClassWithNestedNullableValueClass copy-W2GYjxM$default(DataClassWithNestedNullableValueClass var0, MyNestedNullableValueClass var1, MyNestedNullableValueClass var2, int var3, Object var4) { + // --------- +) + +class WithValueClassPreferredConstructor( + val id: MyNestedNullableValueClass? = MyNestedNullableValueClass( + MyNullableValueClass("foo") + ), val baz: MyNullableValueClass? = MyNullableValueClass("id") +) { + + @PersistenceCreator + constructor( + a: String, id: MyNestedNullableValueClass? = MyNestedNullableValueClass( + MyNullableValueClass("foo") + ) + ) : this(id, MyNullableValueClass(a + "-pref")) { + + } + + // ByteCode explanation + + // --------- + // private WithPreferredConstructor(MyNestedNullableValueClass id, MyNullableValueClass baz) {} + // --------- + + // --------- + // public WithPreferredConstructor(MyNestedNullableValueClass var1, MyNullableValueClass var2, int var3, DefaultConstructorMarker var4) {} + // --------- + + // --------- + // private WithPreferredConstructor(String a, MyNestedNullableValueClass id) {} + // --------- + + // --------- + // this is the one we need to invoke to pass on the defaulting mask + // public synthetic WithPreferredConstructor(String var1, MyNestedNullableValueClass var2, int var3, DefaultConstructorMarker var4) {} + // --------- + + // --------- + // public synthetic WithPreferredConstructor(MyNestedNullableValueClass id, MyNullableValueClass baz, DefaultConstructorMarker $constructor_marker) { + // --------- + + // --------- + // annotated constructor, detected by Discoverers.KOTLIN + // @PersistenceCreator + // public WithPreferredConstructor(String a, MyNestedNullableValueClass id, DefaultConstructorMarker $constructor_marker) { + // --------- + +} + diff --git a/src/test/kotlin/org/springframework/data/mapping/model/KotlinClassGeneratingEntityInstantiatorUnitTests.kt b/src/test/kotlin/org/springframework/data/mapping/model/KotlinClassGeneratingEntityInstantiatorUnitTests.kt index 4d03649bf7..5eb67944db 100644 --- a/src/test/kotlin/org/springframework/data/mapping/model/KotlinClassGeneratingEntityInstantiatorUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/mapping/model/KotlinClassGeneratingEntityInstantiatorUnitTests.kt @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test import org.springframework.data.annotation.PersistenceConstructor import org.springframework.data.mapping.PersistentEntity import org.springframework.data.mapping.context.SamplePersistentProperty +import kotlin.reflect.KClass /** * Unit tests for [KotlinClassGeneratingEntityInstantiator] creating instances using Kotlin data classes. @@ -135,7 +136,8 @@ class KotlinClassGeneratingEntityInstantiatorUnitTests { every { entity.type } returns constructor!!.constructor.declaringClass every { entity.typeInformation } returns mockk() - val instance: WithPrimitiveDefaulting = KotlinClassGeneratingEntityInstantiator().createInstance(entity, provider) + val instance: WithPrimitiveDefaulting = + KotlinClassGeneratingEntityInstantiator().createInstance(entity, provider) assertThat(instance.aByte).isEqualTo(0) assertThat(instance.aShort).isEqualTo(0) @@ -150,43 +152,124 @@ class KotlinClassGeneratingEntityInstantiatorUnitTests { @Test // DATACMNS-1338 fun `should create instance using @PersistenceConstructor`() { - val entity = mockk>() + every { provider.getParameterValue(any()) } returns "Walter" + val instance = construct(CustomUser::class) + + assertThat(instance.id).isEqualTo("Walter") + } + + @Test + fun `should use default constructor for types using value class`() { + + every { provider.getParameterValue(any()) } returns "hello" + val instance = construct(WithMyValueClass::class) + + assertThat(instance.id.id).isEqualTo("hello") + } + + @Test + fun `should use default constructor for types using nullable value class`() { + + every { provider.getParameterValue(any()) } returns null + + val instance = construct(WithNestedMyNullableValueClass::class) + + assertThat(instance.id?.id?.id).isEqualTo("foo") + assertThat(instance.baz?.id).isEqualTo("id") + } + + @Test // GH-1947 + fun `should use annotated constructor for types using nullable value class`() { + + every { provider.getParameterValue(any()) }.returnsMany("Walter", null) + + val instance = construct(WithValueClassPreferredConstructor::class) + + assertThat(instance.id?.id?.id).isEqualTo("foo") + assertThat(instance.baz?.id).isEqualTo("Walter-pref") + } + + @Test // GH-1947 + fun `should instantiate type with value class defaulting`() { + + every { provider.getParameterValue(any()) }.returns(1) + + val instance = construct(WithDefaultPrimitiveValue::class) + + assertThat(instance.pvd.id).isEqualTo(1) + } + + @Test // GH-1947 + fun `should instantiate type with nullable primitive value class defaulting`() { + + every { provider.getParameterValue(any()) }.returnsMany( + 1, + PrimitiveNullableValue(2), + 3, + PrimitiveNullableValue(4) + ) + + val instance = construct(WithPrimitiveNullableValue::class) + + assertThat(instance.nv.id).isEqualTo(1) + assertThat(instance.nvn!!.id).isEqualTo(2) + assertThat(instance.nvd.id).isEqualTo(3) + assertThat(instance.nvdn!!.id).isEqualTo(4) + } + + @Test // GH-1947 + fun `should instantiate type with nullable value class defaulting`() { + + // String nv, String nvn, String nvd, String nvdn, LocalDate dnv, LocalDate dnvn, LocalDate dnvd, LocalDate dnvdn + every { provider.getParameterValue(any()) }.returnsMany("1", "2", "3", "4") + + val instance = construct(WithStringValue::class) + + assertThat(instance.nv.id).isEqualTo("1") + assertThat(instance.nvn!!.id).isEqualTo("2") + assertThat(instance.nvd.id).isEqualTo("3") + assertThat(instance.nvdn!!.id).isEqualTo("4") + } + + private fun construct(typeToCreate: KClass): T { + + val entity = + mockk>() val constructor = - PreferredConstructorDiscoverer.discover( - CustomUser::class.java + PreferredConstructorDiscoverer.discover( + typeToCreate.java ) - every { provider.getParameterValue(any()) } returns "Walter" every { entity.instanceCreatorMetadata } returns constructor every { entity.type } returns constructor!!.constructor.declaringClass every { entity.typeInformation } returns mockk() - val instance: CustomUser = - KotlinClassGeneratingEntityInstantiator().createInstance(entity, provider) - - assertThat(instance.id).isEqualTo("Walter") + return KotlinClassGeneratingEntityInstantiator().createInstance(entity, provider) } data class Contact(val firstname: String, val lastname: String) - data class ContactWithDefaulting(val prop0: String, val prop1: String = "White", val prop2: String, - val prop3: String = "White", val prop4: String = "White", val prop5: String = "White", - val prop6: String = "White", val prop7: String = "White", val prop8: String = "White", - val prop9: String = "White", val prop10: String = "White", val prop11: String = "White", - val prop12: String = "White", val prop13: String = "White", val prop14: String = "White", - val prop15: String = "White", val prop16: String = "White", val prop17: String = "White", - val prop18: String = "White", val prop19: String = "White", val prop20: String = "White", - val prop21: String = "White", val prop22: String = "White", val prop23: String = "White", - val prop24: String = "White", val prop25: String = "White", val prop26: String = "White", - val prop27: String = "White", val prop28: String = "White", val prop29: String = "White", - val prop30: String = "White", val prop31: String = "White", val prop32: String = "White", - val prop33: String, val prop34: String = "White" + data class ContactWithDefaulting( + val prop0: String, val prop1: String = "White", val prop2: String, + val prop3: String = "White", val prop4: String = "White", val prop5: String = "White", + val prop6: String = "White", val prop7: String = "White", val prop8: String = "White", + val prop9: String = "White", val prop10: String = "White", val prop11: String = "White", + val prop12: String = "White", val prop13: String = "White", val prop14: String = "White", + val prop15: String = "White", val prop16: String = "White", val prop17: String = "White", + val prop18: String = "White", val prop19: String = "White", val prop20: String = "White", + val prop21: String = "White", val prop22: String = "White", val prop23: String = "White", + val prop24: String = "White", val prop25: String = "White", val prop26: String = "White", + val prop27: String = "White", val prop28: String = "White", val prop29: String = "White", + val prop30: String = "White", val prop31: String = "White", val prop32: String = "White", + val prop33: String, val prop34: String = "White" ) data class WithBoolean(val state: Boolean) - data class WithPrimitiveDefaulting(val aByte: Byte = 0, val aShort: Short = 0, val anInt: Int = 0, val aLong: Long = 0L, - val aFloat: Float = 0.0f, val aDouble: Double = 0.0, val aChar: Char = 'a', val aBool: Boolean = true) + data class WithPrimitiveDefaulting( + val aByte: Byte = 0, val aShort: Short = 0, val anInt: Int = 0, val aLong: Long = 0L, + val aFloat: Float = 0.0f, val aDouble: Double = 0.0, val aChar: Char = 'a', val aBool: Boolean = true + ) data class ContactWithPersistenceConstructor(val firstname: String, val lastname: String) { @@ -195,14 +278,15 @@ class KotlinClassGeneratingEntityInstantiatorUnitTests { } data class CustomUser( - var id: String? = null, + var id: String? = null, - var organisations: MutableList = mutableListOf() + var organisations: MutableList = mutableListOf() ) { @PersistenceConstructor constructor(id: String?) : this(id, mutableListOf()) } data class Organisation(var id: String? = null) + } diff --git a/src/test/kotlin/org/springframework/data/mapping/model/KotlinValueUtilsUnitTests.kt b/src/test/kotlin/org/springframework/data/mapping/model/KotlinValueUtilsUnitTests.kt new file mode 100644 index 0000000000..79efef7acd --- /dev/null +++ b/src/test/kotlin/org/springframework/data/mapping/model/KotlinValueUtilsUnitTests.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2023 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 org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.reflect.KParameter +import kotlin.reflect.jvm.javaConstructor + +/** + * Unit tests for [KotlinValueUtils]. + * @author Mark Paluch + */ +class KotlinValueUtilsUnitTests { + + @Test // GH-1947 + internal fun considersCustomInnerConstructorRules() { + + val ctor = WithCustomInner::class.constructors.iterator().next(); + assertThat(ctor.javaConstructor.toString()).contains("(org.springframework.data.mapping.model.Inner,kotlin.jvm.internal.DefaultConstructorMarker)") + + val vh = KotlinValueUtils.getConstructorValueHierarchy( + ctor.parameters.iterator().next() + ) + + assertThat(vh.actualType).isEqualTo(Inner::class.java) + assertThat(vh.appliesBoxing()).isFalse + } + + @Test // GH-1947 + internal fun considersCustomInnerCopyRules() { + + val copy = KotlinCopyMethod.findCopyMethod(WithCustomInner::class.java).get(); + assertThat(copy.syntheticCopyMethod.toString()).contains("(org.springframework.data.mapping.model.WithCustomInner,org.springframework.data.mapping.model.Inner,int,java.lang.Object)") + + val vh = KotlinValueUtils.getCopyValueHierarchy( + copy.copyFunction.parameters.get(1) + ) + + assertThat(vh.parameterType).isEqualTo(Outer::class.java) + assertThat(vh.actualType).isEqualTo(Inner::class.java) + assertThat(vh.appliesBoxing()).isFalse + } + + @Test // GH-1947 + internal fun inlinesTypesToStringConstructorRules() { + + val ctor = WithStringValue::class.constructors.iterator().next(); + assertThat(ctor.javaConstructor.toString()).contains("(java.lang.String,java.lang.String,java.lang.String,java.lang.String,kotlin.jvm.internal.DefaultConstructorMarker)") + + for (kParam in ctor.parameters) { + + val vh = KotlinValueUtils.getConstructorValueHierarchy(kParam) + + assertThat(vh.actualType).isEqualTo(String::class.java) + assertThat(vh.appliesBoxing()).describedAs(kParam.toString()).isFalse + assertThat(vh.wrap("1")).describedAs(kParam.toString()).isEqualTo("1") + } + } + + @Test // GH-1947 + internal fun inlinesTypesToStringCopyRules() { + + val copy = KotlinCopyMethod.findCopyMethod(WithStringValue::class.java).get(); + assertThat(copy.syntheticCopyMethod.toString()).contains("(org.springframework.data.mapping.model.WithStringValue,java.lang.String,java.lang.String,java.lang.String,java.lang.String,int,java.lang.Object)") + + for (kParam in copy.copyFunction.parameters) { + + if (kParam.kind == KParameter.Kind.INSTANCE) { + continue + } + + val vh = KotlinValueUtils.getCopyValueHierarchy(kParam) + + assertThat(vh.actualType).isEqualTo(String::class.java) + assertThat(vh.appliesBoxing()).describedAs(kParam.toString()).isFalse + assertThat(vh.wrap("1")).describedAs(kParam.toString()).isEqualTo("1") + } + } + + @Test // GH-1947 + internal fun inlinesTypesToIntConstructorRules() { + + val ctor = WithPrimitiveValue::class.constructors.iterator().next(); + assertThat(ctor.javaConstructor.toString()).contains("(int,org.springframework.data.mapping.model.PrimitiveValue,int,org.springframework.data.mapping.model.PrimitiveValue,kotlin.jvm.internal.DefaultConstructorMarker)") + + val iterator = ctor.parameters.iterator() + + val nv = KotlinValueUtils.getConstructorValueHierarchy(iterator.next()); + assertThat(nv.actualType).isEqualTo(Int::class.java) + assertThat(nv.appliesBoxing()).isFalse + + val nvn = KotlinValueUtils.getConstructorValueHierarchy(iterator.next()); + assertThat(nvn.actualType).isEqualTo(Int::class.java) + assertThat(nvn.appliesBoxing()).isTrue + assertThat(nvn.parameterType).isEqualTo(PrimitiveValue::class.java) + + val nvd = KotlinValueUtils.getConstructorValueHierarchy(iterator.next()); + assertThat(nvd.actualType).isEqualTo(Int::class.java) + assertThat(nvd.appliesBoxing()).isFalse + + val nvdn = KotlinValueUtils.getConstructorValueHierarchy(iterator.next()); + assertThat(nvdn.actualType).isEqualTo(Int::class.java) + assertThat(nvn.parameterType).isEqualTo(PrimitiveValue::class.java) + assertThat(nvdn.appliesBoxing()).isTrue + } + + @Test // GH-1947 + internal fun inlinesTypesToIntCopyRules() { + + val copy = KotlinCopyMethod.findCopyMethod(WithPrimitiveValue::class.java).get(); + assertThat(copy.syntheticCopyMethod.toString()).contains("(org.springframework.data.mapping.model.WithPrimitiveValue,int,org.springframework.data.mapping.model.PrimitiveValue,int,org.springframework.data.mapping.model.PrimitiveValue,int,java.lang.Object)") + + val parameters = copy.copyFunction.parameters; + + val nv = KotlinValueUtils.getConstructorValueHierarchy(parameters[1]); + assertThat(nv.actualType).isEqualTo(Int::class.java) + assertThat(nv.appliesBoxing()).isFalse + + val nvn = KotlinValueUtils.getConstructorValueHierarchy(parameters[2]); + assertThat(nvn.actualType).isEqualTo(Int::class.java) + assertThat(nvn.appliesBoxing()).isTrue + assertThat(nvn.parameterType).isEqualTo(PrimitiveValue::class.java) + + val nvd = KotlinValueUtils.getConstructorValueHierarchy(parameters[3]); + assertThat(nvd.actualType).isEqualTo(Int::class.java) + assertThat(nvd.appliesBoxing()).isFalse + + val nvdn = KotlinValueUtils.getConstructorValueHierarchy(parameters[4]); + assertThat(nvdn.actualType).isEqualTo(Int::class.java) + assertThat(nvn.parameterType).isEqualTo(PrimitiveValue::class.java) + assertThat(nvdn.appliesBoxing()).isTrue + } + + @Test // GH-1947 + internal fun inlinesGenericTypesConstructorRules() { + + val ctor = WithGenericValue::class.constructors.iterator().next(); + assertThat(ctor.javaConstructor.toString()).contains("(java.lang.CharSequence,java.lang.CharSequence,java.lang.Object,kotlin.jvm.internal.DefaultConstructorMarker)") + + val iterator = ctor.parameters.iterator() + + val string = KotlinValueUtils.getConstructorValueHierarchy(iterator.next()); + assertThat(string.actualType).isEqualTo(CharSequence::class.java) + assertThat(string.appliesBoxing()).isFalse + + val charseq = KotlinValueUtils.getConstructorValueHierarchy(iterator.next()); + assertThat(charseq.actualType).isEqualTo(CharSequence::class.java) + assertThat(charseq.appliesBoxing()).isFalse + + val recursive = KotlinValueUtils.getConstructorValueHierarchy(iterator.next()); + assertThat(recursive.actualType).isEqualTo(Any::class.java) + assertThat(recursive.appliesBoxing()).isFalse + } + + @Test // GH-1947 + internal fun inlinesGenericTypesCopyRules() { + + val copy = KotlinCopyMethod.findCopyMethod(WithGenericValue::class.java).get(); + assertThat(copy.syntheticCopyMethod.toString()).contains("(org.springframework.data.mapping.model.WithGenericValue,java.lang.CharSequence,java.lang.CharSequence,org.springframework.data.mapping.model.MyGenericValue,int,java.lang.Object)") + + val parameters = copy.copyFunction.parameters; + + val string = KotlinValueUtils.getConstructorValueHierarchy(parameters[1]); + assertThat(string.actualType).isEqualTo(CharSequence::class.java) + assertThat(string.appliesBoxing()).isFalse + + val charseq = KotlinValueUtils.getConstructorValueHierarchy(parameters[2]); + assertThat(charseq.actualType).isEqualTo(CharSequence::class.java) + assertThat(charseq.appliesBoxing()).isFalse + + val recursive = KotlinValueUtils.getConstructorValueHierarchy(parameters[3]); + assertThat(recursive.actualType).isEqualTo(Object::class.java) + assertThat(recursive.parameterType).isEqualTo(MyGenericValue::class.java) + assertThat(recursive.appliesBoxing()).isFalse + } + +} + diff --git a/src/test/kotlin/org/springframework/data/mapping/model/PreferredConstructorDiscovererUnitTests.kt b/src/test/kotlin/org/springframework/data/mapping/model/PreferredConstructorDiscovererUnitTests.kt index e5b9e43ffa..4d091d91c7 100644 --- a/src/test/kotlin/org/springframework/data/mapping/model/PreferredConstructorDiscovererUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/mapping/model/PreferredConstructorDiscovererUnitTests.kt @@ -38,7 +38,8 @@ class PreferredConstructorDiscovererUnitTests { @Test // DATACMNS-1126 fun `should reject two constructors`() { - val constructor = PreferredConstructorDiscoverer.discover(TwoConstructors::class.java) + val constructor = + PreferredConstructorDiscoverer.discover(TwoConstructors::class.java) assertThat(constructor!!.parameters.size).isEqualTo(1) } @@ -46,7 +47,10 @@ class PreferredConstructorDiscovererUnitTests { @Test // DATACMNS-1170 fun `should fall back to no-args constructor if no primary constructor available`() { - val constructor = PreferredConstructorDiscoverer.discover(TwoConstructorsWithoutDefault::class.java) + val constructor = + PreferredConstructorDiscoverer.discover( + TwoConstructorsWithoutDefault::class.java + ) assertThat(constructor!!.parameters).isEmpty() } @@ -54,7 +58,9 @@ class PreferredConstructorDiscovererUnitTests { @Test // DATACMNS-1126 fun `should discover annotated constructor`() { - val constructor = PreferredConstructorDiscoverer.discover(AnnotatedConstructors::class.java) + val constructor = PreferredConstructorDiscoverer.discover( + AnnotatedConstructors::class.java + ) assertThat(constructor!!.parameters.size).isEqualTo(2) } @@ -62,7 +68,8 @@ class PreferredConstructorDiscovererUnitTests { @Test // DATACMNS-1126 fun `should discover default constructor`() { - val constructor = PreferredConstructorDiscoverer.discover(DefaultConstructor::class.java) + val constructor = + PreferredConstructorDiscoverer.discover(DefaultConstructor::class.java) assertThat(constructor!!.parameters.size).isEqualTo(1) } @@ -70,7 +77,10 @@ class PreferredConstructorDiscovererUnitTests { @Test // DATACMNS-1126 fun `should discover default annotated constructor`() { - val constructor = PreferredConstructorDiscoverer.discover(TwoDefaultConstructorsAnnotated::class.java) + val constructor = + PreferredConstructorDiscoverer.discover( + TwoDefaultConstructorsAnnotated::class.java + ) assertThat(constructor!!.parameters.size).isEqualTo(3) } @@ -88,8 +98,7 @@ class PreferredConstructorDiscovererUnitTests { assertThat(constructor).isNull() } - // See https://github.com/spring-projects/spring-data-commons/issues/2374 - /*@Test // DATACMNS-1800, gh-2215 + @Test // DATACMNS-1800, GH-2215 @ExperimentalUnsignedTypes fun `should discover constructor for class using unsigned types`() { @@ -99,7 +108,7 @@ class PreferredConstructorDiscovererUnitTests { ) assertThat(constructor).isNotNull() - } */ + } data class Simple(val firstname: String) @@ -138,13 +147,11 @@ class PreferredConstructorDiscovererUnitTests { ) } - /* - See https://github.com/spring-projects/spring-data-commons/issues/2374 @ExperimentalUnsignedTypes data class UnsignedTypesEntity( val id: String, val a: UInt = 5u, val b: Int = 5, val c: Double = 1.5 - ) */ + ) } diff --git a/src/test/kotlin/org/springframework/data/mapping/model/ReflectionEntityInstantiatorDataClassUnitTests.kt b/src/test/kotlin/org/springframework/data/mapping/model/ReflectionEntityInstantiatorDataClassUnitTests.kt index c24f9c2028..44c9a69260 100644 --- a/src/test/kotlin/org/springframework/data/mapping/model/ReflectionEntityInstantiatorDataClassUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/mapping/model/ReflectionEntityInstantiatorDataClassUnitTests.kt @@ -21,6 +21,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.springframework.data.mapping.PersistentEntity import org.springframework.data.mapping.context.SamplePersistentProperty +import kotlin.reflect.KClass /** * Unit tests for [ReflectionEntityInstantiator] creating instances using Kotlin data classes. @@ -36,17 +37,9 @@ class ReflectionEntityInstantiatorDataClassUnitTests { @Test // DATACMNS-1126 fun `should create instance`() { - val entity = mockk>() - val constructor = - PreferredConstructorDiscoverer.discover( - Contact::class.java - ) - every { provider.getParameterValue(any()) }.returnsMany("Walter", "White") - every { entity.instanceCreatorMetadata } returns constructor - val instance: Contact = - ReflectionEntityInstantiator.INSTANCE.createInstance(entity, provider) + val instance: Contact = construct(Contact::class) assertThat(instance.firstname).isEqualTo("Walter") assertThat(instance.lastname).isEqualTo("White") @@ -55,23 +48,39 @@ class ReflectionEntityInstantiatorDataClassUnitTests { @Test // DATACMNS-1126 fun `should create instance and fill in defaults`() { - val entity = - mockk>() - val constructor = - PreferredConstructorDiscoverer.discover( - ContactWithDefaulting::class.java - ) - every { provider.getParameterValue(any()) }.returnsMany("Walter", null) - every { entity.instanceCreatorMetadata } returns constructor - val instance: ContactWithDefaulting = - ReflectionEntityInstantiator.INSTANCE.createInstance(entity, provider) + val instance: ContactWithDefaulting = construct(ContactWithDefaulting::class) assertThat(instance.firstname).isEqualTo("Walter") assertThat(instance.lastname).isEqualTo("White") } + @Test // GH-1947 + fun `should instantiate type with value class defaulting`() { + + every { provider.getParameterValue(any()) }.returns(1) + + val instance = construct(WithDefaultPrimitiveValue::class) + + assertThat(instance.pvd.id).isEqualTo(1) + } + + private fun construct(typeToCreate: KClass): T { + + val entity = mockk>() + val constructor = + PreferredConstructorDiscoverer.discover( + typeToCreate.java + ) + + every { entity.instanceCreatorMetadata } returns constructor + every { entity.type } returns constructor!!.constructor.declaringClass + every { entity.typeInformation } returns mockk() + + return ReflectionEntityInstantiator.INSTANCE.createInstance(entity, provider) + } + data class Contact(val firstname: String, val lastname: String) data class ContactWithDefaulting(val firstname: String, val lastname: String = "White") diff --git a/src/test/kotlin/org/springframework/data/mapping/model/ReflectionEntityInstantiatorInlineClassUnitTests.kt b/src/test/kotlin/org/springframework/data/mapping/model/ReflectionEntityInstantiatorInlineClassUnitTests.kt new file mode 100644 index 0000000000..87e4424d73 --- /dev/null +++ b/src/test/kotlin/org/springframework/data/mapping/model/ReflectionEntityInstantiatorInlineClassUnitTests.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2021-2023 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 io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.data.mapping.PersistentEntity +import org.springframework.data.mapping.context.SamplePersistentProperty +import kotlin.reflect.KClass + +/** + * Unit tests for [ReflectionEntityInstantiator] creating instances using Kotlin inline classes. + * + * @author Mark Paluch + * See also https://github.com/spring-projects/spring-framework/issues/28638 + */ +class ReflectionEntityInstantiatorValueClassUnitTests { + + val provider = mockk>() + + @Test // GH-1947 + fun `should create instance`() { + + every { provider.getParameterValue(any()) }.returnsMany("Walter") + + val instance: WithMyValueClass = + construct(WithMyValueClass::class) + + assertThat(instance.id.id).isEqualTo("Walter") + } + + @Test // GH-1947 + fun `should create instance with defaulting without value`() { + + every { provider.getParameterValue(any()) } returns null + + val instance: WithNestedMyNullableValueClass = construct(WithNestedMyNullableValueClass::class) + + assertThat(instance.id?.id?.id).isEqualTo("foo") + assertThat(instance.baz?.id).isEqualTo("id") + } + + @Test // GH-1947 + fun `should use annotated constructor for types using nullable value class`() { + + every { provider.getParameterValue(any()) }.returnsMany("Walter", null) + + val instance = construct(WithValueClassPreferredConstructor::class) + + assertThat(instance.id?.id?.id).isEqualTo("foo") + assertThat(instance.baz?.id).isEqualTo("Walter-pref") + } + + private fun construct(typeToCreate: KClass): T { + + val entity = mockk>() + val constructor = + PreferredConstructorDiscoverer.discover( + typeToCreate.java + ) + + every { entity.instanceCreatorMetadata } returns constructor + every { entity.type } returns constructor!!.constructor.declaringClass + every { entity.typeInformation } returns mockk() + + return ReflectionEntityInstantiator.INSTANCE.createInstance(entity, provider) + } + +} + diff --git a/src/test/kotlin/org/springframework/data/util/KotlinBeanInfoFactoryUnitTests.kt b/src/test/kotlin/org/springframework/data/util/KotlinBeanInfoFactoryUnitTests.kt new file mode 100644 index 0000000000..5f6fb1de93 --- /dev/null +++ b/src/test/kotlin/org/springframework/data/util/KotlinBeanInfoFactoryUnitTests.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2023 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.util + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.BeanUtils + +/** + * Unit tests for [KotlinBeanInfoFactory]. + * @author Mark Paluch + */ +class KotlinBeanInfoFactoryUnitTests { + + @Test + internal fun determinesDataClassProperties() { + + val pds = BeanUtils.getPropertyDescriptors(SimpleDataClass::class.java) + + assertThat(pds).hasSize(2).extracting("name").contains("id", "name") + + for (pd in pds) { + if (pd.name == "id") { + assertThat(pd.readMethod.name).isEqualTo("getId") + assertThat(pd.writeMethod).isNull() + } + + if (pd.name == "name") { + assertThat(pd.readMethod.name).isEqualTo("getName") + assertThat(pd.writeMethod.name).isEqualTo("setName") + } + } + } + + @Test + internal fun determinesInlineClassConsumerProperties() { + + val pds = BeanUtils.getPropertyDescriptors(WithValueClass::class.java) + + assertThat(pds).hasSize(1).extracting("name").containsOnly("address") + assertThat(pds[0].readMethod.name).startsWith("getAddress-") // hashCode suffix + assertThat(pds[0].writeMethod.name).startsWith("setAddress-") // hashCode suffix + } + + @Test + internal fun determinesInlineClassWithDefaultingConsumerProperties() { + + val pds = BeanUtils.getPropertyDescriptors(WithOptionalValueClass::class.java) + + assertThat(pds).hasSize(1).extracting("name").containsOnly("address") + assertThat(pds[0].readMethod.name).startsWith("getAddress-") // hashCode suffix + assertThat(pds[0].writeMethod).isNull() + } + + data class SimpleDataClass(val id: String, var name: String) + + @JvmInline + value class EmailAddress(val address: String) + + data class WithValueClass(var address: EmailAddress) + + data class WithOptionalValueClass(val address: EmailAddress? = EmailAddress("un@known")) + +}