diff --git a/pom.xml b/pom.xml index 2ace266bf9..0d4ca12b51 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 4.2.0-SNAPSHOT + 4.2.0-GH-3521-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. diff --git a/src/main/java/org/springframework/data/core/PropertyPathUtil.java b/src/main/java/org/springframework/data/core/PropertyPathUtil.java index 33b51d3776..50debf36f7 100644 --- a/src/main/java/org/springframework/data/core/PropertyPathUtil.java +++ b/src/main/java/org/springframework/data/core/PropertyPathUtil.java @@ -21,14 +21,17 @@ import java.util.Objects; import org.jspecify.annotations.Nullable; - +import org.springframework.asm.Type; +import org.springframework.data.util.Lazy; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; /** * Utility class for {@link PropertyPath} and {@link PropertyReference} implementations. * * @author Mark Paluch + * @author Christoph Strobl * @since 4.1 */ public class PropertyPathUtil { @@ -48,10 +51,48 @@ public static PropertyPath resolve(Object obj) { return TypedPropertyPaths.of(new SerializableWrapper((Serializable) obj)); } - private record SerializableWrapper(Serializable serializable) implements PropertyReference { + private static final class SerializableWrapper + implements PropertyReference, SerializableLambdaReader.LambdaWrapper { + + private final Serializable serializable; + + private final Lazy bridgeMethod = Lazy.of(this::resolveBridge); + + SerializableWrapper(Serializable serializable) { + this.serializable = serializable; + } + + @Override + public Object unwrap() { + return serializable; + } @Override public @Nullable Object get(Object obj) { + + Method bridgeMethod = this.bridgeMethod.getNullable(); + if (bridgeMethod == null) { + return null; + } + return ReflectionUtils.invokeMethod(bridgeMethod, serializable, obj); + } + + private @Nullable Method resolveBridge() { + + SerializedLambda lambda = writeReplace(); + + Class functionalInterface = ClassUtils.resolveClassName(lambda.getFunctionalInterfaceClass().replace('/', '.'), + serializable.getClass().getClassLoader()); + + // TODO: actual arg count vs. hardcoded value '1' + int argumentCount = Type.getArgumentCount(lambda.getFunctionalInterfaceMethodSignature()); + + for (Method method : functionalInterface.getDeclaredMethods()) { + if (method.getName().equals(lambda.getFunctionalInterfaceMethodName()) + && method.getParameterCount() == argumentCount) { + return method; + } + } return null; } @@ -69,6 +110,16 @@ private record SerializableWrapper(Serializable serializable) implements Propert return (SerializedLambda) ReflectionUtils.invokeMethod(method, serializable); } + @Override + public boolean equals(@Nullable Object obj) { + return obj instanceof SerializableWrapper that && serializable.equals(that.serializable); + } + + @Override + public int hashCode() { + return serializable.hashCode(); + } + } /** diff --git a/src/main/java/org/springframework/data/core/PropertyReferences.java b/src/main/java/org/springframework/data/core/PropertyReferences.java index 74ce51e967..82dc562016 100644 --- a/src/main/java/org/springframework/data/core/PropertyReferences.java +++ b/src/main/java/org/springframework/data/core/PropertyReferences.java @@ -40,7 +40,7 @@ */ class PropertyReferences { - private static final Map, ResolvedPropertyReference>> resolved = new WeakHashMap<>(); + private static final Map>> resolved = new WeakHashMap<>(); private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyReference.class, TypedPropertyPath.class, TypedPropertyPaths.class, PropertyReferences.class); @@ -55,13 +55,15 @@ public static PropertyReference of(PropertyReference lambda) return lambda; } - Map, ResolvedPropertyReference> cache; + Map> cache; synchronized (resolved) { cache = resolved.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); } - return (PropertyReference) cache.computeIfAbsent(lambda, - o -> new ResolvedPropertyReference(o, read(lambda))); + Object key = reader.getCacheKey(lambda); + + return (PropertyReference) cache.computeIfAbsent(key, + o -> new ResolvedPropertyReference(lambda, read(lambda))); } /** diff --git a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java index c434de1a84..7d991ccb3c 100644 --- a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java +++ b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java @@ -18,6 +18,7 @@ import kotlin.jvm.JvmClassMappingKt; import kotlin.jvm.internal.PropertyReference; import kotlin.reflect.KClass; +import kotlin.reflect.KProperty; import kotlin.reflect.KProperty1; import java.io.IOException; @@ -98,6 +99,7 @@ * * * @author Mark Paluch + * @author Christoph Strobl * @since 4.1 */ class SerializableLambdaReader { @@ -140,10 +142,9 @@ private static boolean isEnabled(String property, boolean defaultValue) { */ public MemberDescriptor read(Object lambdaObject) { - // Kotlin 2.0 - Object k2Lambda = KotlinDetectorUtils.detectKotlin2SamLambda(lambdaObject); - if (k2Lambda != null) { - return KotlinDelegate.read(k2Lambda, lambdaObject); + Object property = KotlinDetectorUtils.getCapturedProperty(lambdaObject); + if (property != null) { + return KotlinDelegate.read(property, lambdaObject); } SerializedLambda lambda = serialize(lambdaObject); @@ -177,6 +178,14 @@ public MemberDescriptor read(Object lambdaObject) { + ". The given value is not a lambda or method reference."); } + Object getCacheKey(Object lambdaObject) { + + Object target = lambdaObject instanceof LambdaWrapper wrapper ? wrapper.unwrap() : lambdaObject; + Object property = KotlinDetectorUtils.getCapturedProperty(target); + + return property != null ? property : lambdaObject; + } + private void assertNotConstructor(SerializedLambda lambda) { if (lambda.getImplMethodKind() == MethodHandleInfo.REF_newInvokeSpecial @@ -233,30 +242,34 @@ private static SerializedLambda serialize(Object lambda) { static class KotlinDetectorUtils { /** - * Detect whether the given lambda object is a Kotlin 2 SAM wrapper around a property reference - * {@link kotlin.reflect.KProperty} usage with {@link PropertyReference} or {@link TypedPropertyPath}. - *

- * Kotlin 1 lambdas use {@link SerializedLambda} directly and provide the function object through - * {@link SerializedLambda#getCapturedArg(int) argument capture}. - * - * @param lambdaObject the lambda object to introspect. - * @return the function object or {@code null} if not detected. + * Detect whether the given lambda object is a Kotlin 2 SAM wrapper around a property reference. */ - public static @Nullable Object detectKotlin2SamLambda(Object lambdaObject) { + static @Nullable Object getCapturedProperty(Object lambdaObject) { + + if (!KotlinDetector.isKotlinReflectPresent()) { + return null; + } Class cls = lambdaObject.getClass(); - if (!KotlinDetector.isKotlinType(cls)) { + Field field; + + if (KotlinDetector.isKotlinType(cls)) { + field = ReflectionUtils.findField(cls, "function"); + } else if (cls.isHidden() && ClassUtils.isLambdaClass(cls)) { + Field[] fields = new Field[1]; + ReflectionUtils.doWithFields(cls, it -> fields[0] = it); + field = fields[0]; + } else { return null; } - Field field = ReflectionUtils.findField(lambdaObject.getClass(), "function"); if (field == null) { return null; } ReflectionUtils.makeAccessible(field); - Object function = ReflectionUtils.getField(field, lambdaObject); - return isKotlinPropertyReference(function) ? function : null; + Object value = ReflectionUtils.getField(field, lambdaObject); + return value != null && KotlinDelegate.isKProperty(value) ? value : null; } public static boolean isKotlinPropertyReference(SerializedLambda lambda) { @@ -280,6 +293,10 @@ private static boolean isKotlinPropertyReference(@Nullable Object capturedObject */ static class KotlinDelegate { + static boolean isKProperty(Object candidate) { + return candidate instanceof KProperty; + } + public static MemberDescriptor read(SerializedLambda lambda) { return read(lambda.getCapturedArg(0), lambda); } @@ -605,6 +622,15 @@ private boolean matchesEntrypoint(String className) { return false; } + + /** + * Marker interface to distinguish between a wrapped and an actual lambda. + */ + interface LambdaWrapper { + + Object unwrap(); + } + /** * Value object for reading errors. * diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPathFeature.java b/src/main/java/org/springframework/data/core/TypedPropertyPathFeature.java index 646ed3232d..669973cd17 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPathFeature.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPathFeature.java @@ -15,18 +15,30 @@ */ package org.springframework.data.core; +import kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KClass; +import kotlin.reflect.KProperty1; + import java.beans.PropertyDescriptor; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.function.BiConsumer; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.graalvm.nativeimage.hosted.Feature; import org.graalvm.nativeimage.hosted.RuntimeReflection; import org.graalvm.nativeimage.hosted.RuntimeSerialization; +import org.jspecify.annotations.Nullable; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.aot.AotProcessingException; +import org.springframework.core.KotlinDetector; +import org.springframework.data.core.MemberDescriptor.KPropertyReferenceDescriptor; +import org.springframework.data.util.ReflectionUtils; +import org.springframework.util.ClassUtils; /** * GraalVM {@link Feature} that registers serializable {@link TypedPropertyPath} and {@link PropertyReference} lambdas. @@ -36,21 +48,17 @@ * This feature also registers Java Bean Properties (methods and fields) referenced by the property path or reference * for reflection, so that they are available at runtime and therefore the underlying domain model does not require * additional reachability configuration. + *

+ * Kotlin property references ({@code Person::name}) reach the property path API through SAM-converted lambdas that + * capture the property reference. These lambdas cannot be instantiated at build time, hence the referenced properties + * are registered through the reachable {@link kotlin.jvm.internal.PropertyReference} classes instead. * * @author Mark Paluch * @since 4.1 */ class TypedPropertyPathFeature implements Feature { - /** - * Token indicating a class is or is not a lambda. - */ - private static final String LAMBDA_CLASS_MARKER = "$$Lambda"; - - /** - * The offset from {@link #LAMBDA_CLASS_MARKER} where the end marker is found. - */ - private static final int LAMBDA_CLASS_END_MARKER = LAMBDA_CLASS_MARKER.length(); + private static final Log logger = LogFactory.getLog(TypedPropertyPathFeature.class); private final SerializableLambdaReader reader = new SerializableLambdaReader(); @@ -59,7 +67,7 @@ public void beforeAnalysis(BeforeAnalysisAccess access) { BiConsumer> serializableLambdaHandler = (ignore, cls) -> { - if (isLambdaClass(cls)) { + if (ClassUtils.isLambdaClass(cls)) { try { registerLambdaSerialization(cls); @@ -74,6 +82,10 @@ public void beforeAnalysis(BeforeAnalysisAccess access) { access.registerSubtypeReachabilityHandler(serializableLambdaHandler, TypedPropertyPath.class); access.registerSubtypeReachabilityHandler(serializableLambdaHandler, PropertyReference.class); + + if (KotlinDetector.isKotlinReflectPresent()) { + KotlinDelegate.registerPropertyReferences(access); + } } private void registerLambdaSerialization(Class lambdaClass) { @@ -83,9 +95,16 @@ private void registerLambdaSerialization(Class lambdaClass) { } private void registerDomainModel(Class cls) throws ReflectiveOperationException { - Constructor declaredConstructor = cls.getDeclaredConstructor(); - declaredConstructor.setAccessible(true); - Object lambdaInstance = declaredConstructor.newInstance(); + + // make sure to avoid capturing lambdas + Constructor constructor = ReflectionUtils.findConstructor(cls); + + if (constructor == null) { + return; + } + + constructor.setAccessible(true); + Object lambdaInstance = constructor.newInstance(); MemberDescriptor memberDescriptor = reader.read(lambdaInstance); registerDomainModel(memberDescriptor); @@ -121,30 +140,61 @@ private static void registerDomainModel(MemberDescriptor descriptor) { } /** - * Return true if the specified Class represents a raw lambda. - * - * @param cls class to inspect. - * @return true if the class represents a raw lambda. + * Delegate to register domain model members referenced through Kotlin property references. Kotlin compiles an unbound + * property reference such as {@code Person::name} into a singleton subclass of + * {@link kotlin.jvm.internal.PropertyReference} carrying the owner type and property name. */ - public static boolean isLambdaClass(Class cls) { + static class KotlinDelegate { - String name = cls.getName(); - int marker = name.indexOf(LAMBDA_CLASS_MARKER); - if (marker == -1) { - return false; - } + static void registerPropertyReferences(BeforeAnalysisAccess access) { + + access.registerSubtypeReachabilityHandler((ignore, cls) -> { - int noffset = marker + LAMBDA_CLASS_END_MARKER; - if (noffset > name.length()) { - return false; + if (Modifier.isAbstract(cls.getModifiers())) { + return; + } + + try { + + MemberDescriptor descriptor = getPropertyReference(cls); + + if (descriptor != null) { + registerDomainModel(descriptor); + } + } catch (Exception e) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping registration of Kotlin property reference [%s]".formatted(cls.getName()), e); + } + } + }, kotlin.jvm.internal.PropertyReference.class); } - char c = name.charAt(noffset); + /** + * Describe the property referenced by the given unbound property reference class. + * + * @param cls the property reference class to inspect. + * @return the descriptor or {@code null} if {@code cls} is not an unbound reference to a class property. + */ + static @Nullable MemberDescriptor getPropertyReference(Class cls) throws ReflectiveOperationException { + + // unbound references have a no-arg constructor, bound references capture their receiver. + Constructor constructor = ReflectionUtils.findConstructor(cls); + + if (constructor == null) { + return null; + } + + constructor.setAccessible(true); + Object reference = constructor.newInstance(); + + if (reference instanceof kotlin.jvm.internal.PropertyReference propRef + && propRef.getOwner() instanceof KClass owner && reference instanceof KProperty1 property) { + return KPropertyReferenceDescriptor.create(JvmClassMappingKt.getJavaClass(owner), property); + } + + return null; + } - // '$' character will be seen in releases between Java {8,20} - // '/' is used in Java 21 - // See bug 35177243 - return c == '$' || c == '/'; } } diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index 5347177d7c..d4928254b2 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -19,7 +19,6 @@ import kotlin.reflect.KProperty1; import kotlin.reflect.jvm.ReflectJvmMapping; -import java.io.Serializable; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Collections; @@ -50,7 +49,7 @@ */ class TypedPropertyPaths { - private static final Map>> resolved = new WeakHashMap<>(); + private static final Map>> resolved = new WeakHashMap<>(); private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyPath.class, PropertyReference.class, PropertyReferences.class, TypedPropertyPath.class, TypedPropertyPaths.class); @@ -97,32 +96,46 @@ public static TypedPropertyPath compose(TypedPropertyPath } /** - * Introspect {@link PropertyReference} and return an introspected {@link ResolvedTypedPropertyPath} variant. + * Introspect {@link PropertyReference} and return a resolved {@link ResolvedTypedPropertyPath} object. */ - @SuppressWarnings({ "unchecked", "rawtypes" }) + @SuppressWarnings({ "unchecked" }) public static TypedPropertyPath of(PropertyReference lambda) { + return resolve(lambda); + } + + /** + * Introspect {@link TypedPropertyPath} and return a resolved {@link ResolvedTypedPropertyPath} object. + */ + @SuppressWarnings({ "unchecked" }) + public static TypedPropertyPath of(TypedPropertyPath lambda) { + return resolve(lambda); + } + + @SuppressWarnings({ "rawtypes" }) + private static TypedPropertyPath resolve(Object lambda) { if (lambda instanceof Resolved) { return (TypedPropertyPath) lambda; } - Map, TypedPropertyPath> cache; + Map> cache; synchronized (resolved) { - cache = (Map) resolved.computeIfAbsent(lambda.getClass().getClassLoader(), - k -> new ConcurrentReferenceHashMap<>()); + cache = resolved.computeIfAbsent(lambda.getClass().getClassLoader(), + ignore -> new ConcurrentReferenceHashMap<>()); } - return (TypedPropertyPath) cache.computeIfAbsent(lambda, TypedPropertyPaths::doResolvePropertyReference); + return cache.computeIfAbsent(reader.getCacheKey(lambda), k -> createPath(lambda)); } - @SuppressWarnings({ "rawtypes", "unchecked" }) - private static TypedPropertyPath doResolvePropertyReference(PropertyReference lambda) { + @SuppressWarnings("NullableProblems") + private static TypedPropertyPath createPath(Object lambda) { - if (lambda instanceof PropertyReferences.ResolvedPropertyReferenceSupport resolved) { - return new PropertyReferenceWrapper<>(resolved); + if (lambda instanceof PropertyReference reference + && lambda instanceof PropertyReferences.ResolvedPropertyReferenceSupport) { + return new PropertyReferenceWrapper<>(reference); } - PropertyMetadata metadata = read(lambda); + PropertyMetadata metadata = readMetadata(lambda); if (KotlinDetector.isKotlinReflectPresent()) { if (metadata instanceof KPropertyPathMetadata kMetadata @@ -131,44 +144,18 @@ public static TypedPropertyPath of(PropertyReference lambda) } } - return new ResolvedPropertyReference<>(lambda, metadata); - } - - /** - * Introspect {@link TypedPropertyPath} and return an introspected {@link ResolvedTypedPropertyPath} variant. - */ - @SuppressWarnings({ "unchecked", "rawtypes" }) - public static TypedPropertyPath of(TypedPropertyPath lambda) { - - if (lambda instanceof Resolved) { - return lambda; - } - - Map, TypedPropertyPath> cache; - synchronized (resolved) { - cache = (Map) resolved.computeIfAbsent(lambda.getClass().getClassLoader(), - k -> new ConcurrentReferenceHashMap<>()); + if (lambda instanceof PropertyReference reference) { + return new ResolvedPropertyReference<>(reference, metadata); } - return (TypedPropertyPath) cache.computeIfAbsent(lambda, - TypedPropertyPaths::doResolvePropertyPathReference); - } - - private static TypedPropertyPath doResolvePropertyPathReference(TypedPropertyPath lambda) { - - PropertyMetadata metadata = read(lambda); - - if (KotlinDetector.isKotlinReflectPresent()) { - if (metadata instanceof KPropertyPathMetadata kMetadata - && kMetadata.getProperty() instanceof KPropertyPath ref) { - return KotlinDelegate.of(ref); - } + if (lambda instanceof TypedPropertyPath path) { + return new ResolvedTypedPropertyPath<>(path, metadata); } - return new ResolvedTypedPropertyPath<>(lambda, metadata); + throw new UnsupportedOperationException("Cannot resolve lambda: " + lambda); } - private static PropertyMetadata read(Object lambda) { + private static PropertyMetadata readMetadata(Object lambda) { MemberDescriptor reference = reader.read(lambda); diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt index 6f011ff6d5..c247a4eb8d 100644 --- a/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt +++ b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt @@ -85,6 +85,11 @@ internal class KSinglePropertyReference( override val leaf: KProperty1<*, P> get() = child + override fun equals(other: Any?): Boolean = + other is KSinglePropertyReference<*, *, *> && parent == other.parent && child == other.child + + override fun hashCode(): Int = 31 * parent.hashCode() + child.hashCode() + } /** @@ -113,6 +118,11 @@ internal class KIterablePropertyReference( override val leaf: KProperty1<*, P> get() = child + override fun equals(other: Any?): Boolean = + other is KIterablePropertyReference<*, *, *> && parent == other.parent && child == other.child + + override fun hashCode(): Int = 31 * parent.hashCode() + child.hashCode() + } diff --git a/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java b/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java index 3ac8d0cde0..cf0f583e2f 100644 --- a/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java +++ b/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java @@ -15,6 +15,8 @@ */ package org.springframework.data.core; +import static org.assertj.core.api.Assertions.*; + import java.io.Serializable; import org.jspecify.annotations.Nullable; @@ -26,6 +28,7 @@ * Unit test {@link PropertyPathUtil}. * * @author Mark Paluch + * @author Christoph Strobl */ class PropertyPathUtilUnitTests { @@ -37,6 +40,20 @@ void shouldResolvePropertyPath() { System.out.println(PropertyPathUtil.resolve(c)); } + @Test // GH-3521 + @SuppressWarnings("unchecked") + void resolvedPathShouldDelegatePropertyAccess() { + + Person person = new Person(); + person.name = "Walter"; + + Converter c = convert(Person::getName); + PropertyPath path = PropertyPathUtil.resolve(c); + + assertThat(path.toDotPath()).isEqualTo("name"); + assertThat(((TypedPropertyPath) path).get(person)).isEqualTo("Walter"); + } + static & Serializable> Serializable of(C mapping) { return mapping; } diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index ff84ba3ba4..5113bf400b 100644 --- a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.*; import java.util.List; +import java.util.function.Supplier; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -62,6 +63,14 @@ void resolvesMHSimplePath() { assertThat(PropertyPath.of(PersonQuery::getName).toDotPath()).isEqualTo("name"); } + @Test // GH-3521 + void shouldCacheResolvedMethodReference() { + + Supplier> factory = () -> PropertyPath.of(PersonQuery::getName); + + assertThat(factory.get()).isSameAs(factory.get()); + } + @Test // GH-3400 void resolvesMHComposedPath() { assertThat(PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry).toDotPath()) diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt index bb337d4065..ce9964ec02 100644 --- a/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt @@ -191,6 +191,24 @@ class KPropertyExtensionsTests { assertThat(property).isEqualTo("author.name") } + @Test // GH-3521 + fun `Nested KProperty should be equal`() { + + assertThat(Book::author / Author::name).isEqualTo(Book::author / Author::name) + .hasSameHashCodeAs(Book::author / Author::name) + assertThat(Book::author / Author::name).isNotEqualTo(Book::author / Author::books) + assertThat(Book::author / Author::name).isNotEqualTo(Book::editor / Author::name) + } + + @Test // GH-3521 + fun `Iterable nested KProperty should be equal`() { + + assertThat(Author::books / Book::title).isEqualTo(Author::books / Book::title) + .hasSameHashCodeAs(Author::books / Book::title) + assertThat(Author::books / Book::title).isNotEqualTo(Author::books / Book::author) + assertThat(Author::books / Book::title).isNotEqualTo(Author::favorites / Book::title) + } + @Test // DATACMNS-1835 fun `Convert nullable KProperty to field name`() { @@ -201,8 +219,12 @@ class KPropertyExtensionsTests { assertThat(property).isEqualTo("cat.name") } - class Book(val title: String, val author: Author) - class Author(val name: String, val books: List) + class Book(val title: String, val author: Author, val editor: Author = author) + class Author( + val name: String, + val books: List, + val favorites: List = books + ) class Person { var name: String? = null diff --git a/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt index c12f79ea8d..3244e10b2e 100644 --- a/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt @@ -31,6 +31,15 @@ class PropertyReferenceKtUnitTests { assertThat(PropertyReference.property(Person::address).name).isEqualTo("address") } + @Test // GH-3521 + fun shouldCacheSamPropertyReference() { + assertThat(PropertyReference.property(Person::address)).isSameAs( + PropertyReference.property( + Person::address + ) + ) + } + @Test // GH-3400 fun resolutionShouldFailForComposedPropertyPath() { assertThatExceptionOfType(PropertyResolutionException::class.java) diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathFeatureKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathFeatureKtUnitTests.kt new file mode 100644 index 0000000000..898cf95435 --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathFeatureKtUnitTests.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026-present 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.core + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.data.core.MemberDescriptor.KPropertyReferenceDescriptor +import kotlin.jvm.internal.PropertyReference1Impl + +/** + * Kotlin unit tests for [TypedPropertyPathFeature]. + * + * @author Mark Paluch + */ +class TypedPropertyPathFeatureKtUnitTests { + + @Test // GH-3521 + fun shouldDescribeUnboundPropertyReference() { + + val descriptor = + TypedPropertyPathFeature.KotlinDelegate.getPropertyReference((Person::address).javaClass) + + assertThat(descriptor).isInstanceOf(KPropertyReferenceDescriptor::class.java) + assertThat(descriptor!!.owner).isEqualTo(Person::class.java) + assertThat(descriptor.member).isEqualTo(Person::class.java.getMethod("getAddress")) + } + + @Test // GH-3521 + fun shouldIgnoreBoundPropertyReference() { + + val person = Person() + + assertThat(TypedPropertyPathFeature.KotlinDelegate.getPropertyReference((person::address).javaClass)).isNull() + } + + @Test // GH-3521 + fun shouldIgnorePropertyReferenceBaseClasses() { + + val descriptor = TypedPropertyPathFeature.KotlinDelegate.getPropertyReference( + PropertyReference1Impl::class.java + ) + + assertThat(descriptor).isNull() + } + + class Person { + var address: String? = null + } + +} diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt index 2d2861aa87..bd25eaad75 100644 --- a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt @@ -21,12 +21,14 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.Arguments.ArgumentSet import org.junit.jupiter.params.provider.MethodSource +import java.util.WeakHashMap import java.util.stream.Stream /** * Kotlin unit tests for [TypedPropertyPath] and related functionality. * * @author Mark Paluch + * @author Christoph Strobl */ class TypedPropertyPathKtUnitTests { @@ -38,6 +40,8 @@ class TypedPropertyPathKtUnitTests { companion object { + private const val ITERATIONS = 10 + @JvmStatic fun propertyPaths(): Stream { @@ -81,6 +85,26 @@ class TypedPropertyPathKtUnitTests { ).isEqualTo("address") } + @Test // GH-3521 + fun shouldCacheSamPropertyReference() { + + assertThat(TypedPropertyPath.path(Person::address)) + .isSameAs(TypedPropertyPath.path(Person::address)) + assertThat(TypedPropertyPath.of(Person::address)) + .isSameAs(TypedPropertyPath.of(Person::address)) + } + + @Test // GH-3521 + fun shouldCacheComposedKProperty() { + + assertThat(TypedPropertyPath.path(Person::address / Address::city)) + .isSameAs(TypedPropertyPath.path(Person::address / Address::city)) + assertThat(TypedPropertyPath.of(Person::address / Address::city)) + .isSameAs(TypedPropertyPath.of(Person::address / Address::city)) + assertThat(TypedPropertyPath.path(Person::addresses / Address::city)) + .isSameAs(TypedPropertyPath.path(Person::addresses / Address::city)) + } + @Test // GH-3400 fun shouldSupportComposedPropertyReference() { @@ -114,10 +138,74 @@ class TypedPropertyPathKtUnitTests { assertThat(otherPath.toDotPath()).isEqualTo("address.city") } + @Test // GH-3521 + fun shouldCacheKotlinPropertyReference() { + + clearPropertyPathCache(); + + repeat(ITERATIONS) { + PropertyPathUtil.resolve(asPropertyReference(Person::name)) + } + + assertThat(typedPropertyPathCacheSize()).isOne() + } + + @Test // GH-3521 + fun shouldCacheKotlinLambdaResolvedThroughPropertyPathUtil() { + + clearPropertyPathCache(); + + repeat(ITERATIONS) { + PropertyPathUtil.resolve(PropertyReference { it.name }) + } + + assertThat(typedPropertyPathCacheSize()).isOne() + } + + @Test // GH-3521 + fun shouldCacheKotlinPropertyReferenceResolvedDirectly() { + + clearPropertyPathCache(); + + repeat(ITERATIONS) { + TypedPropertyPath.of(Person::age) + } + + assertThat(typedPropertyPathCacheSize()).isOne() + } + + private fun asPropertyReference(property: PropertyReference) = property + + private fun typedPropertyPathCacheSize(): Int { + + val cache = obtainPropertyPathCache() + + synchronized(cache) { + return cache.values.sumOf { it.size } + } + } + + private fun clearPropertyPathCache() { + + val cache = obtainPropertyPathCache() + + synchronized(cache) { + return cache.clear() + } + } + + @Suppress("UNCHECKED_CAST") + private fun obtainPropertyPathCache() : WeakHashMap> { + val field = TypedPropertyPaths::class.java.getDeclaredField("resolved") + field.isAccessible = true + return field.get(null) as WeakHashMap> + } + class Person { var name: String? = null var age: Int = 0 var address: Address? = null + var addresses: List

= emptyList() var emergencyContact: Person? = null }