diff --git a/pom.xml b/pom.xml index 253b26b609..3ceeab1532 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 4.1.0-SNAPSHOT + 4.1.0-GH-3400-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. @@ -388,6 +388,28 @@ + + jmh + + + org.openjdk.jmh + jmh-core + test + ${jmh} + + + org.openjdk.jmh + jmh-generator-annprocess + test + + + + + jitpack.io + https://jitpack.io + + + diff --git a/src/jmh/java/org/springframework/data/BenchmarkSettings.java b/src/jmh/java/org/springframework/data/BenchmarkSettings.java new file mode 100644 index 0000000000..5b7ae8752f --- /dev/null +++ b/src/jmh/java/org/springframework/data/BenchmarkSettings.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025 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; + +import java.util.concurrent.TimeUnit; + +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Global benchmark settings. + * + * @author Mark Paluch + */ +@Warmup(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS) +@Fork(value = 1, warmups = 0) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +public abstract class BenchmarkSettings { + +} diff --git a/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java b/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java new file mode 100644 index 0000000000..04d6e0660f --- /dev/null +++ b/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025 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.junit.platform.commons.annotation.Testable; +import org.openjdk.jmh.annotations.Benchmark; + +import org.springframework.data.BenchmarkSettings; + +/** + * Benchmarks for {@link SerializableLambdaReader}. + * + * @author Mark Paluch + */ +@Testable +public class SerializableLambdaReaderBenchmarks extends BenchmarkSettings { + + private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyReference.class); + + @Benchmark + public Object benchmarkMethodReference() { + + PropertyReference methodReference = Person::firstName; + return reader.read(methodReference); + } + + @Benchmark + public Object benchmarkLambda() { + + PropertyReference methodReference = person -> person.firstName(); + return reader.read(methodReference); + } + + record Person(String firstName, String lastName) { + + } + +} diff --git a/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java new file mode 100644 index 0000000000..245641dc03 --- /dev/null +++ b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 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.junit.platform.commons.annotation.Testable; +import org.openjdk.jmh.annotations.Benchmark; +import org.springframework.data.BenchmarkSettings; + +/** + * Benchmarks for {@link TypedPropertyPath}. + * + * @author Mark Paluch + */ +@Testable +public class TypedPropertyPathBenchmarks extends BenchmarkSettings { + + @Benchmark + public Object benchmarkMethodReference() { + return TypedPropertyPath.path(Person::firstName); + } + + @Benchmark + public Object benchmarkComposedMethodReference() { + return TypedPropertyPath.path(Person::address).then(Address::city); + } + + @Benchmark + public TypedPropertyPath benchmarkLambda() { + return TypedPropertyPath.path(person -> person.firstName()); + } + + @Benchmark + public TypedPropertyPath benchmarkComposedLambda() { + return TypedPropertyPath.path((Person person) -> person.address()).then(address -> address.city()); + } + + @Benchmark + public Object dotPath() { + return TypedPropertyPath.path(Person::firstName).toDotPath(); + } + + @Benchmark + public Object composedDotPath() { + return TypedPropertyPath.path(Person::address).then(Address::city).toDotPath(); + } + + record Person(String firstName, String lastName, Address address) { + + } + + record Address(String city) { + + } + +} diff --git a/src/main/antora/modules/ROOT/pages/property-paths.adoc b/src/main/antora/modules/ROOT/pages/property-paths.adoc new file mode 100644 index 0000000000..d1dc0982ed --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/property-paths.adoc @@ -0,0 +1,204 @@ +[[property-paths]] += Property Paths + +This chapter covers the concept of property paths. +Property paths are a form of navigation through domain classes to apply certain aspects in the context of interacting with the model. +Application code provides property paths to data access components to express intents such as selection of properties within a query, forming predicates, or applying sorting. +A property path originates from its owning type and can consist of one to many segments. + +[TIP] +==== +Following domain-driven design principles the classes that form the backbone of your persistent domain model and that are accessed through Spring Data are called entities. +An entry point to the object graph is called aggregate root. + +Understanding how to navigate and reference these properties is essential for working with repositories and query operations. +==== + +[[property-path-overview]] +== Property Path Overview + +Property paths provide a simple, text-based mechanism to navigate domain model properties. +This section introduces the fundamentals of property path navigation and demonstrates trade-offs between string-based and type-safe approaches. + +.Domain model example +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +class Person { + String firstname, lastname; + int age; + Address address; + List
previousAddresses; + + String getFirstname() { … } // other property accessors omitted for brevity + +} + +class Address { + String city, street; + + // accessors omitted for brevity + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +class Person { + var firstname: String? = null + var lastname: String? = null + var age: Int = 0 + var address: Address? = null + var previousAddresses: List
= emptyList() +} + +class Address { + var city: String? = null + var street: String? = null +} +---- +====== + +Property paths use dot-notation to express property references throughout Spring Data operations, such as sorting and filtering: + +.Dot-notation property references +[source,java] +---- +Sort.by("firstname", "address.city") +---- + +A property path consists of one or more segments separated by a dot (`.`). +Methods accepting property paths support single-segment references (top-level properties) and multi-segment navigation unless otherwise indicated. + +Collection and array properties support transparent traversal to their component type, enabling direct reference to nested properties: + +---- +Sort.by("address.city") <1> + +Sort.by("previousAddresses") <2> + +Sort.by("previousAddresses.city") <3> +---- + +<1> Navigate from the top-level `address` property to the `city` field. +<2> Reference the entire `previousAddresses` collection (supported by certain technologies for collection-based sorting). +<3> Navigate through the collection to sort by the `city` field of each address. + +String-based property paths offer simplicity and can be broadly applied but there are tradeoffs to consider: + +* **Flexibility**: Property paths are flexible and can be constructed from constant string, configuration or as result of user input. +* **Untyped**: String paths do not carry compile-time type information. +Typed as textual content they do not have a dependency on the underlying domain type. +* **Refactoring risk**: Renaming domain properties requires often manual updates to string literals; IDEs cannot reliably track these references. + +To improve refactoring safety and type consistency, prefer type-safe property references using method references. +This approach associates property paths with compile-time type information and enables compiler validation and IDE-driven refactoring. +See <> for details. + +NOTE: For implementation details, refer to <> for more information. + +[[property-path-internals]] +=== Property Path Internals + +The `org.springframework.data.core` package is the basis for Spring Data's navigation across domain classes. +The javadoc:org.springframework.data.core.TypeInformation[] inteface provides type introspection capable of resolving the type of a property. javadoc:org.springframework.data.core.PropertyPath[] represents a textual navigation path through a domain class. + +Together they provide: + +* Generic type resolution and introspection +* Property path creation and validation +* Actual type resolution for complex properties such as collections and maps + +[[type-safe-property-references]] +== Type-safe Property-References + +Type-safe property-references eliminate a common source of errors in data access code: Brittle, string-based property references. +This section explains how method references can be used to express refactoring-safe property paths. + +While a property path is a simple representation of object navigation, String-based property paths are inherently fragile during refactoring as they can be easily missed with an increasing distance between the property definition and its usage. +Type-safe alternatives derive property paths from method references, enabling the compiler to validate property names and IDEs to support refactoring operations. + +Consider the practical difference: The following examples express the same intent - sorting by `address.city` - but only the type-safe version benefits from compiler validation and IDE support: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +// Static import variant +path(Person::getAddress) + .then(Address::getCity); + +// Composition factory method +path(Person::getAddress, Address::getCity); + +// Fluent composition +TypedPropertyPath.of(Person::getAddress) + .then(Address::getCity); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +// Kotlin API +TypedPropertyPath.of(Person::address / Address::city) + +// as extension function +(Person::address / Address::city).toPath() +---- +====== + +=== Building Type-safe Property Paths + +Type-safe property paths compose method references to construct navigation expressions. +The following examples demonstrate inline usage and composition: + +.Type-safe Property Path Construction +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +// Inline usage with Sort +Sort.by(Person::getFirstName, Person::getLastName); + +// Composed navigation +Sort.by(TypedPropertyPath.of(Person::getAddress).then(Address::getCity), + Person::getLastName); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +// Inline usage with Sort +Sort.by(Person::firstName, Person::lastName) + +// Composed navigation +Sort.by(Person::address / Address::city, Person::lastName) +---- +====== + +Type-safe property paths integrate seamlessly with query abstractions and criteria builders, enabling declarative query construction without string-based property references: + +.Integration with Criteria API +[source,java] +---- +Criteria.where(Person::getAddress) + .then(Address::getCity) + .is("New York"); +---- + +Adopting type-safe property references aligns with modern Spring development principles. +Providing declarative, type-safe, and fluent APIs leads to simpler reasoning about data access eliminating an entire category of potential bugs through IDE refactoring support and early feedback on invalid properties by the compiler. + +Lambda introspection is cached for efficiency enabling repeatable use. +The JVM reuses static lambda instances contributing to minimal overhead of one-time parsing. diff --git a/src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java b/src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java index 869b60269e..1606dc7fed 100644 --- a/src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java +++ b/src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java @@ -20,6 +20,7 @@ import java.util.function.Function; import org.springframework.data.convert.PropertyValueConverter.FunctionPropertyValueConverter; +import org.springframework.data.core.PropertyReference; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.util.MethodInvocationRecorder; import org.springframework.lang.Contract; @@ -50,11 +51,25 @@ public class PropertyValueConverterRegistrar

> { * * @param the domain type * @param the property type - * @param type the domain type to obtain the property from * @param property a {@link Function} to describe the property to be referenced. * Usually a method handle to a getter. * @return will never be {@literal null}. */ + public WritingConverterRegistrationBuilder registerConverter(PropertyReference property) { + return new WritingConverterRegistrationBuilder<>(property.getOwningType().getType(), property.getName(), this); + } + + /** + * Starts a converter registration by pointing to a property of a domain type. + * + * @param the domain type + * @param the property type + * @param type the domain type to obtain the property from + * @param property a {@link Function} to describe the property to be referenced. Usually a method handle to a getter. + * @return will never be {@literal null}. + * @deprecated since 4.1, use {@link #registerConverter(PropertyReference)} instead. + */ + @Deprecated(since = "4.1") public WritingConverterRegistrationBuilder registerConverter(Class type, Function property) { String propertyName = MethodInvocationRecorder.forProxyOf(type).record(property).getPropertyPath() diff --git a/src/main/java/org/springframework/data/core/MemberDescriptor.java b/src/main/java/org/springframework/data/core/MemberDescriptor.java new file mode 100644 index 0000000000..4ce02107f5 --- /dev/null +++ b/src/main/java/org/springframework/data/core/MemberDescriptor.java @@ -0,0 +1,250 @@ +/* + * Copyright 2025 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 kotlin.reflect.KProperty1; +import kotlin.reflect.jvm.ReflectJvmMapping; + +import java.lang.invoke.SerializedLambda; +import java.lang.reflect.Field; +import java.lang.reflect.Member; +import java.lang.reflect.Method; + +import org.springframework.asm.Type; +import org.springframework.core.ResolvableType; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; + +/** + * Interface representing a member reference such as a field or method. + * + * @author Mark Paluch + * @since 4.1 + */ +interface MemberDescriptor { + + /** + * @return class owning the member, can be the declaring class or a subclass. + */ + Class getOwner(); + + /** + * @return the member (field or method). + */ + Member getMember(); + + /** + * @return field type or method return type. + */ + ResolvableType getType(); + + /** + * Create {@link MethodDescriptor} from a serialized lambda representing a method reference. + */ + static MethodDescriptor ofMethodReference(ClassLoader classLoader, SerializedLambda lambda) + throws ClassNotFoundException { + return ofMethod(classLoader, Type.getObjectType(lambda.getImplClass()).getClassName(), lambda.getImplMethodName()); + } + + /** + * Create {@link MethodDescriptor} from owner type and method name. + */ + static MethodDescriptor ofMethod(ClassLoader classLoader, String ownerClassName, String name) + throws ClassNotFoundException { + Class owner = ClassUtils.forName(ownerClassName, classLoader); + return MethodDescriptor.create(owner, name); + } + + /** + * Create {@link MethodDescriptor.FieldDescriptor} from owner type, field name and field type. + */ + static MethodDescriptor.FieldDescriptor ofField(ClassLoader classLoader, String ownerClassName, String name, + String fieldType) throws ClassNotFoundException { + + Class owner = ClassUtils.forName(ownerClassName, classLoader); + Class type = ClassUtils.forName(fieldType, classLoader); + + return FieldDescriptor.create(owner, name, type); + } + + /** + * Value object describing a {@link Method} in the context of an owning class. + */ + record MethodDescriptor(Class owner, Method method) implements MemberDescriptor { + + static MethodDescriptor create(Class owner, String methodName) { + Method method = ReflectionUtils.findMethod(owner, methodName); + if (method == null) { + throw new IllegalArgumentException("Method '%s.%s()' not found".formatted(owner.getName(), methodName)); + } + return new MethodDescriptor(owner, method); + } + + @Override + public Class getOwner() { + return owner(); + } + + @Override + public Method getMember() { + return method(); + } + + @Override + public ResolvableType getType() { + return ResolvableType.forMethodReturnType(method(), owner()); + } + + } + + /** + * Value object describing a {@link Field} in the context of an owning class. + */ + record FieldDescriptor(Class owner, Field field) implements MemberDescriptor { + + static FieldDescriptor create(Class owner, String fieldName, Class fieldType) { + + Field field = ReflectionUtils.findField(owner, fieldName, fieldType); + if (field == null) { + throw new IllegalArgumentException("Field '%s.%s' not found".formatted(owner.getName(), fieldName)); + } + return new FieldDescriptor(owner, field); + } + + @Override + public Class getOwner() { + return owner(); + } + + @Override + public Field getMember() { + return field(); + } + + @Override + public ResolvableType getType() { + return ResolvableType.forField(field(), owner()); + } + + } + + interface KotlinMemberDescriptor extends MemberDescriptor { + + KProperty1 getKotlinProperty(); + + } + + /** + * Value object describing a Kotlin property in the context of an owning class. + */ + record KPropertyReferenceDescriptor(Class owner, KProperty1 property) implements KotlinMemberDescriptor { + + static KPropertyReferenceDescriptor create(Class owner, KProperty1 property) { + return new KPropertyReferenceDescriptor(owner, property); + } + + @Override + public KProperty1 getKotlinProperty() { + return property(); + } + + @Override + public Class getOwner() { + return owner(); + } + + @Override + public Member getMember() { + + Method javaGetter = ReflectJvmMapping.getJavaGetter(property()); + if (javaGetter != null) { + return javaGetter; + } + + Field javaField = ReflectJvmMapping.getJavaField(property()); + + if (javaField != null) { + return javaField; + } + + throw new IllegalStateException("Cannot resolve member for property '%s'".formatted(property().getName())); + } + + @Override + public ResolvableType getType() { + + Member member = getMember(); + + if (member instanceof Method m) { + return ResolvableType.forMethodReturnType(m, owner()); + } + + return ResolvableType.forField((Field) member, owner()); + } + + } + + /** + * Value object describing a Kotlin property in the context of an owning class. + */ + record KPropertyPathDescriptor(KProperty1 property) implements KotlinMemberDescriptor { + + static KPropertyPathDescriptor create(KProperty1 propertyReference) { + return new KPropertyPathDescriptor(propertyReference); + } + + @Override + public KProperty1 getKotlinProperty() { + return property(); + } + + @Override + public Class getOwner() { + return getMember().getDeclaringClass(); + } + + @Override + public Member getMember() { + + Method javaGetter = ReflectJvmMapping.getJavaGetter(property()); + if (javaGetter != null) { + return javaGetter; + } + + Field javaField = ReflectJvmMapping.getJavaField(property()); + + if (javaField != null) { + return javaField; + } + + throw new IllegalStateException("Cannot resolve member for property '%s'".formatted(property().getName())); + } + + @Override + public ResolvableType getType() { + + Member member = getMember(); + + if (member instanceof Method m) { + return ResolvableType.forMethodReturnType(m, getOwner()); + } + + return ResolvableType.forField((Field) member, getOwner()); + } + + } + +} diff --git a/src/main/java/org/springframework/data/core/PropertyPath.java b/src/main/java/org/springframework/data/core/PropertyPath.java index 26a06299c9..68b7235e01 100644 --- a/src/main/java/org/springframework/data/core/PropertyPath.java +++ b/src/main/java/org/springframework/data/core/PropertyPath.java @@ -19,21 +19,67 @@ import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; - import org.springframework.data.util.Streamable; import org.springframework.util.Assert; /** * Abstraction of a {@link PropertyPath} within a domain class. + *

+ * Property paths allow to navigate nested properties such as {@code address.city.name} and provide metadata for each + * segment of the path. Paths are represented in dot-path notation and are resolved from an owning type, for example: + * + *

+ * PropertyPath.from("address.city.name", Person.class);
+ * 
+ * + * Paths are cached on a best-effort basis using a weak reference cache to avoid repeated introspection if GC pressure + * permits. + *

+ * A typed variant of {@link PropertyPath} is available as {@link TypedPropertyPath} through + * {@link #of(PropertyReference)} to leverage method references for a type-safe usage across application code. * * @author Oliver Gierke * @author Christoph Strobl * @author Mark Paluch * @author Mariusz Mączkowski * @author Johannes Englmeier + * @see PropertyReference + * @see TypedPropertyPath + * @see java.beans.PropertyDescriptor */ public interface PropertyPath extends Streamable { + /** + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans property. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference. + * + * @param property the method reference referring to a Java beans property. + * @param owning type. + * @param

property type. + * @return the typed property path. + * @since 4.1 + */ + static TypedPropertyPath of(PropertyReference property) { + return TypedPropertyPaths.of(property); + } + + /** + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans collection property. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference. + * + * @param property the method reference referring to a property. + * @param owning type. + * @param

property type. + * @return the typed property path. + * @since 4.1 + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + static TypedPropertyPath ofMany(PropertyReference> property) { + return (TypedPropertyPath) TypedPropertyPaths.of(property); + } + /** * Returns the owning type of the {@link PropertyPath}. * @@ -42,22 +88,25 @@ public interface PropertyPath extends Streamable { TypeInformation getOwningType(); /** - * Returns the first part of the {@link PropertyPath}. For example: + * Returns the current property path segment (i.e. first part of {@link #toDotPath()}). + *

+ * For example: * *

-	 * PropertyPath.from("a.b.c", Some.class).getSegment();
+	 * PropertyPath.from("address.city.name", Person.class).getSegment();
 	 * 
* - * results in {@code a}. + * results in {@code address}. * - * @return the name will never be {@literal null}. + * @return the current property path segment. */ String getSegment(); /** - * Returns the leaf property of the {@link PropertyPath}. + * Returns the leaf property of the {@link PropertyPath}. Either this property if the path ends here or the last + * property in the chain. * - * @return will never be {@literal null}. + * @return leaf property. */ default PropertyPath getLeafProperty() { @@ -74,57 +123,72 @@ default PropertyPath getLeafProperty() { * Returns the type of the leaf property of the current {@link PropertyPath}. * * @return will never be {@literal null}. + * @see #getLeafProperty() */ default Class getLeafType() { return getLeafProperty().getType(); } /** - * Returns the actual type of the property. Will return the plain resolved type for simple properties, the component - * type for any {@link Iterable} or the value type of a {@link java.util.Map}. + * Returns the actual type of the property at this segment. Will return the plain resolved type for simple properties, + * the component type for any {@link Iterable} or the value type of {@link java.util.Map} properties. * * @return the actual type of the property. + * @see #getTypeInformation() + * @see TypeInformation#getRequiredActualType() */ default Class getType() { return getTypeInformation().getRequiredActualType().getType(); } /** - * Returns the type information of the property. + * Returns the type information for the property at this segment. * - * @return the actual type of the property. + * @return the type information for the property at this segment. */ TypeInformation getTypeInformation(); /** - * Returns the {@link PropertyPath} path that results from removing the first element of the current one. For example: + * Returns whether the current property path segment is a collection. + * + * @return {@literal true} if the current property path segment is a collection. + * @see #getTypeInformation() + * @see TypeInformation#isCollectionLike() + */ + default boolean isCollection() { + return getTypeInformation().isCollectionLike(); + } + + /** + * Returns the next {@code PropertyPath} segment in the property path chain. * *
-	 * PropertyPath.from("a.b.c", Some.class).next().toDotPath();
+	 * PropertyPath.from("address.city.name", Person.class).next().toDotPath();
 	 * 
* - * results in the output: {@code b.c} + * results in the output: {@code city.name}. * - * @return the next nested {@link PropertyPath} or {@literal null} if no nested {@link PropertyPath} available. + * @return the next {@code PropertyPath} or {@literal null} if the path does not contain further segments. * @see #hasNext() */ @Nullable PropertyPath next(); /** - * Returns whether there is a nested {@link PropertyPath}. If this returns {@literal true} you can expect - * {@link #next()} to return a non- {@literal null} value. + * Returns {@literal true} if the property path contains further segments or {@literal false} if the path ends at this + * segment. * - * @return + * @return {@literal true} if the property path contains further segments or {@literal false} if the path ends at this + * segment. */ default boolean hasNext() { return next() != null; } /** - * Returns the {@link PropertyPath} in dot notation. + * Returns the {@code PropertyPath} in dot notation. * - * @return the {@link PropertyPath} in dot notation. + * @return the {@code PropertyPath} in dot notation. */ default String toDotPath() { @@ -133,16 +197,7 @@ default String toDotPath() { } /** - * Returns whether the {@link PropertyPath} is actually a collection. - * - * @return {@literal true} whether the {@link PropertyPath} is actually a collection. - */ - default boolean isCollection() { - return getTypeInformation().isCollectionLike(); - } - - /** - * Returns the {@link PropertyPath} for the path nested under the current property. + * Returns the {@code PropertyPath} for the path nested under the current property. * * @param path must not be {@literal null} or empty. * @return will never be {@literal null}. @@ -157,20 +212,19 @@ default PropertyPath nested(String path) { } /** - * Returns an {@link Iterator Iterator of PropertyPath} that iterates over all the partial property paths with the - * same leaf type but decreasing length. For example: + * Returns an {@link Iterator Iterator of PropertyPath} that iterates over all property path segments. For example: * *
-	 * PropertyPath propertyPath = PropertyPath.from("a.b.c", Some.class);
-	 * propertyPath.forEach(p -> p.toDotPath());
+	 * PropertyPath path = PropertyPath.from("address.city.name", Person.class);
+	 * path.forEach(p -> p.toDotPath());
 	 * 
* * results in the dot paths: * *
-	 * a.b.c
-	 * b.c
-	 * c
+	 * address.city.name     (this object)
+	 * city.name             (next() object)
+	 * city.name             (next().next() object)
 	 * 
*/ @Override diff --git a/src/main/java/org/springframework/data/core/PropertyPathUtil.java b/src/main/java/org/springframework/data/core/PropertyPathUtil.java new file mode 100644 index 0000000000..b46c47d84e --- /dev/null +++ b/src/main/java/org/springframework/data/core/PropertyPathUtil.java @@ -0,0 +1,138 @@ +/* + * Copyright 2025 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 java.io.Serializable; +import java.lang.invoke.SerializedLambda; +import java.lang.reflect.Method; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * Utility class for {@link PropertyPath} and {@link PropertyReference} implementations. + * + * @author Mark Paluch + * @since 4.1 + */ +public class PropertyPathUtil { + + /** + * Resolve a {@link PropertyPath} from a {@link Serializable} lambda implementing a functional interface accepting a + * single method argument and returning a value. The form of the interface must follow a design aligned with + * {@link org.springframework.core.convert.converter.Converter} or {@link java.util.function.Function}. + * + * @param obj the serializable lambda object. + * @return the resolved property path. + */ + public static PropertyPath resolve(Object obj) { + + Assert.isInstanceOf(Serializable.class, obj, "Object must be Serializable"); + + return TypedPropertyPaths.of(new SerializableWrapper((Serializable) obj)); + } + + private record SerializableWrapper(Serializable serializable) implements PropertyReference { + + @Override + public @Nullable Object get(Object obj) { + return null; + } + + // serializable bridge + public SerializedLambda writeReplace() { + + Method method = ReflectionUtils.findMethod(serializable.getClass(), "writeReplace"); + + if (method == null) { + throw new InvalidDataAccessApiUsageException( + "Cannot find writeReplace method on " + serializable.getClass().getName()); + } + + ReflectionUtils.makeAccessible(method); + return (SerializedLambda) ReflectionUtils.invokeMethod(method, serializable); + } + + } + + /** + * Compute the hash code for the given {@link PropertyPath} based on its {@link Object#toString() string} + * representation. + * + * @param path the property path. + * @return property path hash code. + */ + static int hashCode(PropertyPath path) { + return path.toString().hashCode(); + } + + /** + * Compute the hash code for the given {@link PropertyReference} based on its {@link Object#toString() string} + * representation. + * + * @param property the property reference + * @return property reference hash code. + */ + static int hashCode(PropertyReference property) { + return Objects.hash(property.getOwningType(), property.getName()); + } + + /** + * Equality check for {@link PropertyPath} implementations based on their owning type and string representation. + * + * @param self the property path. + * @param o the other object. + * @return {@literal true} if both are equal; {@literal false} otherwise. + */ + static boolean equals(PropertyPath self, @Nullable Object o) { + + if (self == o) { + return true; + } + + if (!(o instanceof PropertyPath that)) { + return false; + } + + return Objects.equals(self.getOwningType(), that.getOwningType()) + && Objects.equals(self.toString(), that.toString()); + } + + /** + * Equality check for {@link PropertyReference} implementations based on their owning type and name. + * + * @param self the property path. + * @param o the other object. + * @return {@literal true} if both are equal; {@literal false} otherwise. + */ + static boolean equals(PropertyReference self, @Nullable Object o) { + + if (self == o) { + return true; + } + + if (!(o instanceof PropertyReference that)) { + return false; + } + + return Objects.equals(self.getOwningType(), that.getOwningType()) && Objects.equals(self.getName(), that.getName()); + } + +} diff --git a/src/main/java/org/springframework/data/core/PropertyReference.java b/src/main/java/org/springframework/data/core/PropertyReference.java new file mode 100644 index 0000000000..97e374bf7a --- /dev/null +++ b/src/main/java/org/springframework/data/core/PropertyReference.java @@ -0,0 +1,205 @@ +/* + * Copyright 2025 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 java.io.Serializable; + +import org.jspecify.annotations.Nullable; + +/** + * Interface providing type-safe property references. + *

+ * This functional interface is typically implemented through method references that allow for compile-time type safety + * and refactoring support. Instead of string-based property names that are easy to miss when changing the domain model, + * {@code PropertyReference} leverages Java's declarative method references to ensure type-safe property access. + *

+ * Create a typed property reference using the static factory method {@link #property(PropertyReference)} with a method + * reference, for example: + * + *

+ * PropertyReference.property(Person::getName);
+ * 
+ * + * The resulting object can be used to obtain the {@link #getName() property name} and to interact with the target + * property. Typed references can be used to compose {@link TypedPropertyPath property paths} to navigate nested object + * structures using {@link #then(PropertyReference)}: + * + *
+ * TypedPropertyPath<Person, String> city = PropertyReference.of(Person::getAddress).then(Address::getCity);
+ * 
+ *

+ * The generic type parameters preserve type information across the property path chain: {@code T} represents the owning + * type of the current segment (or the root type for composed paths), while {@code P} represents the property value type + * at this segment. Composition automatically flows type information forward, ensuring that {@code then()} preserves the + * full chain's type safety. + *

+ * Implement {@code PropertyReference} using method references (strongly recommended) or lambdas that directly access a + * property getter. Constructor references, method calls with parameters, and complex expressions are not supported and + * result in {@link org.springframework.dao.InvalidDataAccessApiUsageException}. Unlike method references, introspection + * of lambda expressions requires bytecode analysis of the declaration site classes and thus depends on their + * availability at runtime. + * + * @param the owning type of this property. + * @param

the property value type. + * @author Mark Paluch + * @since 4.1 + * @see #property(PropertyReference) + * @see #then(PropertyReference) + * @see #of(PropertyReference) + * @see #ofMany(PropertyReference) + * @see TypedPropertyPath + * @see java.beans.PropertyDescriptor + */ +@FunctionalInterface +public interface PropertyReference extends Serializable { + + /** + * Syntax sugar to create a {@link PropertyReference} from a method reference to a Java beans property. Suitable for + * static imports. + *

+ * This method returns a resolved {@link PropertyReference} by introspecting the given method reference. + * + * @param property the method reference to a Java beans property. + * @param owning type. + * @param

property type. + * @return the typed property reference. + */ + static PropertyReference property(PropertyReference property) { + return of(property); + } + + /** + * Syntax sugar to create a {@link PropertyReference} from a method reference to a Java beans property. + *

+ * This method returns a resolved {@link PropertyReference} by introspecting the given method reference. + * + * @param property the method reference to a Java beans property. + * @param owning type. + * @param

property type. + * @return the typed property reference. + */ + static PropertyReference of(PropertyReference property) { + return PropertyReferences.of(property); + } + + /** + * Syntax sugar to create a {@link PropertyReference} from a method reference to a Java beans property. + *

+ * This method returns a resolved {@link PropertyReference} by introspecting the given method reference. Note that + * {@link #get(Object)} becomes unusable for collection properties as the property type adapted from + * {@code Iterable <P>} and a single {@code P} cannot represent a collection of items. + * + * @param property the method reference to a Java beans property. + * @param owning type. + * @param

property type. + * @return the typed property reference. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + static PropertyReference ofMany(PropertyReference> property) { + return (PropertyReference) PropertyReferences.of(property); + } + + /** + * Get the property value for the given object. + * + * @param obj the object to get the property value from. + * @return the property value. + */ + @Nullable + P get(T obj); + + /** + * Returns the owning type of the referenced property. + * + * @return the owningType will never be {@literal null}. + */ + default TypeInformation getOwningType() { + return PropertyReferences.of(this).getOwningType(); + } + + /** + * Returns the name of the property. + * + * @return the current property name. + */ + default String getName() { + return PropertyReferences.of(this).getName(); + } + + /** + * Returns the actual type of the property at this segment. Will return the plain resolved type for simple properties, + * the component type for any {@link Iterable} or the value type of {@link java.util.Map} properties. + * + * @return the actual type of the property. + * @see #getTypeInformation() + * @see TypeInformation#getRequiredActualType() + */ + default Class getType() { + return getTypeInformation().getRequiredActualType().getType(); + } + + /** + * Returns the type information for the property at this segment. + * + * @return the type information for the property at this segment. + */ + default TypeInformation getTypeInformation() { + return PropertyReferences.of(this).getTypeInformation(); + } + + /** + * Returns whether the property is a collection. + * + * @return {@literal true} if the property is a collection. + * @see #getTypeInformation() + * @see TypeInformation#isCollectionLike() + */ + default boolean isCollection() { + return getTypeInformation().isCollectionLike(); + } + + /** + * Extend the property to a property path by appending the {@code next} path segment and return a new property path + * instance. + * + * @param next the next property path segment as method reference accepting the owner object {@code P} type and + * returning {@code N} as result of accessing the property. + * @param the new property value type. + * @return a new composed {@code TypedPropertyPath}. + */ + default TypedPropertyPath then(PropertyReference next) { + return TypedPropertyPaths.compose(this, next); + } + + /** + * Extend the property to a property path by appending the {@code next} path segment and return a new property path + * instance. + *

+ * Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from + * {@code Iterable <P>} and a single {@code P} cannot represent a collection of items. + * + * @param next the next property path segment as method reference accepting the owner object {@code P} type and + * returning {@code N} as result of accessing the property. + * @param the new property value type. + * @return a new composed {@code TypedPropertyPath}. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + default TypedPropertyPath thenMany( + PropertyReference> next) { + return (TypedPropertyPath) TypedPropertyPaths.compose(this, next); + } + +} diff --git a/src/main/java/org/springframework/data/core/PropertyReferences.java b/src/main/java/org/springframework/data/core/PropertyReferences.java new file mode 100644 index 0000000000..98540ecb00 --- /dev/null +++ b/src/main/java/org/springframework/data/core/PropertyReferences.java @@ -0,0 +1,320 @@ +/* + * Copyright 2025 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 kotlin.reflect.KProperty; + +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.BeanUtils; +import org.springframework.core.KotlinDetector; +import org.springframework.core.ResolvableType; +import org.springframework.data.core.MemberDescriptor.FieldDescriptor; +import org.springframework.data.core.MemberDescriptor.MethodDescriptor; +import org.springframework.util.ConcurrentReferenceHashMap; + +/** + * Utility class to read metadata and resolve {@link PropertyReference} instances. + * + * @author Mark Paluch + * @since 4.1 + */ +class PropertyReferences { + + private static final Map, ResolvedPropertyReference>> resolved = new WeakHashMap<>(); + + private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyReference.class, + TypedPropertyPath.class, TypedPropertyPaths.class, PropertyReferences.class); + + /** + * Introspect {@link PropertyReference} and return an introspected {@link ResolvedPropertyReference} variant. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static PropertyReference of(PropertyReference lambda) { + + if (lambda instanceof ResolvedPropertyReferenceSupport) { + return lambda; + } + + Map, ResolvedPropertyReference> cache; + synchronized (resolved) { + cache = resolved.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); + } + + return (PropertyReference) cache.computeIfAbsent(lambda, + o -> new ResolvedPropertyReference(o, read(lambda))); + } + + /** + * Retrieve {@link PropertyMetadata} for a given {@link PropertyReference}. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static PropertyReference of(PropertyReference delegate, + PropertyMetadata metadata) { + + if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyMetadata kmp) { + return new ResolvedKPropertyReference(kmp.getProperty(), metadata); + } + + return new ResolvedPropertyReference<>(delegate, metadata); + } + + private static PropertyMetadata read(PropertyReference lambda) { + + MemberDescriptor reference = reader.read(lambda); + + if (KotlinDetector.isKotlinReflectPresent() + && reference instanceof MemberDescriptor.KotlinMemberDescriptor kProperty) { + + if (kProperty instanceof MemberDescriptor.KPropertyPathDescriptor) { + throw new IllegalArgumentException("PropertyReference " + kProperty.getKotlinProperty().getName() + + " is a property path. Use a single property reference."); + } + + return KPropertyMetadata.of(kProperty); + } + + if (reference instanceof MethodDescriptor method) { + return PropertyMetadata.ofMethod(method); + } + + return PropertyMetadata.ofField((FieldDescriptor) reference); + } + + /** + * Metadata describing a property reference including its owner type, property type, and name. + */ + static class PropertyMetadata { + + private final TypeInformation owner; + private final String property; + private final TypeInformation propertyType; + + PropertyMetadata(Class owner, String property, ResolvableType propertyType) { + this(TypeInformation.of(owner), property, TypeInformation.of(propertyType)); + } + + PropertyMetadata(TypeInformation owner, String property, TypeInformation propertyType) { + this.owner = owner; + this.property = property; + this.propertyType = propertyType; + } + + /** + * Create a new {@code PropertyMetadata} from a method. + */ + public static PropertyMetadata ofMethod(MethodDescriptor descriptor) { + + return resolveProperty(descriptor, + () -> new IllegalArgumentException("Cannot find PropertyDescriptor from method '%s.%s()'" + .formatted(descriptor.owner().getName(), descriptor.getMember().getName()))); + } + + /** + * Create a new {@code PropertyMetadata} from a field. + */ + public static PropertyMetadata ofField(FieldDescriptor field) { + return new PropertyMetadata(field.owner(), field.getMember().getName(), field.getType()); + } + + /** + * Resolve {@code PropertyMetadata} from a method descriptor by introspecting bean metadata and return metadata if + * available otherwise throw an exception supplied by {@code exceptionSupplier}. + * + * @param method the method descriptor. + * @param exceptionSupplier supplier for exception to be thrown when property cannot be resolved. + * @return metadata for the resolved property. + * @see BeanUtils + */ + public static PropertyMetadata resolveProperty(MethodDescriptor method, + Supplier exceptionSupplier) { + + PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method.method()); + String methodName = method.method().getName(); + + if (descriptor == null) { + + String propertyName = getPropertyName(methodName); + TypeInformation owner = TypeInformation.of(method.owner()); + TypeInformation fallback = owner.getProperty(propertyName); + + if (fallback != null) { + return new PropertyMetadata(owner, propertyName, fallback); + } + + throw exceptionSupplier.get(); + } + + return new PropertyMetadata(method.owner(), descriptor.getName(), method.getType()); + } + + private static String getPropertyName(String methodName) { + + if (methodName.startsWith("is")) { + return Introspector.decapitalize(methodName.substring(2)); + } else if (methodName.startsWith("get")) { + return Introspector.decapitalize(methodName.substring(3)); + } + + return methodName; + } + + public TypeInformation owner() { + return owner; + } + + public String property() { + return property; + } + + public TypeInformation propertyType() { + return propertyType; + } + + } + + /** + * Kotlin-specific {@link PropertyMetadata} implementation. + */ + static class KPropertyMetadata extends PropertyMetadata { + + private final KProperty property; + + KPropertyMetadata(Class owner, KProperty property, ResolvableType propertyType) { + super(owner, property.getName(), propertyType); + this.property = property; + } + + /** + * Create a new {@code KPropertyMetadata}. + */ + public static KPropertyMetadata of(MemberDescriptor.KotlinMemberDescriptor descriptor) { + return new KPropertyMetadata(descriptor.getOwner(), descriptor.getKotlinProperty(), + descriptor.getType()); + } + + public KProperty getProperty() { + return property; + } + } + + /** + * A {@link PropertyReference} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

the property type. + */ + static abstract class ResolvedPropertyReferenceSupport implements PropertyReference { + + private final PropertyMetadata metadata; + private final String toString; + + ResolvedPropertyReferenceSupport(PropertyMetadata metadata) { + this.metadata = metadata; + this.toString = metadata.owner().getType().getSimpleName() + "." + getName(); + } + + @Override + @SuppressWarnings("unchecked") + public TypeInformation getOwningType() { + return (TypeInformation) metadata.owner(); + } + + @Override + public String getName() { + return metadata.property(); + } + + @Override + @SuppressWarnings("unchecked") + public TypeInformation

getTypeInformation() { + return (TypeInformation

) metadata.propertyType(); + } + + @Override + public boolean equals(@Nullable Object obj) { + return PropertyPathUtil.equals(this, obj); + } + + @Override + public int hashCode() { + return PropertyPathUtil.hashCode(this); + } + + @Override + public String toString() { + return toString; + } + + } + + /** + * A {@link PropertyReference} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

the property type. + */ + static class ResolvedPropertyReference extends ResolvedPropertyReferenceSupport { + + private final PropertyReference function; + + ResolvedPropertyReference(PropertyReference function, PropertyMetadata metadata) { + super(metadata); + this.function = function; + } + + @Override + public @Nullable P get(T obj) { + return function.get(obj); + } + + } + + /** + * A Kotlin-based {@link PropertyReference} implementation that caches resolved metadata to avoid repeated + * introspection. + * + * @param the owning type. + * @param

the property type. + */ + static class ResolvedKPropertyReference extends ResolvedPropertyReferenceSupport { + + private final KProperty

property; + + @SuppressWarnings("unchecked") + ResolvedKPropertyReference(KPropertyMetadata metadata) { + this((KProperty

) metadata.getProperty(), metadata); + } + + ResolvedKPropertyReference(KProperty

property, PropertyMetadata metadata) { + super(metadata); + this.property = property; + } + + @Override + public @Nullable P get(T obj) { + return property.call(obj); + } + + } + +} diff --git a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java new file mode 100644 index 0000000000..4ddeef66fe --- /dev/null +++ b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java @@ -0,0 +1,569 @@ +/* + * Copyright 2025 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 kotlin.jvm.JvmClassMappingKt; +import kotlin.jvm.internal.PropertyReference; +import kotlin.reflect.KClass; +import kotlin.reflect.KProperty1; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandleInfo; +import java.lang.invoke.SerializedLambda; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; +import org.springframework.asm.ClassReader; +import org.springframework.asm.ClassVisitor; +import org.springframework.asm.Label; +import org.springframework.asm.MethodVisitor; +import org.springframework.asm.Opcodes; +import org.springframework.asm.SpringAsmInfo; +import org.springframework.asm.Type; +import org.springframework.core.KotlinDetector; +import org.springframework.core.SpringProperties; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.core.MemberDescriptor.KPropertyPathDescriptor; +import org.springframework.data.core.MemberDescriptor.KPropertyReferenceDescriptor; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Reader to extract references to fields and methods from serializable lambda expressions and method references using + * lambda serialization and bytecode analysis. Allows introspection of property access patterns expressed through + * functional interfaces without executing the lambda's behavior. Their declarative nature makes method references in + * general and a constrained subset of lambda expressions suitable to declare property references in the sense of Java + * Beans properties. Although lambdas and method references are primarily used in a declarative functional programming + * models to express behavior, lambda serialization allows for further introspection such as parsing the lambda method + * bytecode for property or member access information and not taking the functional behavior into account. + *

+ * The actual interface is not constrained by a base type, however the object must: + *

    + *
  • Implement a functional Java interface
  • + *
  • Must be the top-level lambda (i.e. not wrapped or a functional composition)
  • + *
  • Implement Serializable (either through the actual interface or through inference)
  • + *
  • Declare a single method to be implemented
  • + *
  • Accept a single method argument and return a value
  • + *
+ * Ideally, the interface has a similar format to {@link Function}, for example: + * + *
+ * interface XtoYFunction (optional: extends Serializable) {
+ *   Y <method-name>(X someArgument);
+ * }
+ * 
+ *

+ * Supported patterns + *

    + *
  • Method references: {@code Person::getName}
  • + *
  • Property access lambdas: {@code person -> person.getName()}
  • + *
  • Field access lambdas: {@code person -> person.name}
  • + *
+ * Unsupported patterns + *
    + *
  • Constructor references: {@code Person::new}
  • + *
  • Methods with arguments: {@code person -> person.setAge(25)}
  • + *
  • Lambda expressions that do more than property access, e.g. {@code person -> { person.setAge(25); return + * person.getName(); }}
  • + *
  • Arithmetic operations, arbitrary calls
  • + *
  • Functional composition: {@code Function.andThen(...)}
  • + *
+ * + * @author Mark Paluch + * @since 4.1 + */ +class SerializableLambdaReader { + + /** + * System property that instructs Spring Data to filter stack traces of exceptions thrown during SAM parsing. + */ + public static final String FILTER_STACK_TRACE = "spring.data.lambda-reader.filter-stacktrace"; + + /** + * System property that instructs Spring Data to include suppressed exceptions during SAM parsing. + */ + public static final String INCLUDE_SUPPRESSED_EXCEPTIONS = "spring.data.lambda-reader.include-suppressed-exceptions"; + + private static final Log LOGGER = LogFactory.getLog(SerializableLambdaReader.class); + private static final boolean filterStackTrace = isEnabled(FILTER_STACK_TRACE, true); + private static final boolean includeSuppressedExceptions = isEnabled(INCLUDE_SUPPRESSED_EXCEPTIONS, false); + + private final List> entryPoints; + + SerializableLambdaReader(Class... entryPoints) { + this.entryPoints = Arrays.asList(entryPoints); + } + + private static boolean isEnabled(String property, boolean defaultValue) { + + String value = SpringProperties.getProperty(property); + return StringUtils.hasText(value) ? Boolean.parseBoolean(value) : defaultValue; + } + + /** + * Read the given lambda object and extract a reference to a {@link Member} such as a field or method. + *

+ * Ideally used with an interface resembling {@link java.util.function.Function}. + * + * @param lambdaObject the actual lambda object, must be {@link java.io.Serializable}. + * @return the member reference. + * @throws InvalidDataAccessApiUsageException if the lambda object does not contain a valid property reference or hits + * any of the mentioned limitations. + */ + public MemberDescriptor read(Object lambdaObject) { + + SerializedLambda lambda = serialize(lambdaObject); + + if (isKotlinPropertyReference(lambda)) { + return KotlinDelegate.read(lambda); + } + + assertNotConstructor(lambda); + + try { + + // method reference + if ((lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeInterface) + && !lambda.getImplMethodName().startsWith("lambda$")) { + return MemberDescriptor.ofMethodReference(lambdaObject.getClass().getClassLoader(), lambda); + } + + // all other lambda forms + if (lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeStatic + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual) { + return getMemberDescriptor(lambdaObject, lambda); + } + } catch (ReflectiveOperationException | IOException e) { + throw new InvalidDataAccessApiUsageException("Cannot extract method or field", e); + } + + throw new InvalidDataAccessApiUsageException("Cannot extract method or field from: " + lambdaObject + + ". The given value is not a lambda or method reference."); + } + + private void assertNotConstructor(SerializedLambda lambda) { + + if (lambda.getImplMethodKind() == MethodHandleInfo.REF_newInvokeSpecial + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeSpecial) { + + InvalidDataAccessApiUsageException e = new InvalidDataAccessApiUsageException( + "Method reference must not be a constructor call"); + + if (filterStackTrace) { + e.setStackTrace(filterStackTrace(e.getStackTrace(), null)); + } + throw e; + } + } + + private MemberDescriptor getMemberDescriptor(Object lambdaObject, SerializedLambda lambda) throws IOException { + + String implClass = Type.getObjectType(lambda.getImplClass()).getClassName(); + Type owningType = Type.getArgumentTypes(lambda.getImplMethodSignature())[0]; + String classFileName = implClass.replace('.', '/') + ".class"; + InputStream classFile = ClassLoader.getSystemResourceAsStream(classFileName); + + if (classFile == null) { + throw new IllegalStateException("Cannot find class file '%s' for lambda introspection".formatted(classFileName)); + } + + try (classFile) { + + ClassReader cr = new ClassReader(classFile); + LambdaReadingVisitor classVisitor = new LambdaReadingVisitor(lambdaObject.getClass().getClassLoader(), + lambda.getImplMethodName(), owningType); + cr.accept(classVisitor, ClassReader.SKIP_FRAMES); + return classVisitor.getMemberReference(lambda); + } + } + + private static SerializedLambda serialize(Object lambda) { + + try { + Method method = lambda.getClass().getDeclaredMethod("writeReplace"); + method.setAccessible(true); + return (SerializedLambda) method.invoke(lambda); + } catch (ReflectiveOperationException e) { + throw new InvalidDataAccessApiUsageException( + "Not a lambda: " + (lambda instanceof Enum ? lambda.getClass().getName() + "#" + lambda : lambda), e); + } + } + + private static boolean isKotlinPropertyReference(SerializedLambda lambda) { + + return KotlinDetector.isKotlinReflectPresent() // + && lambda.getCapturedArgCount() == 1 // + && lambda.getCapturedArg(0) != null // + && KotlinDetector.isKotlinType(lambda.getCapturedArg(0).getClass()); + } + + /** + * Delegate to detect and read Kotlin property references. + *

+ * Inner class delays loading of Kotlin classes. + */ + static class KotlinDelegate { + + public static MemberDescriptor read(SerializedLambda lambda) { + + Object captured = lambda.getCapturedArg(0); + + if (captured instanceof PropertyReference propRef // + && propRef.getOwner() instanceof KClass owner // + && captured instanceof KProperty1 kProperty) { + return new KPropertyReferenceDescriptor(JvmClassMappingKt.getJavaClass(owner), kProperty); + } + + if (captured instanceof KPropertyPath propRef) { + return KPropertyPathDescriptor.create(propRef); + } + + throw new InvalidDataAccessApiUsageException("Cannot extract MemberDescriptor from: " + lambda); + } + + } + + class LambdaReadingVisitor extends ClassVisitor { + + private final String implMethodName; + private final LambdaMethodVisitor methodVisitor; + + public LambdaReadingVisitor(ClassLoader classLoader, String implMethodName, Type owningType) { + super(SpringAsmInfo.ASM_VERSION); + this.implMethodName = implMethodName; + this.methodVisitor = new LambdaMethodVisitor(classLoader, owningType); + } + + public MemberDescriptor getMemberReference(SerializedLambda lambda) { + return methodVisitor.resolve(lambda); + } + + @Override + public @Nullable MethodVisitor visitMethod(int access, String name, String desc, String signature, + String[] exceptions) { + return name.equals(implMethodName) ? methodVisitor : null; + } + + } + + class LambdaMethodVisitor extends MethodVisitor { + + private static final Pattern HEX_PATTERN = Pattern.compile("[0-9a-f]+"); + + private static final Set BOXING_TYPES = Set.of(Type.getInternalName(Integer.class), + Type.getInternalName(Long.class), Type.getInternalName(Short.class), Type.getInternalName(Byte.class), + Type.getInternalName(Float.class), Type.getInternalName(Double.class), Type.getInternalName(Character.class), + Type.getInternalName(Boolean.class)); + + private static final String BOXING_METHOD = "valueOf"; + + private final ClassLoader classLoader; + private final Type owningType; + private int line; + private final List memberDescriptors = new ArrayList<>(); + private final Set errors = new LinkedHashSet<>(); + + public LambdaMethodVisitor(ClassLoader classLoader, Type owningType) { + super(SpringAsmInfo.ASM_VERSION); + this.classLoader = classLoader; + this.owningType = owningType; + } + + @Override + public void visitLineNumber(int line, Label start) { + this.line = line; + } + + @Override + public void visitInsn(int opcode) { + + // allow primitive and object return + if (opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) { + return; + } + + // we don't care about stack manipulation + if (opcode >= Opcodes.DUP && opcode <= Opcodes.DUP2_X2) { + return; + } + + // no-op + if (opcode == Opcodes.NOP) { + return; + } + + visitLdcInsn(""); + } + + @Override + public void visitLdcInsn(Object value) { + errors.add(new ReadingError(line, + "Code loads a constant. Only method calls to getters, record components, or field access allowed.", null)); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String name, String descriptor) { + + if (opcode == Opcodes.PUTSTATIC || opcode == Opcodes.PUTFIELD) { + errors.add(new ReadingError(line, String.format("Code attempts to set field '%s'", name), null)); + return; + } + + Type fieldType = Type.getType(descriptor); + + try { + this.memberDescriptors + .add(MemberDescriptor.ofField(classLoader, owningType.getClassName(), name, fieldType.getClassName())); + } catch (ReflectiveOperationException e) { + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Failed to resolve field '%s.%s'".formatted(owner, name), e); + } + errors.add(new ReadingError(line, e.getMessage())); + } + } + + @Override + public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { + + if (opcode == Opcodes.INVOKESPECIAL && name.equals("")) { + errors.add(new ReadingError(line, "Constructor calls not supported.", null)); + return; + } + + int count = Type.getArgumentCount(descriptor); + + if (count != 0) { + + if (BOXING_TYPES.contains(owner) && name.equals(BOXING_METHOD)) { + return; + } + + errors.add(new ReadingError(line, "Method references must invoke no-arg methods only")); + return; + } + + Type ownerType = Type.getObjectType(owner); + if (!ownerType.equals(this.owningType)) { + + Type[] argumentTypes = Type.getArgumentTypes(descriptor); + String signature = Arrays.stream(argumentTypes).map(Type::getClassName).collect(Collectors.joining(", ")); + errors.add(new ReadingError(line, + "Cannot derive method reference from '%s#%s(%s)': Method calls allowed on owning type '%s' only." + .formatted(ownerType.getClassName(), name, signature, this.owningType.getClassName()))); + return; + } + + try { + this.memberDescriptors.add(MemberDescriptor.ofMethod(classLoader, owningType.getClassName(), name)); + } catch (ReflectiveOperationException e) { + + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Failed to resolve method '%s.%s'".formatted(owner, name), e); + } + errors.add(new ReadingError(line, e.getMessage())); + } + } + + /** + * Resolve a {@link MemberDescriptor} from a {@link SerializedLambda}. + * + * @param lambda the lambda to introspect. + * @return the resolved member descriptor. + */ + public MemberDescriptor resolve(SerializedLambda lambda) { + + // TODO composite path information + if (errors.isEmpty()) { + + if (memberDescriptors.isEmpty()) { + throw new InvalidDataAccessApiUsageException("There is no method or field access"); + } + + return memberDescriptors.get(memberDescriptors.size() - 1); + } + + if (lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeStatic + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual) { + + String methodName = getDeclaringMethodName(lambda); + + InvalidDataAccessApiUsageException e = new InvalidDataAccessApiUsageException( + "Cannot resolve property path%n%nError%s:%n".formatted(errors.size() > 1 ? "s" : "") + errors.stream() + .map(ReadingError::message).map(LambdaMethodVisitor::formatMessage).collect(Collectors.joining())); + + if (includeSuppressedExceptions) { + for (ReadingError error : errors) { + if (error.e != null) { + e.addSuppressed(error.e); + } + } + } + + if (filterStackTrace) { + e.setStackTrace( + filterStackTrace(e.getStackTrace(), userCode -> createSynthetic(lambda, methodName, userCode))); + } + + throw e; + } + + throw new InvalidDataAccessApiUsageException("Error resolving " + errors); + } + + private static String formatMessage(String args) { + + String[] split = args.split("%n".formatted()); + StringBuilder builder = new StringBuilder(); + + for (int i = 0; i < split.length; i++) { + + if (i == 0) { + builder.append("\t* %s%n".formatted(split[i])); + } else { + builder.append("\t %s%n".formatted(split[i])); + } + } + + return builder.toString(); + } + + private static String getDeclaringMethodName(SerializedLambda lambda) { + + String methodName = lambda.getImplMethodName(); + if (methodName.startsWith("lambda$")) { + methodName = methodName.substring("lambda$".length()); + + if (methodName.contains("$")) { + methodName = methodName.substring(0, methodName.lastIndexOf('$')); + } + + if (methodName.contains("$")) { + String probe = methodName.substring(methodName.lastIndexOf('$') + 1); + if (HEX_PATTERN.matcher(probe).matches()) { + methodName = methodName.substring(0, methodName.lastIndexOf('$')); + } + } + } + return methodName; + } + + private StackTraceElement createSynthetic(SerializedLambda lambda, String methodName, StackTraceElement userCode) { + + Type type = Type.getObjectType(lambda.getCapturingClass()); + + return new StackTraceElement(null, userCode.getModuleName(), userCode.getModuleVersion(), type.getClassName(), + methodName, ClassUtils.getShortName(type.getClassName()) + ".java", errors.iterator().next().line()); + } + } + + private StackTraceElement[] filterStackTrace(StackTraceElement[] stackTrace, + @Nullable Function syntheticSupplier) { + + int filterIndex = findEntryPoint(stackTrace); + + if (filterIndex != -1) { + + int offset = syntheticSupplier == null ? 0 : 1; + + StackTraceElement[] copy = new StackTraceElement[(stackTrace.length - filterIndex) + offset]; + System.arraycopy(stackTrace, filterIndex, copy, offset, stackTrace.length - filterIndex); + + if (syntheticSupplier != null) { + StackTraceElement userCode = copy[1]; + StackTraceElement synthetic = syntheticSupplier.apply(userCode); + copy[0] = synthetic; + } + return copy; + } + + return stackTrace; + } + + private int findEntryPoint(StackTraceElement[] stackTrace) { + + int filterIndex = -1; + + for (int i = 0; i < stackTrace.length; i++) { + + if (matchesEntrypoint(stackTrace[i].getClassName())) { + filterIndex = i; + } + } + + return filterIndex; + } + + private boolean matchesEntrypoint(String className) { + + if (className.equals(getClass().getName())) { + return true; + } + + for (Class entryPoint : entryPoints) { + if (className.equals(entryPoint.getName())) { + return true; + } + } + + return false; + } + + /** + * Value object for reading errors. + * + * @param line + * @param message + * @param e + */ + record ReadingError(int line, String message, @Nullable Exception e) { + + ReadingError(int line, String message) { + this(line, message, null); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ReadingError that)) { + return false; + } + if (!ObjectUtils.nullSafeEquals(e, that.e)) { + return false; + } + return ObjectUtils.nullSafeEquals(message, that.message); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHash(message, e); + } + + } + +} diff --git a/src/main/java/org/springframework/data/core/SimplePropertyPath.java b/src/main/java/org/springframework/data/core/SimplePropertyPath.java index be48ce3637..a8dfb2211a 100644 --- a/src/main/java/org/springframework/data/core/SimplePropertyPath.java +++ b/src/main/java/org/springframework/data/core/SimplePropertyPath.java @@ -148,16 +148,6 @@ public boolean isCollection() { return isCollection; } - @Override - public SimplePropertyPath nested(String path) { - - Assert.hasText(path, "Path must not be null or empty"); - - String lookup = toDotPath().concat(".").concat(path); - - return SimplePropertyPath.from(lookup, owningType); - } - @Override public Iterator iterator() { @@ -187,45 +177,12 @@ public boolean hasNext() { @Override public boolean equals(@Nullable Object o) { - - if (this == o) { - return true; - } - - if (!(o instanceof SimplePropertyPath that)) { - return false; - } - - if (isCollection != that.isCollection) { - return false; - } - - return Objects.equals(this.owningType, that.owningType) && Objects.equals(this.name, that.name) - && Objects.equals(this.typeInformation, that.typeInformation) - && Objects.equals(this.actualTypeInformation, that.actualTypeInformation) && Objects.equals(next, that.next); + return PropertyPathUtil.equals(this, o); } @Override public int hashCode() { - return Objects.hash(owningType, name, typeInformation, actualTypeInformation, isCollection, next); - } - - /** - * Returns the next {@link SimplePropertyPath}. - * - * @return the next {@link SimplePropertyPath}. - * @throws IllegalStateException it there's no next one. - */ - private SimplePropertyPath requiredNext() { - - SimplePropertyPath result = next; - - if (result == null) { - throw new IllegalStateException( - "No next path available; Clients should call hasNext() before invoking this method"); - } - - return result; + return PropertyPathUtil.hashCode(this); } /** diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java new file mode 100644 index 0000000000..66d788b2ce --- /dev/null +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -0,0 +1,247 @@ +/* + * Copyright 2025 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 java.io.Serializable; +import java.util.Collections; +import java.util.Iterator; + +import org.jspecify.annotations.Nullable; + +/** + * Interface providing type-safe property path navigation through method references expressions. + *

+ * This functional interface extends {@link PropertyPath} to provide compile-time type safety and refactoring support. + * Instead of using {@link PropertyPath#from(String, TypeInformation) string-based property paths} for textual property + * representation that are easy to miss when changing the domain model, {@code TypedPropertyPath} leverages Java's + * declarative method references to ensure type-safe property access. + *

+ * Create a typed property path using the static factory method {@link #of(TypedPropertyPath)} with a method reference , + * for example: + * + *

+ * TypedPropertyPath.path(Person::getName);
+ * 
+ * + * The resulting object can be used to obtain the {@link #toDotPath() dot-path} and to interact with the targeting + * property. Typed paths allow for composition to navigate nested object structures using + * {@link #then(PropertyReference)}: + * + *
+ * // factory method chaining
+ * TypedPropertyPath<Person, String> city = TypedPropertyPath.path(Person::getAddress, Address::getCity);
+ *
+ * // fluent API
+ * TypedPropertyPath<Person, String> city = TypedPropertyPath.of(Person::getAddress).then(Address::getCity);
+ * 
+ *

+ * The generic type parameters preserve type information across the property path chain: {@code T} represents the owning + * type of the current segment (or the root type for composed paths), while {@code P} represents the property value type + * at this segment. Composition automatically flows type information forward, ensuring that {@code then()} preserves the + * full chain's type safety. + *

+ * Implement {@code TypedPropertyPath} using method references (strongly recommended)s that directly access a property + * getter. Constructor references, method calls with parameters, and complex expressions are not supported and result in + * {@link org.springframework.dao.InvalidDataAccessApiUsageException}. Unlike method references, introspection of lambda + * expressions requires bytecode analysis of the declaration site classes and thus depends on their availability at + * runtime. + * + * @param the owning type of this path segment; the root type for composed paths. + * @param

the property value type at this path segment. + * @author Mark Paluch + * @since 4.1 + * @see #path(PropertyReference) + * @see #of(PropertyReference) + * @see #ofMany(PropertyReference) + * @see #then(PropertyReference) + * @see PropertyReference + * @see PropertyPath#of(PropertyReference) + */ +@FunctionalInterface +public interface TypedPropertyPath extends PropertyPath, Serializable { + + /** + * Syntax sugar to create a {@link TypedPropertyPath} from a property described as method reference to a Java beans + * property. Suitable for static imports. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference. + * + * @param property the method reference to a Java beans property. + * @param owning type. + * @param

property type. + * @return the typed property path. + */ + static TypedPropertyPath path(PropertyReference property) { + return TypedPropertyPaths.of(property); + } + + /** + * Syntax sugar to create a composed {@link TypedPropertyPath} from properties described as method reference to a Java + * beans property. Suitable for static imports. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method references. + * + * @param owner the owner property. + * @param child the nested property. + * @param owning type. + * @param

property type. + * @return the typed property path. + */ + static TypedPropertyPath path(PropertyReference owner, + PropertyReference child) { + return TypedPropertyPaths.compose(owner, child); + } + + /** + * Syntax sugar to create a composed {@link TypedPropertyPath} from properties described as method reference to a Java + * beans property. Suitable for static imports. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method references. + * + * @param owner the owner property. + * @param child1 the first nested property. + * @param child2 the second nested property. + * @param owning type. + * @param

property type. + * @return the typed property path. + */ + static TypedPropertyPath path(PropertyReference owner, + PropertyReference child1, PropertyReference child2) { + return path(owner, child1).then(child2); + } + + /** + * Syntax sugar to create a composed {@link TypedPropertyPath} from properties described as method reference to a Java + * beans property. Suitable for static imports. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method references. + * + * @param owner the owner property. + * @param child1 the first nested property. + * @param child2 the second nested property. + * @param child3 the third nested property. + * @param owning type. + * @param

property type. + * @return the typed property path. + */ + static TypedPropertyPath path( + PropertyReference owner, PropertyReference child1, + PropertyReference child2, PropertyReference child3) { + return path(owner, child1, child2).then(child3); + } + + /** + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans property. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference. + * + * @param property the method reference to a Java beans property. + * @param owning type. + * @param

property type. + * @return the typed property path. + */ + static TypedPropertyPath of(TypedPropertyPath property) { + return TypedPropertyPaths.of(property); + } + + /** + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans collection property. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference. + *

+ * Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from + * {@code Iterable <P>} and a single {@code P} cannot represent a collection of items. + * + * @param property the method reference to a Java beans collection property. + * @param owning type. + * @param

property type. + * @return the typed property path. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + static TypedPropertyPath ofMany(TypedPropertyPath> property) { + return (TypedPropertyPath) TypedPropertyPaths.of(property); + } + + /** + * Get the property value for the given object. + * + * @param obj the object to get the property value from. + * @return the property value. + */ + @Nullable + P get(T obj); + + @Override + default TypeInformation getOwningType() { + return TypedPropertyPaths.of(this).getOwningType(); + } + + @Override + default String getSegment() { + return TypedPropertyPaths.of(this).getSegment(); + } + + @Override + default TypeInformation

getTypeInformation() { + return TypedPropertyPaths.of(this).getTypeInformation(); + } + + @Override + @Nullable + default PropertyPath next() { + return null; + } + + @Override + default boolean hasNext() { + return false; + } + + @Override + default Iterator iterator() { + return Collections.singletonList((PropertyPath) this).iterator(); + } + + /** + * Extend the property path by appending the {@code next} path segment and return a new property path instance. + * + * @param next the next property path segment as method reference accepting the owner object {@code P} type and + * returning {@code N} as result of accessing a property. + * @param the new property value type. + * @return a new composed {@code TypedPropertyPath}. + */ + default TypedPropertyPath then(PropertyReference next) { + return TypedPropertyPaths.compose(this, next); + } + + /** + * Extend the property path by appending the {@code next} path segment and return a new property path instance. + *

+ * Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from + * {@code Iterable <P>} and a single {@code P} cannot represent a collection of items. + * + * @param next the next property path segment as method reference accepting the owner object {@code P} type and + * returning {@code N} as result of accessing a property. + * @param the new property value type. + * @return a new composed {@code TypedPropertyPath}. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + default TypedPropertyPath thenMany( + PropertyReference> next) { + return (TypedPropertyPath) TypedPropertyPaths.compose(this, PropertyReference.of(next)); + } + +} diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java new file mode 100644 index 0000000000..d82c35904b --- /dev/null +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -0,0 +1,571 @@ +/* + * Copyright 2025 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 kotlin.reflect.KProperty; +import kotlin.reflect.KProperty1; +import kotlin.reflect.jvm.internal.KProperty1Impl; +import kotlin.reflect.jvm.internal.KPropertyImpl; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.KotlinDetector; +import org.springframework.core.ResolvableType; +import org.springframework.data.core.MemberDescriptor.KPropertyPathDescriptor; +import org.springframework.data.core.MemberDescriptor.KPropertyReferenceDescriptor; +import org.springframework.data.core.MemberDescriptor.MethodDescriptor; +import org.springframework.data.core.PropertyReferences.PropertyMetadata; +import org.springframework.util.CompositeIterator; +import org.springframework.util.ConcurrentReferenceHashMap; + +/** + * Utility class to read metadata and resolve {@link TypedPropertyPath} instances. + * + * @author Mark Paluch + * @since 4.1 + */ +class TypedPropertyPaths { + + private static final Map>> resolved = new WeakHashMap<>(); + + private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyPath.class, + PropertyReference.class, PropertyReferences.class, TypedPropertyPath.class, TypedPropertyPaths.class); + + /** + * Compose a {@link TypedPropertyPath} by appending {@code next}. + */ + public static TypedPropertyPath compose(PropertyReference owner, PropertyReference next) { + return compose(of(owner), of(next)); + } + + /** + * Compose a {@link TypedPropertyPath} by appending {@code next}. + */ + public static TypedPropertyPath compose(TypedPropertyPath owner, PropertyReference next) { + return compose(of(owner), of(next)); + } + + /** + * Compose a {@link TypedPropertyPath} by appending {@code next}. + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + public static TypedPropertyPath compose(TypedPropertyPath owner, TypedPropertyPath next) { + + if (owner instanceof ForwardingPropertyPath fwd) { + + List paths = fwd.stream().map(ForwardingPropertyPath::getSelf).collect(Collectors.toList()); + Collections.reverse(paths); + + ForwardingPropertyPath result = null; + for (PropertyPath path : paths) { + + if (result == null) { + result = new ForwardingPropertyPath((TypedPropertyPath) path, next); + } else { + result = new ForwardingPropertyPath((TypedPropertyPath) path, result); + } + } + + return result; + } + + return new ForwardingPropertyPath<>(of(owner), next); + } + + /** + * Introspect {@link PropertyReference} and return an introspected {@link ResolvedTypedPropertyPath} variant. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static TypedPropertyPath of(PropertyReference lambda) { + + if (lambda instanceof Resolved) { + return (TypedPropertyPath) lambda; + } + + Map, TypedPropertyPath> cache; + synchronized (resolved) { + cache = (Map) resolved.computeIfAbsent(lambda.getClass().getClassLoader(), + k -> new ConcurrentReferenceHashMap<>()); + } + + return (TypedPropertyPath) cache.computeIfAbsent(lambda, TypedPropertyPaths::doResolvePropertyReference); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static TypedPropertyPath doResolvePropertyReference(PropertyReference lambda) { + + if (lambda instanceof PropertyReferences.ResolvedPropertyReferenceSupport resolved) { + return new PropertyReferenceWrapper<>(resolved); + } + + PropertyMetadata metadata = read(lambda); + + if (KotlinDetector.isKotlinReflectPresent()) { + if (metadata instanceof KPropertyPathMetadata kMetadata + && kMetadata.getProperty() instanceof KPropertyPath ref) { + return KotlinDelegate.of(ref); + } + } + + 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<>()); + } + + 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); + } + } + + return new ResolvedTypedPropertyPath<>(lambda, metadata); + } + + private static PropertyMetadata read(Object lambda) { + + MemberDescriptor reference = reader.read(lambda); + + if (KotlinDetector.isKotlinReflectPresent()) { + + if (reference instanceof KPropertyReferenceDescriptor descriptor) { + return KPropertyPathMetadata.of(descriptor); + } + + if (reference instanceof KPropertyPathDescriptor descriptor) { + return KPropertyPathMetadata.of(descriptor); + } + } + + if (reference instanceof MethodDescriptor method) { + return PropertyMetadata.ofMethod(method); + } + + return PropertyMetadata.ofField((MemberDescriptor.MethodDescriptor.FieldDescriptor) reference); + } + + /** + * Kotlin-specific {@link PropertyMetadata} implementation supporting composed {@link KProperty property paths}. + */ + static class KPropertyPathMetadata extends PropertyMetadata { + + private final KProperty property; + + KPropertyPathMetadata(Class owner, KProperty property, ResolvableType propertyType) { + super(owner, property.getName(), propertyType); + this.property = property; + } + + /** + * Create a new {@code KPropertyPathMetadata}. + */ + public static KPropertyPathMetadata of(KPropertyReferenceDescriptor descriptor) { + return new KPropertyPathMetadata(descriptor.getOwner(), descriptor.property(), descriptor.getType()); + } + + /** + * Create a new {@code KPropertyPathMetadata}. + */ + public static KPropertyPathMetadata of(KPropertyPathDescriptor descriptor) { + return new KPropertyPathMetadata(descriptor.getOwner(), descriptor.property(), descriptor.getType()); + } + + public KProperty getProperty() { + return property; + } + } + + /** + * Delegate to handle property path composition of single-property and property-path KProperty1 references. + */ + static class KotlinDelegate { + + @SuppressWarnings({ "rawtypes", "unchecked" }) + public static TypedPropertyPath of(Object property) { + + if (property instanceof KPropertyPath paths) { + + TypedPropertyPath parent = of(paths.getProperty()); + TypedPropertyPath child = of(paths.getLeaf()); + + return TypedPropertyPaths.compose(parent, child); + } + + if (property instanceof KPropertyImpl impl) { + + Class owner = impl.getJavaField() != null ? impl.getJavaField().getDeclaringClass() + : impl.getGetter().getCaller().getMember().getDeclaringClass(); + KPropertyPathMetadata metadata = TypedPropertyPaths.KPropertyPathMetadata + .of(MemberDescriptor.KPropertyReferenceDescriptor.create(owner, (KProperty1) impl)); + return new TypedPropertyPaths.ResolvedKPropertyPath(metadata); + } + + if (property instanceof KProperty1 kProperty) { + + if (kProperty.getGetter().getProperty() instanceof KProperty1Impl impl) { + return of(impl); + } + + throw new IllegalArgumentException("Property " + kProperty.getName() + " is not a KProperty"); + } + + throw new IllegalArgumentException("Property " + property + " is not a KProperty"); + } + + } + + /** + * Marker interface to indicate a resolved and processed property path. + */ + interface Resolved { + + } + + /** + * A {@link TypedPropertyPath} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

the property type. + */ + static abstract class ResolvedTypedPropertyPathSupport implements TypedPropertyPath, Resolved { + + private final PropertyMetadata metadata; + private final List list; + private final String toString; + + ResolvedTypedPropertyPathSupport(PropertyMetadata metadata) { + this.metadata = metadata; + this.list = List.of(this); + this.toString = metadata.owner().getType().getSimpleName() + "." + toDotPath(); + } + + @Override + @SuppressWarnings("unchecked") + public TypeInformation getOwningType() { + return (TypeInformation) metadata.owner(); + } + + @Override + public String getSegment() { + return metadata.property(); + } + + @Override + @SuppressWarnings("unchecked") + public TypeInformation

getTypeInformation() { + return (TypeInformation

) metadata.propertyType(); + } + + @Override + public Iterator iterator() { + return list.iterator(); + } + + @Override + public Stream stream() { + return list.stream(); + } + + @Override + public List toList() { + return list; + } + + @Override + public boolean equals(@Nullable Object obj) { + return PropertyPathUtil.equals(this, obj); + } + + @Override + public int hashCode() { + return PropertyPathUtil.hashCode(this); + } + + @Override + public String toString() { + return toString; + } + + } + + /** + * Wrapper for {@link PropertyReference}. + * + * @param the owning type. + * @param

the property type. + */ + static class PropertyReferenceWrapper implements TypedPropertyPath, Resolved { + + private final PropertyReference property; + private final List self; + + public PropertyReferenceWrapper(PropertyReference property) { + this.property = property; + this.self = List.of(this); + } + + @Override + public @Nullable P get(T obj) { + return property.get(obj); + } + + @Override + public TypeInformation getOwningType() { + return property.getOwningType(); + } + + @Override + public String getSegment() { + return property.getName(); + } + + @Override + @SuppressWarnings("unchecked") + public TypeInformation

getTypeInformation() { + return (TypeInformation

) property.getTypeInformation(); + } + + @Override + public Iterator iterator() { + return self.iterator(); + } + + @Override + public Stream stream() { + return self.stream(); + } + + @Override + public List toList() { + return self; + } + + @Override + public boolean equals(@Nullable Object obj) { + return PropertyPathUtil.equals(this, obj); + } + + @Override + public int hashCode() { + return PropertyPathUtil.hashCode(this); + } + + @Override + public String toString() { + return property.toString(); + } + + } + + /** + * A {@link TypedPropertyPath} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

the property type. + */ + static class ResolvedPropertyReference extends ResolvedTypedPropertyPathSupport { + + private final PropertyReference function; + + ResolvedPropertyReference(PropertyReference function, PropertyMetadata metadata) { + super(metadata); + this.function = function; + } + + @Override + public @Nullable P get(T obj) { + return function.get(obj); + } + + } + + /** + * A {@link TypedPropertyPath} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

the property type. + */ + static class ResolvedTypedPropertyPath extends ResolvedTypedPropertyPathSupport { + + private final TypedPropertyPath function; + + ResolvedTypedPropertyPath(TypedPropertyPath function, PropertyMetadata metadata) { + super(metadata); + this.function = function; + } + + @Override + public @Nullable P get(T obj) { + return function.get(obj); + } + + } + + /** + * A Kotlin-based {@link TypedPropertyPath} implementation that caches resolved metadata to avoid repeated + * introspection. + * + * @param the owning type. + * @param

the property type. + */ + static class ResolvedKPropertyPath extends ResolvedTypedPropertyPathSupport { + + private final KProperty

property; + + @SuppressWarnings("unchecked") + ResolvedKPropertyPath(KPropertyPathMetadata metadata) { + this((KProperty

) metadata.getProperty(), metadata); + } + + ResolvedKPropertyPath(KProperty

property, PropertyMetadata metadata) { + super(metadata); + this.property = property; + } + + @Override + public @Nullable P get(T obj) { + return property.call(obj); + } + + } + + /** + * Forwarding implementation to compose a linked {@link TypedPropertyPath} graph. + * + * @param self + * @param nextSegment + * @param leaf cached leaf property. + * @param toStringRepresentation cached toString representation. + */ + record ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment, + PropertyPath leaf, String dotPath, String toStringRepresentation) implements TypedPropertyPath, Resolved { + + public ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment) { + this(self, nextSegment, nextSegment.getLeafProperty(), getDotPath(self, nextSegment), + getToString(self, nextSegment)); + } + + private static String getToString(PropertyPath self, PropertyPath nextSegment) { + return self.getOwningType().getType().getSimpleName() + "." + getDotPath(self, nextSegment); + } + + private static String getDotPath(PropertyPath self, PropertyPath nextSegment) { + return self.getSegment() + "." + nextSegment.toDotPath(); + } + + public static PropertyPath getSelf(PropertyPath path) { + return path instanceof ForwardingPropertyPath fwd ? fwd.self() : path; + } + + @Override + public @Nullable P get(T obj) { + M intermediate = self.get(obj); + return intermediate != null ? nextSegment.get(intermediate) : null; + } + + @Override + public TypeInformation getOwningType() { + return self.getOwningType(); + } + + @Override + public String getSegment() { + return self.getSegment(); + } + + @Override + public PropertyPath getLeafProperty() { + return leaf; + } + + @Override + public String toDotPath() { + return self.getSegment() + "." + nextSegment.toDotPath(); + } + + @Override + @SuppressWarnings("unchecked") + public TypeInformation

getTypeInformation() { + return (TypeInformation

) self.getTypeInformation(); + } + + @Override + public boolean hasNext() { + return true; + } + + @Override + public PropertyPath next() { + return nextSegment; + } + + @Override + public Iterator iterator() { + + CompositeIterator iterator = new CompositeIterator<>(); + iterator.add(List.of((PropertyPath) this).iterator()); + iterator.add(nextSegment.iterator()); + return iterator; + } + + @Override + public boolean equals(@Nullable Object o) { + return PropertyPathUtil.equals(this, o); + } + + @Override + public int hashCode() { + return PropertyPathUtil.hashCode(this); + } + + @Override + public String toString() { + return toStringRepresentation; + } + } + +} diff --git a/src/main/java/org/springframework/data/domain/ExampleMatcher.java b/src/main/java/org/springframework/data/domain/ExampleMatcher.java index e59698a719..cc842b8f39 100644 --- a/src/main/java/org/springframework/data/domain/ExampleMatcher.java +++ b/src/main/java/org/springframework/data/domain/ExampleMatcher.java @@ -15,6 +15,7 @@ */ package org.springframework.data.domain; +import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashMap; import java.util.Map; @@ -24,6 +25,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.lang.CheckReturnValue; import org.springframework.lang.Contract; import org.springframework.util.Assert; @@ -77,6 +79,21 @@ static ExampleMatcher matchingAll() { return new TypedExampleMatcher().withMode(MatchMode.ALL); } + /** + * Returns a copy of this {@link ExampleMatcher} with the specified {@code propertyPaths}. This instance is immutable + * and unaffected by this method call. + * + * @param ignoredPaths must not be {@literal null} and not empty. + * @return new instance of {@link ExampleMatcher}. + * @since 4.1 + */ + @Contract("_ -> new") + @CheckReturnValue + default ExampleMatcher withIgnorePaths(TypedPropertyPath... ignoredPaths) { + return withIgnorePaths(Arrays.stream(ignoredPaths).map(TypedPropertyPath::of).map(TypedPropertyPath::toDotPath) + .toArray(String[]::new)); + } + /** * Returns a copy of this {@link ExampleMatcher} with the specified {@code propertyPaths}. This instance is immutable * and unaffected by this method call. @@ -122,6 +139,22 @@ default ExampleMatcher withIgnoreCase() { @CheckReturnValue ExampleMatcher withIgnoreCase(boolean defaultIgnoreCase); + /** + * Returns a copy of this {@link ExampleMatcher} with the specified {@code GenericPropertyMatcher} for the + * {@code propertyPath}. This instance is immutable and unaffected by this method call. + * + * @param propertyPath must not be {@literal null}. + * @param matcherConfigurer callback to configure a {@link GenericPropertyMatcher}, must not be {@literal null}. + * @return new instance of {@link ExampleMatcher}. + * @since 4.1 + */ + @Contract("_, _ -> new") + @CheckReturnValue + default ExampleMatcher withMatcher(TypedPropertyPath propertyPath, + MatcherConfigurer matcherConfigurer) { + return withMatcher(propertyPath.toDotPath(), matcherConfigurer); + } + /** * Returns a copy of this {@link ExampleMatcher} with the specified {@code GenericPropertyMatcher} for the * {@code propertyPath}. This instance is immutable and unaffected by this method call. @@ -143,6 +176,21 @@ default ExampleMatcher withMatcher(String propertyPath, MatcherConfigurer new") + @CheckReturnValue + default ExampleMatcher withMatcher(TypedPropertyPath propertyPath, + GenericPropertyMatcher genericPropertyMatcher) { + return withMatcher(propertyPath.toDotPath(), genericPropertyMatcher); + } + /** * Returns a copy of this {@link ExampleMatcher} with the specified {@code GenericPropertyMatcher} for the * {@code propertyPath}. This instance is immutable and unaffected by this method call. @@ -155,6 +203,22 @@ default ExampleMatcher withMatcher(String propertyPath, MatcherConfigurer new") + @CheckReturnValue + default ExampleMatcher withTransformer(TypedPropertyPath propertyPath, + PropertyValueTransformer propertyValueTransformer) { + return withTransformer(propertyPath.toDotPath(), propertyValueTransformer); + } + /** * Returns a copy of this {@link ExampleMatcher} with the specified {@code PropertyValueTransformer} for the * {@code propertyPath}. @@ -167,6 +231,20 @@ default ExampleMatcher withMatcher(String propertyPath, MatcherConfigurer new") + @CheckReturnValue + default ExampleMatcher withIgnoreCase(TypedPropertyPath... propertyPaths) { + return withIgnoreCase(Arrays.stream(propertyPaths).map(TypedPropertyPath::of).map(TypedPropertyPath::toDotPath) + .toArray(String[]::new)); + } + /** * Returns a copy of this {@link ExampleMatcher} with ignore case sensitivity for the {@code propertyPaths}. This * instance is immutable and unaffected by this method call. diff --git a/src/main/java/org/springframework/data/domain/Sort.java b/src/main/java/org/springframework/data/domain/Sort.java index f3d39640dd..44aad6693d 100644 --- a/src/main/java/org/springframework/data/domain/Sort.java +++ b/src/main/java/org/springframework/data/domain/Sort.java @@ -29,6 +29,8 @@ import org.jspecify.annotations.Nullable; +import org.springframework.data.core.PropertyPath; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.data.util.MethodInvocationRecorder; import org.springframework.data.util.MethodInvocationRecorder.Recorded; import org.springframework.data.util.Streamable; @@ -94,6 +96,24 @@ public static Sort by(String... properties) { : new Sort(DEFAULT_DIRECTION, Arrays.asList(properties)); } + /** + * Creates a new {@link Sort} for the given properties. + * + * @param properties must not be {@literal null}. + * @return {@link Sort} for the given properties. + * @since 4.1 + */ + @SafeVarargs + public static Sort by(TypedPropertyPath... properties) { + + Assert.notNull(properties, "Properties must not be null"); + + return properties.length == 0 // + ? Sort.unsorted() // + : new Sort(DEFAULT_DIRECTION, Arrays.stream(properties).map(TypedPropertyPath::of).map(PropertyPath::toDotPath) + .collect(Collectors.toList())); + } + /** * Creates a new {@link Sort} for the given {@link Order}s. * @@ -120,6 +140,25 @@ public static Sort by(Order... orders) { return new Sort(Arrays.asList(orders)); } + /** + * Creates a new {@link Sort} for the given {@link Direction} and properties. + * + * @param direction must not be {@literal null}. + * @param properties must not be {@literal null}. + * @return {@link Sort} for the given {@link Direction} and properties. + * @since 4.1 + */ + @SafeVarargs + public static Sort by(Direction direction, TypedPropertyPath... properties) { + + Assert.notNull(direction, "Direction must not be null"); + Assert.notNull(properties, "Properties must not be null"); + Assert.isTrue(properties.length > 0, "At least one property must be given"); + + return by(Arrays.stream(properties).map(TypedPropertyPath::of).map(PropertyPath::toDotPath) + .map(it -> new Order(direction, it)).toList()); + } + /** * Creates a new {@link Sort} for the given {@link Direction} and properties. * @@ -144,7 +183,9 @@ public static Sort by(Direction direction, String... properties) { * @param type must not be {@literal null}. * @return {@link TypedSort} for the given type. * @since 2.2 + * @deprecated since 4.1 in favor of {@link Sort#by(TypedPropertyPath[])}. */ + @Deprecated(since = "4.1") public static TypedSort sort(Class type) { return new TypedSort<>(type); } @@ -460,6 +501,17 @@ public Order(@Nullable Direction direction, String property, boolean ignoreCase, this.nullHandling = nullHandling; } + /** + * Creates a new {@link Order} instance. Takes a property path. Direction defaults to + * {@link Sort#DEFAULT_DIRECTION}. + * + * @param propertyPath must not be {@literal null} or empty. + * @since 4.1 + */ + public static Order by(TypedPropertyPath propertyPath) { + return by(TypedPropertyPath.of(propertyPath).toDotPath()); + } + /** * Creates a new {@link Order} instance. Takes a single property. Direction defaults to * {@link Sort#DEFAULT_DIRECTION}. @@ -471,6 +523,17 @@ public static Order by(String property) { return new Order(DEFAULT_DIRECTION, property); } + /** + * Creates a new {@link Order} instance. Takes a property path. Direction is {@link Direction#ASC} and NullHandling + * {@link NullHandling#NATIVE}. + * + * @param propertyPath must not be {@literal null} or empty. + * @since 4.1 + */ + public static Order asc(TypedPropertyPath propertyPath) { + return asc(TypedPropertyPath.of(propertyPath).toDotPath()); + } + /** * Creates a new {@link Order} instance. Takes a single property. Direction is {@link Direction#ASC} and * NullHandling {@link NullHandling#NATIVE}. @@ -482,6 +545,17 @@ public static Order asc(String property) { return new Order(Direction.ASC, property, DEFAULT_NULL_HANDLING); } + /** + * Creates a new {@link Order} instance. Takes a property path. Direction is {@link Direction#DESC} and NullHandling + * {@link NullHandling#NATIVE}. + * + * @param propertyPath must not be {@literal null} or empty. + * @since 4.1 + */ + public static Order desc(TypedPropertyPath propertyPath) { + return desc(TypedPropertyPath.of(propertyPath).toDotPath()); + } + /** * Creates a new {@link Order} instance. Takes a single property. Direction is {@link Direction#DESC} and * NullHandling {@link NullHandling#NATIVE}. @@ -562,6 +636,19 @@ public Order reverse() { return with(this.direction == Direction.ASC ? Direction.DESC : Direction.ASC); } + /** + * Returns a new {@link Order} with the {@code propertyPath} applied. + * + * @param propertyPath must not be {@literal null} or empty. + * @return a new {@link Order} with the {@code propertyPath} applied. + * @since 4.1 + */ + @Contract("_ -> new") + @CheckReturnValue + public Order withProperty(TypedPropertyPath propertyPath) { + return withProperty(TypedPropertyPath.of(propertyPath).toDotPath()); + } + /** * Returns a new {@link Order} with the {@code property} name applied. * @@ -575,6 +662,20 @@ public Order withProperty(String property) { return new Order(this.direction, property, this.ignoreCase, this.nullHandling); } + /** + * Returns a new {@link Sort} instance for the given properties using {@link #getDirection()}. + * + * @param propertyPaths properties to sort by. + * @return a new {@link Sort} instance for the given properties using {@link #getDirection()}. + * @since 4.1 + */ + @Contract("_ -> new") + @CheckReturnValue + public Sort withProperties(TypedPropertyPath... propertyPaths) { + return Sort.by(this.direction, + Arrays.stream(propertyPaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + /** * Returns a new {@link Sort} instance for the given properties using {@link #getDirection()}. * @@ -696,7 +797,9 @@ public String toString() { * @author Oliver Gierke * @since 2.2 * @soundtrack The Intersphere - Linger (The Grand Delusion) + * @deprecated since 4.1 in favor of {@link Sort#by(org.springframework.data.core.TypedPropertyPath...)} */ + @Deprecated(since = "4.1") public static class TypedSort extends Sort { private static final @Serial long serialVersionUID = -3550403511206745880L; diff --git a/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java b/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java index 37a4404d03..798228e554 100644 --- a/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java +++ b/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java @@ -44,7 +44,11 @@ * @author Johannes Englmeier * @since 2.2 * @soundtrack The Intersphere - Don't Think Twice (The Grand Delusion) + * @deprecated since 4.1 in favor of {@link org.springframework.data.core.PropertyReference} and + * {@link org.springframework.data.core.TypedPropertyPath} infrastructure and limitations imposed by + * subclass proxy limitations. */ +@Deprecated(since = "4.1") public class MethodInvocationRecorder { public static PropertyNameDetectionStrategy DEFAULT = DefaultPropertyNameDetectionStrategy.INSTANCE; diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt new file mode 100644 index 0000000000..b3c306feaa --- /dev/null +++ b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2018-2025 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 kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 + + +/** + * Extension for [KProperty] providing an `toPath` function to render a [KProperty] in dot notation. + * + * @author Mark Paluch + * @since 2.5 + * @see org.springframework.data.core.PropertyPath.toDotPath + */ +fun KProperty<*>.toDotPath(): String = asString(this) + +/** + * Extension for [KProperty1] providing an `toPropertyPath` function to create a [TypedPropertyPath]. + * + * @author Mark Paluch + * @since 4.1 + * @see org.springframework.data.core.PropertyPath.toDotPath + */ +fun KProperty1.toPropertyPath(): TypedPropertyPath = + KTypedPropertyPath.of(this) + +/** + * Builds [KPropertyPath] from Property References. + * Refer to a nested property in an embeddable or association. + * + * For example, referring to the field "author.name": + * ``` + * Book::author / Author::name isEqualTo "Herman Melville" + * ``` + * @author Tjeu Kayim + * @author Yoann de Martino + * @since 4.1 + */ +@JvmName("div") +@Suppress("UNCHECKED_CAST") +operator fun KProperty1.div(other: KProperty1): KProperty1 = + KSinglePropertyReference(this, other) as KProperty1 + +/** + * Builds [KPropertyPath] from Property References. + * Refer to a nested property in an embeddable or association. + * + * Note, that this function is different from [div] above in the + * way that it represents a division operator overloading for + * child references, where parent to child reference relation is 1-M, not 1-1. + * It implies that parent defines a [Collection] of children. + ** + * For example, referring to the field "books.title": + * ``` + * Author::books / Book::title contains "Bartleby" + * ``` + * @author Mikhail Polivakha + * @since 4.1 + */ +@JvmName("divIterable") +@Suppress("UNCHECKED_CAST") +operator fun KProperty1?>.div(other: KProperty1): KProperty1 = + KIterablePropertyReference(this, other) as KProperty1 diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt new file mode 100644 index 0000000000..796d60c29d --- /dev/null +++ b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2018-2025 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. + */ +@file:Suppress("UNCHECKED_CAST") + +package org.springframework.data.core + +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 + +/** + * Abstraction of a property path consisting of [KProperty1]. + * + * @author Tjeu Kayim + * @author Mark Paluch + * @author Yoann de Martino + * @since 4.1 + */ +internal interface KPropertyPath : KProperty1 { + val property: KProperty1 + val leaf: KProperty1<*, P> + +} + +/** + * Abstraction of a single property reference wrapping [KProperty1]. + * + * @author Mark Paluch + * @since 4.1 + */ +internal class KSinglePropertyReference( + val parent: KProperty1, + val child: KProperty1 +) : KProperty1 by child as KProperty1, KPropertyPath { + + override fun get(receiver: T): P { + + val get = parent.get(receiver) + + if (get != null) { + return child.get(get) + } + + throw NullPointerException("Parent property returned null") + } + + override fun getDelegate(receiver: T): Any { + return child + } + + override val property: KProperty1 + get() = parent + override val leaf: KProperty1<*, P> + get() = child + +} + +/** + * Abstraction of a property path that consists of parent [KProperty], + * and child property [KProperty], where parent [parent] has an [Iterable] + * of children, so it represents 1-M mapping. + * + * @author Mikhail Polivakha + * @since 4.1 + */ +internal class KIterablePropertyReference( + val parent: KProperty1?>, + val child: KProperty1 +) : KProperty1 by child as KProperty1, KPropertyPath { + + override fun get(receiver: T): P { + throw UnsupportedOperationException("Collection retrieval not supported") + } + + override fun getDelegate(receiver: T): Any { + throw UnsupportedOperationException("Collection retrieval not supported") + } + + override val property: KProperty1 + get() = parent + override val leaf: KProperty1<*, P> + get() = child + +} + + +/** + * Recursively construct field name for a nested property. + * + * @author Tjeu Kayim + * @author Mikhail Polivakha + * @since 4.1 + */ +internal fun asString(property: KProperty<*>): String { + + return when (property) { + is KPropertyPath<*, *> -> + "${asString(property.property)}.${property.leaf.name}" + + else -> property.name + } + +} + diff --git a/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt new file mode 100644 index 0000000000..123477b1ca --- /dev/null +++ b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2025 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. + */ +@file:Suppress( + "UPPER_BOUND_VIOLATED_BASED_ON_JAVA_ANNOTATIONS", + "NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS", "UNCHECKED_CAST" +) + +package org.springframework.data.core + +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaGetter + +/** + * Extension function to compose a [TypedPropertyPath] with a [KProperty1]. + * + * @since 4.1 + */ +fun PropertyReference.then(next: KProperty1): TypedPropertyPath { + val nextPath = KPropertyReference.of(next) as PropertyReference + return TypedPropertyPaths.compose(this, nextPath) +} + +/** + * Extension function to compose a [TypedPropertyPath] with a [KProperty]. + * + * @since 4.1 + */ +fun PropertyReference.then(next: KProperty): TypedPropertyPath { + val nextPath = KPropertyReference.of(next) as PropertyReference + return TypedPropertyPaths.compose(this, nextPath) +} + +/** + * Helper to create [PropertyReference] from [KProperty]. + * + * @since 4.1 + */ +class KPropertyReference { + + /** + * Companion object for static factory methods. + */ + companion object { + + /** + * Create a [PropertyReference] from a [KProperty1] reference. + * @param property the property reference, must not be a property path. + */ + fun of(property: KProperty1): PropertyReference { + return of((property as KProperty)) + } + + /** + * Create a [PropertyReference] from a collection-like [KProperty1] reference. + * @param property the property reference, must not be a property path. + */ + @JvmName("ofMany") + fun of(property: KProperty1?>): PropertyReference { + return of((property as KProperty)) + } + + /** + * Create a [PropertyReference] from a [KProperty]. + */ + fun of(property: KProperty): PropertyReference { + + if (property is KPropertyPath<*, *>) { + throw IllegalArgumentException("Property reference '${property.toDotPath()}' must be a single property reference, not a property path") + } + + if (property is KProperty1<*, *>) { + + val property1 = property as KProperty1<*, *> + val owner = property1.javaField?.declaringClass + ?: property1.javaGetter?.declaringClass + val metadata = PropertyReferences.KPropertyMetadata.of( + MemberDescriptor.KPropertyReferenceDescriptor.create( + owner, + property1 + ) + ) + return PropertyReferences.ResolvedKPropertyReference(metadata) + } + + throw IllegalArgumentException("Property '${property.name}' is not a KProperty") + } + + } + +} diff --git a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt new file mode 100644 index 0000000000..5fc6b3e2aa --- /dev/null +++ b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2025 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. + */ +@file:Suppress( + "UPPER_BOUND_VIOLATED_BASED_ON_JAVA_ANNOTATIONS", + "NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS", "UNCHECKED_CAST" +) + +package org.springframework.data.core + +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaGetter + +/** + * Helper to create [TypedPropertyPath] from [KProperty]. + * + * @since 4.1 + */ +internal class KTypedPropertyPath { + + /** + * Companion object for static factory methods. + */ + companion object { + + /** + * Create a [TypedPropertyPath] from a [KProperty1]. + */ + fun of(property: KProperty1): TypedPropertyPath { + return of((property as KProperty)) + } + + /** + * Create a [TypedPropertyPath] from a collection-like [KProperty1]. + */ + @JvmName("ofMany") + fun of(property: KProperty1?>): TypedPropertyPath { + return of((property as KProperty)) + } + + /** + * Create a [TypedPropertyPath] from a [KProperty]. + */ + fun of(property: KProperty): TypedPropertyPath { + + if (property is KPropertyPath<*, *>) { + + val paths = property as KPropertyPath<*, *> + + val parent = of(paths.property) + val child = of(paths.leaf) + + return TypedPropertyPaths.compose( + parent, + child + ) as TypedPropertyPath + } + + if (property is KProperty1<*, *>) { + + val property1 = property as KProperty1<*, *> + val owner = property1.javaField?.declaringClass + ?: property1.javaGetter?.declaringClass + val metadata = TypedPropertyPaths.KPropertyPathMetadata.of( + MemberDescriptor.KPropertyReferenceDescriptor.create( + owner, + property1 + ) + ) + return TypedPropertyPaths.ResolvedKPropertyPath(metadata) + } + + throw IllegalArgumentException("Property '${property.name}' is not a KProperty") + } + + } + +} diff --git a/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt b/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt index 3224afff8d..1707918fc6 100644 --- a/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt +++ b/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt @@ -49,7 +49,7 @@ internal class KIterablePropertyPath( * @author Tjeu Kayim * @author Mikhail Polivakha */ -internal fun asString(property: KProperty<*>): String { +fun asString(property: KProperty<*>): String { return when (property) { is KPropertyPath<*, *> -> "${asString(property.parent)}.${property.child.name}" @@ -72,6 +72,7 @@ internal fun asString(property: KProperty<*>): String { * @since 2.5 */ @JvmName("div") +@Deprecated("since 4.1, use the org.springframework.data.core extensions instead") operator fun KProperty.div(other: KProperty1): KProperty = KPropertyPath(this, other) @@ -92,5 +93,6 @@ operator fun KProperty.div(other: KProperty1): KProperty = * @since 3.5 */ @JvmName("divIterable") +@Deprecated("since 4.1, use the org.springframework.data.core extensions instead") operator fun KProperty?>.div(other: KProperty1): KProperty = KIterablePropertyPath(this, other) diff --git a/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt index 2886032f1f..2eec08497d 100644 --- a/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt +++ b/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt @@ -24,4 +24,5 @@ import kotlin.reflect.KProperty * @since 2.5 * @see org.springframework.data.core.PropertyPath.toDotPath */ +@Deprecated("since 4.1, use the org.springframework.data.core extensions instead") fun KProperty<*>.toDotPath(): String = asString(this) diff --git a/src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java b/src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java index 3f154f0117..4bbfb0a4c2 100644 --- a/src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java +++ b/src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java @@ -26,6 +26,7 @@ * Unit tests for {@link ValueConverterRegistry}. * * @author Christoph Strobl + * @author Mark Paluch */ @SuppressWarnings({ "rawtypes", "unchecked" }) class PropertyValueConverterRegistrarUnitTests { @@ -68,6 +69,20 @@ void allowsTypeSafeConverterRegistration() { assertThat(name.read("off", null)).isEqualTo("off"); } + @Test // GH-3400 + void allowsTypeSafeConverterRegistrationViaPropertyReference() { + + PropertyValueConverterRegistrar registrar = new PropertyValueConverterRegistrar<>(); + registrar.registerConverter(Person::getName) // + .writing(PropertyValueConverterRegistrarUnitTests::reverse) // + .readingAsIs(); + + PropertyValueConverter> name = registrar + .buildRegistry().getConverter(Person.class, "name"); + assertThat(name.write("foo", null)).isEqualTo("oof"); + assertThat(name.read("мир", null)).isEqualTo("мир"); + } + @Test // GH-1484 void allowsTypeSafeConverterRegistrationViaRecordedProperty() { diff --git a/src/test/java/org/springframework/data/core/PropertyPathTck.java b/src/test/java/org/springframework/data/core/PropertyPathTck.java new file mode 100644 index 0000000000..1d811c71ee --- /dev/null +++ b/src/test/java/org/springframework/data/core/PropertyPathTck.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 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 static org.assertj.core.api.Assertions.*; + +import java.util.Iterator; + +/** + * TCK for {@link PropertyPath} implementations. + * + * @author Mark Paluch + */ +class PropertyPathTck { + + /** + * Verify that the given {@link PropertyPath} API behavior matches the expected one. + * + * @param actual + * @param expected + */ + static void verify(PropertyPath actual, PropertyPath expected) { + + assertThat(actual).hasToString(expected.toString()).hasSameHashCodeAs(expected).isEqualTo(expected); + + assertThat(actual.getSegment()).isEqualTo(expected.getSegment()); + assertThat(actual.getType()).isEqualTo(expected.getType()); + + assertThat(actual.getLeafProperty()).isEqualTo(expected.getLeafProperty()); + + assertThat(actual.hasNext()).isEqualTo(expected.hasNext()); + assertThat(actual.next()).isEqualTo(expected.next()); + + Iterator actualIterator = actual.iterator(); + Iterator expectedIterator = actual.iterator(); + + assertThat(actualIterator.hasNext()).isEqualTo(expectedIterator.hasNext()); + + assertThat(actualIterator.next()).isEqualTo(actual); + assertThat(expectedIterator.next()).isEqualTo(expected); + + while (actualIterator.hasNext() && expectedIterator.hasNext()) { + + verify(actualIterator.next(), expectedIterator.next()); + assertThat(actualIterator.hasNext()).isEqualTo(expectedIterator.hasNext()); + } + + while (actual != null && expected != null && actual.hasNext() && expected.hasNext()) { + + actual = actual.next(); + expected = expected.next(); + + verify(actual, expected); + } + } + +} diff --git a/src/test/java/org/springframework/data/core/PropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/PropertyPathUnitTests.java index 1144c2324c..83fbc85127 100755 --- a/src/test/java/org/springframework/data/core/PropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/PropertyPathUnitTests.java @@ -23,8 +23,12 @@ import java.util.Map; import java.util.Set; import java.util.regex.Pattern; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; /** * Unit tests for {@link PropertyPath}. @@ -453,6 +457,22 @@ void detectsNestedSingleCharacterProperty() { assertThat(from("category_B", Product.class).toDotPath()).isEqualTo("category.b"); } + @ParameterizedTest + @MethodSource("propertyPaths") + void verifyTck(PropertyPath actual, PropertyPath expected) { + PropertyPathTck.verify(actual, expected); + } + + static Stream propertyPaths() { + return Stream.of( + Arguments.argumentSet("Sample.userName", PropertyPath.from("userName", Sample.class), + PropertyPath.from("userName", Sample.class)), + Arguments.argumentSet("Sample.user.name", PropertyPath.from("user.name", Sample.class), + PropertyPath.from("user.name", Sample.class)), + Arguments.argumentSet("Sample.bar.user.name", PropertyPath.from("bar.user.name", Sample.class), + PropertyPath.from("bar.user.name", Sample.class))); + } + private class Foo { String userName; diff --git a/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java b/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java new file mode 100644 index 0000000000..256d5a99f8 --- /dev/null +++ b/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 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 java.io.Serializable; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.core.convert.converter.Converter; + +/** + * Unit test {@link PropertyPathUtil}. + * + * @author Mark Paluch + */ +class PropertyPathUtilUnitTests { + + @Test + void shouldResolvePropertyPath() { + + Converter c = convert(Person::getName); + + System.out.println(PropertyPathUtil.resolve(c)); + } + + static & Serializable> Serializable of(C mapping) { + return mapping; + } + + static & Serializable> T convert(T converter) { + return converter; + } + + static class Person { + + private String name; + private @Nullable Integer age; + + // Getters + public String getName() { + return name; + } + + public @Nullable Integer getAge() { + return age; + } + + } +} diff --git a/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java b/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java new file mode 100644 index 0000000000..42f0b2511b --- /dev/null +++ b/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java @@ -0,0 +1,316 @@ +/* + * Copyright 2025 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 static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.dao.InvalidDataAccessApiUsageException; + +/** + * Unit tests for {@link PropertyReference}. + * + * @author Mark Paluch + */ +class PropertyReferenceUnitTests { + + @Test // GH-3400 + void resolvesMHSimplePath() { + assertThat(PropertyReference.of(PersonQuery::getName).getName()).isEqualTo("name"); + } + + @Test // GH-3400 + void resolvesMHComposedPath() { + assertThat(PropertyReference.of(PersonQuery::getAddress).then(Address::getCountry).toDotPath()) + .isEqualTo("address.country"); + } + + @Test // GH-3400 + void resolvesCollectionPath() { + assertThat(PropertyReference.ofMany(PersonQuery::getAddresses).then(Address::getCity).toDotPath()) + .isEqualTo("addresses.city"); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesInitialLambdaGetter() { + assertThat(PropertyReference.of((PersonQuery person) -> person.getName()).getName()).isEqualTo("name"); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesComposedLambdaGetter() { + assertThat(PropertyReference.of(PersonQuery::getAddress).then(it -> it.getCity()).toDotPath()) + .isEqualTo("address.city"); + } + + @Test // GH-3400 + void resolvesComposedLambdaFieldAccess() { + assertThat(PropertyReference.of(PersonQuery::getAddress).then(it -> it.city).toDotPath()).isEqualTo("address.city"); + } + + @Test // GH-3400 + void resolvesInterfaceMethodReferenceGetter() { + assertThat(PropertyReference.of(PersonProjection::getName).getName()).isEqualTo("name"); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesInterfaceLambdaGetter() { + assertThat(PropertyReference.of((PersonProjection person) -> person.getName()).getName()).isEqualTo("name"); + } + + @Test // GH-3400 + void resolvesSuperclassMethodReferenceGetter() { + assertThat(PropertyReference.of(PersonQuery::getTenant).getName()).isEqualTo("tenant"); + } + + @Test // GH-3400 + void resolvesSuperclassLambdaGetter() { + assertThat(PropertyReference.of((PersonQuery person) -> person.getTenant()).getName()).isEqualTo("tenant"); + } + + @Test // GH-3400 + void resolvesPrivateMethodReference() { + assertThat(PropertyReference.of(Secret::getSecret).getName()).isEqualTo("secret"); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesPrivateMethodLambda() { + assertThat(PropertyReference.of((Secret secret) -> secret.getSecret()).getName()).isEqualTo("secret"); + } + + @Test // GH-3400 + void switchingOwningTypeFails() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PersonQuery person) -> { + return ((SuperClass) person).getTenant(); + })); + } + + @Test // GH-3400 + void constructorCallsShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PersonQuery person) -> new PersonQuery(person))); + } + + @Test // GH-3400 + void enumShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of(NotSupported.INSTANCE)); + } + + @Test // GH-3400 + void returningSomethingShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PropertyReference) obj -> null)); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PropertyReference) obj -> 1)); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PropertyReference) obj -> "")); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2Lambda") + void classImplementationShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of(new PropertyReference() { + @Override + public @Nullable Object get(Object obj) { + return null; + } + })); + } + + @Test // GH-3400 + void constructorMethodReferenceShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference. of(PersonQuery::new)); + } + + @Test // GH-3400 + void failsResolutionWith$StrangeStuff() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PersonQuery person) -> { + int a = 1 + 2; + new Integer(a).toString(); + return person.getName(); + }).getName()); + } + + @Test // GH-3400 + void arithmeticOpsFail() { + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> { + PropertyReference.of((PersonQuery person) -> { + int a = 1 + 2; + return person.getName(); + }); + }); + } + + @Test // GH-3400 + void failsResolvingCallingLocalMethod() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PersonQuery person) -> { + failsResolutionWith$StrangeStuff(); + return person.getName(); + })); + } + + @Nested + class NestedTestClass { + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesInterfaceLambdaGetter() { + assertThat(PropertyReference.of((PersonProjection person) -> person.getName()).getName()).isEqualTo("name"); + } + + @Test // GH-3400 + void resolvesSuperclassMethodReferenceGetter() { + assertThat(PropertyReference.of(PersonQuery::getTenant).getName()).isEqualTo("tenant"); + } + + } + + // Domain entities + + static class SuperClass { + + private int tenant; + + public int getTenant() { + return tenant; + } + + public void setTenant(int tenant) { + this.tenant = tenant; + } + } + + static class PersonQuery extends SuperClass { + + private String name; + private @Nullable Integer age; + private PersonQuery emergencyContact; + private Address address; + private List

addresses; + + public PersonQuery(PersonQuery pq) {} + + public PersonQuery() {} + + // Getters + public String getName() { + return name; + } + + public @Nullable Integer getAge() { + return age; + } + + public PersonQuery getEmergencyContact() { + return emergencyContact; + } + + public void setEmergencyContact(PersonQuery emergencyContact) { + this.emergencyContact = emergencyContact; + } + + public Address getAddress() { + return address; + } + + public List
getAddresses() { + return addresses; + } + + public void setAddresses(List
addresses) { + this.addresses = addresses; + } + } + + static class Address { + + String street; + String city; + private Country country; + private String secret; + + // Getters + public String getStreet() { + return street; + } + + public String getCity() { + return city; + } + + public Country getCountry() { + return country; + } + + private String getSecret() { + return secret; + } + + private void setSecret(String secret) { + this.secret = secret; + } + } + + record Country(String name, String code) { + + } + + static class Secret { + + private String secret; + + private String getSecret() { + return secret; + } + + } + + interface PersonProjection { + + String getName(); + } + + enum NotSupported implements PropertyReference { + + INSTANCE; + + @Override + public @Nullable String get(String obj) { + return ""; + } + } +} diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java new file mode 100644 index 0000000000..79ff65ea3e --- /dev/null +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -0,0 +1,359 @@ +/* + * Copyright 2025 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 static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.dao.InvalidDataAccessApiUsageException; + +/** + * Unit tests for {@link TypedPropertyPath}. + * + * @author Mark Paluch + */ +class TypedPropertyPathUnitTests { + + @ParameterizedTest // GH-3400 + @MethodSource("propertyPaths") + void verifyTck(TypedPropertyPath actual, PropertyPath expected) { + PropertyPathTck.verify(actual, expected); + } + + static Stream propertyPaths() { + + return Stream.of( + Arguments.argumentSet("PersonQuery.name", PropertyPath.of(PersonQuery::getName), + PropertyPath.from("name", PersonQuery.class)), + Arguments.argumentSet("PersonQuery.address.country", + PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry), + PropertyPath.from("address.country", PersonQuery.class)), + Arguments.argumentSet("PersonQuery.address.country.name", + PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry).then(Country::name), + PropertyPath.from("address.country.name", PersonQuery.class)), + Arguments.argumentSet( + "PersonQuery.emergencyContact.address.country.name", PropertyPath.of(PersonQuery::getEmergencyContact) + .then(PersonQuery::getAddress).then(Address::getCountry).then(Country::name), + PropertyPath.from("emergencyContact.address.country.name", PersonQuery.class))); + } + + @Test // GH-3400 + void resolvesMHSimplePath() { + assertThat(PropertyPath.of(PersonQuery::getName).toDotPath()).isEqualTo("name"); + } + + @Test // GH-3400 + void resolvesMHComposedPath() { + assertThat(PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry).toDotPath()) + .isEqualTo("address.country"); + } + + @Test // GH-3400 + void resolvesMHComposedPathThroughFactory() { + assertThat(TypedPropertyPath + .path(PersonQuery::getEmergencyContact, PersonQuery::getAddress, Address::getCountry, Country::name) + .toDotPath()).isEqualTo("emergencyContact.address.country.name"); + } + + @Test // GH-3400 + void resolvesCollectionPath() { + assertThat(PropertyPath.ofMany(PersonQuery::getAddresses).then(Address::getCity).toDotPath()) + .isEqualTo("addresses.city"); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesInitialLambdaGetter() { + assertThat(PropertyPath.of((PersonQuery person) -> person.getName()).toDotPath()).isEqualTo("name"); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesComposedLambdaGetter() { + assertThat(PropertyPath.of(PersonQuery::getAddress).then(it -> it.getCity()).toDotPath()).isEqualTo("address.city"); + } + + @Test // GH-3400 + void resolvesComposedLambdaFieldAccess() { + assertThat(PropertyPath.of(PersonQuery::getAddress).then(it -> it.city).toDotPath()).isEqualTo("address.city"); + } + + @Test // GH-3400 + void resolvesInterfaceMethodReferenceGetter() { + assertThat(PropertyPath.of(PersonProjection::getName).toDotPath()).isEqualTo("name"); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesInterfaceLambdaGetter() { + assertThat(PropertyPath.of((PersonProjection person) -> person.getName()).toDotPath()).isEqualTo("name"); + } + + @Test // GH-3400 + void resolvesSuperclassMethodReferenceGetter() { + assertThat(PropertyPath.of(PersonQuery::getTenant).toDotPath()).isEqualTo("tenant"); + } + + @Test // GH-3400 + void resolvesSuperclassLambdaGetter() { + assertThat(PropertyPath.of((PersonQuery person) -> person.getTenant()).toDotPath()).isEqualTo("tenant"); + } + + @Test // GH-3400 + void resolvesPrivateMethodReference() { + assertThat(PropertyPath.of(Secret::getSecret).toDotPath()).isEqualTo("secret"); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesPrivateMethodLambda() { + assertThat(PropertyPath.of((Secret secret) -> secret.getSecret()).toDotPath()).isEqualTo("secret"); + } + + @Test // GH-3400 + void switchingOwningTypeFails() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> { + return ((SuperClass) person).getTenant(); + })); + } + + @Test // GH-3400 + void constructorCallsShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> new PersonQuery(person))); + } + + @Test // GH-3400 + void enumShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> TypedPropertyPath.of(NotSupported.INSTANCE)); + } + + @Test // GH-3400 + void returningSomethingShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> TypedPropertyPath.of((TypedPropertyPath) obj -> null)); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> TypedPropertyPath.of((TypedPropertyPath) obj -> 1)); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> TypedPropertyPath.of((TypedPropertyPath) obj -> "")); + } + + @Test // GH-3400 + @SuppressWarnings("Convert2Lambda") + void classImplementationShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> TypedPropertyPath.of(new TypedPropertyPath() { + @Override + public @Nullable Object get(Object obj) { + return null; + } + })); + } + + @Test // GH-3400 + void constructorMethodReferenceShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath. of(PersonQuery::new)); + } + + @Test // GH-3400 + void resolvesMRRecordPath() { + + TypedPropertyPath then = PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry) + .then(Country::name); + + assertThat(then.toDotPath()).isEqualTo("address.country.name"); + } + + @Test // GH-3400 + void failsResolutionWith$StrangeStuff() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> { + int a = 1 + 2; + new Integer(a).toString(); + return person.getName(); + }).toDotPath()); + } + + @Test // GH-3400 + void arithmeticOpsFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> { + PropertyPath.of((PersonQuery person) -> { + int a = 1 + 2; + return person.getName(); + }); + }); + } + + @Test // GH-3400 + void failsResolvingCallingLocalMethod() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> { + failsResolutionWith$StrangeStuff(); + return person.getName(); + })); + } + + @Nested + class NestedTestClass { + + @Test // GH-3400 + @SuppressWarnings("Convert2MethodRef") + void resolvesInterfaceLambdaGetter() { + assertThat(PropertyPath.of((PersonProjection person) -> person.getName()).toDotPath()).isEqualTo("name"); + } + + @Test // GH-3400 + void resolvesSuperclassMethodReferenceGetter() { + assertThat(PropertyPath.of(PersonQuery::getTenant).toDotPath()).isEqualTo("tenant"); + } + + } + + // Domain entities + + static class SuperClass { + + private int tenant; + + public int getTenant() { + return tenant; + } + + public void setTenant(int tenant) { + this.tenant = tenant; + } + } + + static class PersonQuery extends SuperClass { + + private String name; + private @Nullable Integer age; + private PersonQuery emergencyContact; + private Address address; + private List
addresses; + + public PersonQuery(PersonQuery pq) {} + + public PersonQuery() {} + + // Getters + public String getName() { + return name; + } + + public @Nullable Integer getAge() { + return age; + } + + public PersonQuery getEmergencyContact() { + return emergencyContact; + } + + public void setEmergencyContact(PersonQuery emergencyContact) { + this.emergencyContact = emergencyContact; + } + + public Address getAddress() { + return address; + } + + public List
getAddresses() { + return addresses; + } + + public void setAddresses(List
addresses) { + this.addresses = addresses; + } + } + + static class Address { + + String street; + String city; + private Country country; + private String secret; + + // Getters + public String getStreet() { + return street; + } + + public String getCity() { + return city; + } + + public Country getCountry() { + return country; + } + + private String getSecret() { + return secret; + } + + private void setSecret(String secret) { + this.secret = secret; + } + } + + record Country(String name, String code) { + + } + + static class Secret { + + private String secret; + + private String getSecret() { + return secret; + } + + } + + interface PersonProjection { + + String getName(); + } + + enum NotSupported implements TypedPropertyPath { + + INSTANCE; + + @Override + public @Nullable String get(String obj) { + return ""; + } + } +} diff --git a/src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java b/src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java index 20875f0b20..72209cd236 100755 --- a/src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java +++ b/src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java @@ -121,6 +121,15 @@ void ignoredPathsShouldReturnUniqueProperties() { assertThat(matcher.getIgnoredPaths()).hasSize(2); } + @Test // + void ignoredPropertyPathsShouldReturnUniqueProperties() { + + matcher = matching().withIgnorePaths(Person::getFirstname, Person::getLastname, Person::getFirstname); + + assertThat(matcher.getIgnoredPaths()).contains("firstname", "lastname"); + assertThat(matcher.getIgnoredPaths()).hasSize(2); + } + @Test // DATACMNS-810 void withCreatesNewInstance() { @@ -160,11 +169,11 @@ void anyMatcherYieldsAnyMatching() { void shouldCompareUsingHashCodeAndEquals() { matcher = matching() // - .withIgnorePaths("foo", "bar", "baz") // + .withIgnorePaths(Random::getFoo, Random::getBar, Random::getBaz) // .withNullHandler(NullHandler.IGNORE) // .withIgnoreCase("ignored-case") // - .withMatcher("hello", GenericPropertyMatchers.contains().caseSensitive()) // - .withMatcher("world", GenericPropertyMatcher::endsWith); + .withMatcher(Random::getHello, GenericPropertyMatchers.contains().caseSensitive()) // + .withMatcher(Random::getWorld, GenericPropertyMatcher::endsWith); var sameAsMatcher = matching() // .withIgnorePaths("foo", "bar", "baz") // @@ -182,8 +191,54 @@ void shouldCompareUsingHashCodeAndEquals() { assertThat(matcher).isEqualTo(sameAsMatcher).isNotEqualTo(different); } + static class Random { + + String foo; + String bar; + String baz; + String hello; + String world; + + public String getFoo() { + return foo; + } + + public String getBar() { + return bar; + } + + public String getBaz() { + return baz; + } + + public String getHello() { + return hello; + } + + public String getWorld() { + return world; + } + } + static class Person { String firstname; + String lastname; + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } } } diff --git a/src/test/java/org/springframework/data/domain/SortUnitTests.java b/src/test/java/org/springframework/data/domain/SortUnitTests.java index 7d03e8c6d5..cf0bae3442 100755 --- a/src/test/java/org/springframework/data/domain/SortUnitTests.java +++ b/src/test/java/org/springframework/data/domain/SortUnitTests.java @@ -16,14 +16,17 @@ package org.springframework.data.domain; import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.core.TypedPropertyPath.*; import static org.springframework.data.domain.Sort.NullHandling.*; import java.util.Collection; import org.junit.jupiter.api.Test; + import org.springframework.data.domain.Sort.Direction; import org.springframework.data.domain.Sort.Order; import org.springframework.data.geo.Circle; +import org.springframework.data.mapping.Person; /** * Unit test for {@link Sort}. @@ -44,6 +47,37 @@ void appliesDefaultForOrder() { assertThat(Sort.by("foo").iterator().next().getDirection()).isEqualTo(Sort.DEFAULT_DIRECTION); } + @Test + void appliesDefaultForOrderForProperty() { + assertThat(Sort.by(Person::getFirstName).iterator().next().getDirection()).isEqualTo(Sort.DEFAULT_DIRECTION); + } + + @Test + void appliesPropertyPath() { + + record PersonHolder(Person person) { + } + + assertThat(Sort.by(Person::getFirstName).iterator().next().getProperty()).isEqualTo("firstName"); + assertThat( + Sort.by(path(PersonHolder::person, Person::getFirstName)).iterator().next() + .getProperty()) + .isEqualTo("person.firstName"); + } + + @Test + void appliesPropertyPaths() { + assertThat(Sort.by(Person::getFirstName, Person::getLastName).stream().map(Order::getProperty)) + .containsSequence("firstName", "lastName"); + } + + @Test + void appliesPropertyPathsWithOrders() { + assertThat( + Sort.by(Order.asc(Person::getFirstName), Order.desc(Person::getLastName)).stream().map(Order::getProperty)) + .containsSequence("firstName", "lastName"); + } + /** * Asserts that the class rejects {@code null} as properties array. */ @@ -74,7 +108,7 @@ void preventsEmptyProperty() { */ @Test void preventsNoProperties() { - assertThatIllegalArgumentException().isThrownBy(() -> Sort.by(Direction.ASC)); + assertThatIllegalArgumentException().isThrownBy(() -> Sort.by(Direction.ASC, new String[0])); } @Test @@ -108,6 +142,14 @@ void orderDoesNotIgnoreCaseByDefault() { assertThat(Order.desc("foo").isIgnoreCase()).isFalse(); } + @Test + void orderFactoryMethodsConsiderPropertyPath() { + + assertThat(Order.by(Person::getFirstName)).isEqualTo(Order.by("firstName")); + assertThat(Order.asc(Person::getFirstName)).isEqualTo(Order.asc("firstName")); + assertThat(Order.desc(Person::getFirstName)).isEqualTo(Order.desc("firstName")); + } + @Test // DATACMNS-1021 void createsOrderWithDirection() { @@ -166,6 +208,26 @@ void createsNewOrderForDifferentProperty() { assertThat(result.isIgnoreCase()).isEqualTo(source.isIgnoreCase()); } + @Test + void createsNewOrderForDifferentPropertyPath() { + + var source = Order.desc("foo").nullsFirst().ignoreCase(); + var result = source.withProperty(Person::getFirstName); + + assertThat(result.getProperty()).isEqualTo("firstName"); + assertThat(result.getDirection()).isEqualTo(source.getDirection()); + assertThat(result.getNullHandling()).isEqualTo(source.getNullHandling()); + assertThat(result.isIgnoreCase()).isEqualTo(source.isIgnoreCase()); + } + + @Test + void createsNewOrderFromPaths() { + + var sort = Order.desc("foo").withProperties(Person::getFirstName, Person::getLastName); + + assertThat(sort).isEqualTo(Sort.by(Direction.DESC, "firstName", "lastName")); + } + @Test @SuppressWarnings("null") void preventsNullDirection() { diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt new file mode 100644 index 0000000000..e358418c2a --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2018-2025 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.Test +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.stream.Stream + +/** + * Unit tests for [kotlin.reflect.KProperty] extensions. + * + * @author Tjeu Kayim + * @author Yoann de Martino + * @author Mark Paluch + * @author Mikhail Polivakha + */ +class KPropertyExtensionsTests { + + @ParameterizedTest + @MethodSource("propertyPaths") + fun verifyTck(actual: TypedPropertyPath<*, *>, expected: PropertyPath) { + PropertyPathTck.verify(actual, expected) + } + + companion object { + + @JvmStatic + fun propertyPaths(): Stream { + + return Stream.of( + Arguments.argumentSet( + "Person.name (toPath)", + Person::name.toPropertyPath(), + PropertyPath.from("name", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country.name (toPath)", + (Person::address / Address::country / Country::name).toPropertyPath(), + PropertyPath.from("address.country.name", Person::class.java) + ), + Arguments.argumentSet( + "Person.addresses.country.name (toPath)", + (Person::addresses / Address::country / Country::name).toPropertyPath(), + PropertyPath.from("addresses.country.name", Person::class.java) + ) + ) + } + } + + @Test // DATACMNS-1835 + fun `Convert normal KProperty to field name`() { + + val property = Book::title.toDotPath() + + assertThat(property).isEqualTo("title") + } + + @Test // DATACMNS-1835 + fun `Convert nested KProperty to field name`() { + + val property = (Book::author / Author::name).toDotPath() + + assertThat(property).isEqualTo("author.name") + } + + @Test // GH-3010 + fun `Convert from Iterable nested KProperty to field name`() { + + val property = (Author::books / Book::title).toDotPath() + + assertThat(property).isEqualTo("books.title") + } + + @Test // GH-3010 + fun `Convert from Iterable nested Iterable Property to field name`() { + + val property = (Author::books / Book::author / Author::name).toDotPath() + + assertThat(property).isEqualTo("books.author.name") + } + + @Test // DATACMNS-1835 + fun `Convert double nested KProperty to field name`() { + + class Entity(val book: Book) + + val property = (Entity::book / Book::author / Author::name).toDotPath() + + assertThat(property).isEqualTo("book.author.name") + } + + @Test // DATACMNS-1835 + fun `Convert triple nested KProperty to field name`() { + + class Entity(val book: Book) + class AnotherEntity(val entity: Entity) + + val property = + (AnotherEntity::entity / Entity::book / Book::author / Author::name).toDotPath() + + assertThat(property).isEqualTo("entity.book.author.name") + } + + @Test // DATACMNS-1835 + fun `Convert triple nested KProperty to property path using toDotPath`() { + + class Entity(val book: Book) + class AnotherEntity(val entity: Entity) + + val property = + (AnotherEntity::entity / Entity::book / Book::author / Author::name).toDotPath() + + assertThat(property).isEqualTo("entity.book.author.name") + } + + @Test // DATACMNS-1835 + fun `Convert simple KProperty to property path using toDotPath`() { + + class AnotherEntity(val entity: String) + + val property = AnotherEntity::entity.toDotPath() + + assertThat(property).isEqualTo("entity") + } + + @Test // DATACMNS-1835 + fun `Convert nested KProperty to field name using toDotPath()`() { + + val property = (Book::author / Author::name).toDotPath() + + assertThat(property).isEqualTo("author.name") + } + + @Test // DATACMNS-1835 + fun `Convert nullable KProperty to field name`() { + + class Cat(val name: String?) + class Owner(val cat: Cat?) + + val property = (Owner::cat / Cat::name).toDotPath() + assertThat(property).isEqualTo("cat.name") + } + + class Book(val title: String, val author: Author) + class Author(val name: String, val books: List) + + class Person { + var name: String? = null + var age: Int = 0 + var address: Address? = null + var addresses: List
= emptyList() + } + + class Address { + var city: String? = null + var street: String? = null + var country: Country? = null + } + + data class Country(val name: String) + +} diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt new file mode 100644 index 0000000000..22ec18fc7c --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt @@ -0,0 +1,63 @@ +package org.springframework.data.core + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalArgumentException +import org.junit.jupiter.api.Test + +/** + * Unit tests for [KPropertyReference] and related functionality. + * + * @author Mark Paluch + */ +class KPropertyReferenceUnitTests { + + @Test // GH-3400 + fun shouldCreatePropertyReference() { + + val path = KPropertyReference.of(Person::name) + + assertThat(path.name).isEqualTo("name") + } + + @Test // GH-3400 + fun shouldComposePropertyPath() { + + val path = KPropertyReference.of(Person::address).then(Address::city) + + assertThat(path.toDotPath()).isEqualTo("address.city") + } + + @Test // GH-3400 + fun shouldComposeManyPropertyPath() { + + val path = KPropertyReference.of(Person::addresses).then(Address::city) + + assertThat(path.toDotPath()).isEqualTo("addresses.city") + } + + @Test // GH-3400 + fun composedReferenceCreationShouldFail() { + assertThatIllegalArgumentException().isThrownBy { + PropertyReference.property( + Person::address / Address::city + ) + } + assertThatIllegalArgumentException().isThrownBy { KPropertyReference.of(Person::address / Address::city) } + } + + class Person { + var name: String? = null + var age: Int = 0 + var address: Address? = null + var addresses: List
= emptyList() + var emergencyContact: Person? = null + } + + class Address { + var city: String? = null + var street: String? = null + var country: Country? = null + } + + data class Country(val name: String) +} diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt new file mode 100644 index 0000000000..4dcbaefbbb --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -0,0 +1,75 @@ +package org.springframework.data.core + +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.stream.Stream + +/** + * Unit tests for [KPropertyPath] and related functionality. + * + * @author Mark Paluch + */ +class KTypedPropertyPathUnitTests { + + @ParameterizedTest + @MethodSource("propertyPaths") + fun verifyTck(actual: TypedPropertyPath<*, *>, expected: PropertyPath) { + PropertyPathTck.verify(actual, expected) + } + + companion object { + + @JvmStatic + fun propertyPaths(): Stream { + + return Stream.of( + Arguments.argumentSet( + "Person.name (toPath)", + Person::name.toPropertyPath(), + PropertyPath.from("name", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country", + (Person::address / Address::country).toPropertyPath(), + PropertyPath.from("address.country", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country.name", + (Person::address / Address::country / Country::name).toPropertyPath(), + PropertyPath.from("address.country.name", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country.name (toPath)", + (Person::address / Address::country / Country::name).toPropertyPath(), + PropertyPath.from("address.country.name", Person::class.java) + ), + Arguments.argumentSet( + "Person.emergencyContact.address.country.name", + (Person::emergencyContact / Person::address / Address::country / Country::name).toPropertyPath(), + PropertyPath.from( + "emergencyContact.address.country.name", + Person::class.java + ) + ) + ) + } + } + + class Person { + var name: String? = null + var age: Int = 0 + var address: Address? = null + var addresses: List
= emptyList() + var emergencyContact: Person? = null + } + + class Address { + var city: String? = null + var street: String? = null + var country: Country? = null + } + + data class Country(val name: String) +} diff --git a/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt new file mode 100644 index 0000000000..e30b92fc17 --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt @@ -0,0 +1,38 @@ +package org.springframework.data.core + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalArgumentException +import org.junit.jupiter.api.Test + +/** + * Kotlin unit tests for [PropertyReference] and related functionality. + * + * @author Mark Paluch + */ +class PropertyReferenceKtUnitTests { + + @Test // GH-3400 + fun shouldSupportPropertyReference() { + assertThat(PropertyReference.property(Person::address).name).isEqualTo("address") + } + + @Test // GH-3400 + fun resolutionShouldFailForComposedPropertyPath() { + assertThatIllegalArgumentException() + .isThrownBy { PropertyReference.property(Person::address / Address::city) } + } + + class Person { + var name: String? = null + var age: Int = 0 + var address: Address? = null + } + + class Address { + var city: String? = null + var street: String? = null + } + + data class Country(val name: String) + +} diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt new file mode 100644 index 0000000000..3dd5043ede --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt @@ -0,0 +1,117 @@ +package org.springframework.data.core + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +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.stream.Stream + +/** + * Kotlin unit tests for [TypedPropertyPath] and related functionality. + * + * @author Mark Paluch + */ +class TypedPropertyPathKtUnitTests { + + @ParameterizedTest + @MethodSource("propertyPaths") + fun verifyTck(actual: TypedPropertyPath<*, *>?, expected: PropertyPath) { + PropertyPathTck.verify(actual, expected) + } + + companion object { + + @JvmStatic + fun propertyPaths(): Stream { + + return Stream.of( + Arguments.argumentSet( + "Person.name", + TypedPropertyPath.path(Person::name), + PropertyPath.from("name", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country", + TypedPropertyPath.path(Person::address) + .then(Address::country), + PropertyPath.from("address.country", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country.name", + TypedPropertyPath.path(Person::address) + .then(Address::country).then(Country::name), + PropertyPath.from("address.country.name", Person::class.java) + ), + Arguments.argumentSet( + "Person.emergencyContact.address.country.name", + TypedPropertyPath.path(Person::emergencyContact) + .then
(Person::address).then(Address::country) + .then(Country::name), + PropertyPath.from( + "emergencyContact.address.country.name", + Person::class.java + ) + ) + ) + } + } + + @Test // GH-3400 + fun shouldSupportPropertyReference() { + + assertThat( + TypedPropertyPath.path(Person::address).toDotPath() + ).isEqualTo("address") + } + + @Test // GH-3400 + fun shouldSupportComposedPropertyReference() { + + val path = TypedPropertyPath.path(Person::address) + .then(Address::city); + assertThat(path.toDotPath()).isEqualTo("address.city") + } + + @Test // GH-3400 + fun shouldSupportPropertyLambda() { + assertThat(TypedPropertyPath.path { it.address } + .toDotPath()).isEqualTo("address") + assertThat(TypedPropertyPath.path { foo -> foo.address } + .toDotPath()).isEqualTo("address") + } + + @Test // GH-3400 + fun shouldSupportComposedPropertyLambda() { + + val path = TypedPropertyPath.path { it.address }; + assertThat(path.then { it.city }.toDotPath()).isEqualTo("address.city") + } + + @Test // GH-3400 + fun shouldSupportComposedKProperty() { + + val path = TypedPropertyPath.path(Person::address / Address::city); + assertThat(path.toDotPath()).isEqualTo("address.city") + + val otherPath = TypedPropertyPath.of(Person::address / Address::city); + assertThat(otherPath.toDotPath()).isEqualTo("address.city") + } + + class Person { + var name: String? = null + var age: Int = 0 + var address: Address? = null + var emergencyContact: Person? = null + } + + class Address { + var city: String? = null + var street: String? = null + var country: Country? = null + } + + data class Country(val name: String) + +}