Browse Source

Introduce first-class type-safe property path references.

We now support type-safe property paths and property references:

Java variants:

PropertyPath.from("name", Person.class) // existing String-based API
PropertyPath.of(Person::getName) // type-safe property reference expression

PropertyPath.from("address.country", Person.class) // existing nested path API
PropertyPath.of(Person::getAddress).then(Address::getCountry) // type-safe composed path expression

PropertyReference.of(Secret::getSecret)

Kotlin variants:

PropertyReference.of(Secret::secret)

PropertyPath.of(Person::address / Address::city)

allowing type-safe usage through e.g.:

Sort.by(Person::getFirstName, Person::getLastName)

Closes: #3400
Original Pull Request: #3409
main
Mark Paluch 4 months ago committed by Christoph Strobl
parent
commit
820af2a296
No known key found for this signature in database
GPG Key ID: E6054036D0C37A4B
  1. 22
      pom.xml
  2. 39
      src/jmh/java/org/springframework/data/BenchmarkSettings.java
  3. 51
      src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java
  4. 68
      src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java
  5. 1
      src/main/antora/modules/ROOT/nav.adoc
  6. 205
      src/main/antora/modules/ROOT/pages/property-paths.adoc
  7. 17
      src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java
  8. 250
      src/main/java/org/springframework/data/core/MemberDescriptor.java
  9. 128
      src/main/java/org/springframework/data/core/PropertyPath.java
  10. 138
      src/main/java/org/springframework/data/core/PropertyPathUtil.java
  11. 205
      src/main/java/org/springframework/data/core/PropertyReference.java
  12. 320
      src/main/java/org/springframework/data/core/PropertyReferences.java
  13. 569
      src/main/java/org/springframework/data/core/SerializableLambdaReader.java
  14. 47
      src/main/java/org/springframework/data/core/SimplePropertyPath.java
  15. 192
      src/main/java/org/springframework/data/core/TypedPropertyPath.java
  16. 571
      src/main/java/org/springframework/data/core/TypedPropertyPaths.java
  17. 78
      src/main/java/org/springframework/data/domain/ExampleMatcher.java
  18. 104
      src/main/java/org/springframework/data/domain/Sort.java
  19. 30
      src/main/java/org/springframework/data/repository/query/FluentQuery.java
  20. 4
      src/main/java/org/springframework/data/util/MethodInvocationRecorder.java
  21. 77
      src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt
  22. 116
      src/main/kotlin/org/springframework/data/core/KPropertyPath.kt
  23. 105
      src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt
  24. 92
      src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt
  25. 4
      src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt
  26. 1
      src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt
  27. 15
      src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java
  28. 70
      src/test/java/org/springframework/data/core/PropertyPathTck.java
  29. 20
      src/test/java/org/springframework/data/core/PropertyPathUnitTests.java
  30. 63
      src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java
  31. 316
      src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java
  32. 352
      src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java
  33. 61
      src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java
  34. 64
      src/test/java/org/springframework/data/domain/SortUnitTests.java
  35. 179
      src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt
  36. 63
      src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt
  37. 75
      src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt
  38. 38
      src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt
  39. 117
      src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt

22
pom.xml

@ -388,6 +388,28 @@ @@ -388,6 +388,28 @@
</resources>
</build>
</profile>
<profile>
<id>jmh</id>
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<scope>test</scope>
<version>${jmh}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
</profile>
</profiles>
<repositories>

39
src/jmh/java/org/springframework/data/BenchmarkSettings.java

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data;
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 {
}

51
src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import org.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<Person, String> methodReference = Person::firstName;
return reader.read(methodReference);
}
@Benchmark
public Object benchmarkLambda() {
PropertyReference<Person, String> methodReference = person -> person.firstName();
return reader.read(methodReference);
}
record Person(String firstName, String lastName) {
}
}

68
src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java

@ -0,0 +1,68 @@ @@ -0,0 +1,68 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import org.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<Person, String> benchmarkLambda() {
return TypedPropertyPath.path(person -> person.firstName());
}
@Benchmark
public TypedPropertyPath<Person, String> 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) {
}
}

1
src/main/antora/modules/ROOT/nav.adoc

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
** xref:repositories/projections.adoc[]
* xref:query-by-example.adoc[]
* xref:value-expressions.adoc[]
* xref:property-paths.adoc[]
* xref:auditing.adoc[]
* xref:custom-conversions.adoc[]
* xref:entity-callbacks.adoc[]

205
src/main/antora/modules/ROOT/pages/property-paths.adoc

@ -0,0 +1,205 @@ @@ -0,0 +1,205 @@
[[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<Address> 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<Address> = 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 <<type-safe-property-references>> for details.
NOTE: For implementation details, refer to <<property-path-internals>> for more information.
[[property-path-internals]]
=== Property Path Internals
The {spring-data-commons-javadoc-base}/org/springframework/data/core/package-summary.html[`org.springframework.data.core`] package is the basis for Spring Data's navigation across domain classes.
The javadoc:org.springframework.data.core.TypeInformation[] interface 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.
[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.
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.
You can use `TypedPropertyPath` on its own if you are looking for a type-safe variant which benefits from compiler validation and IDE support for cases that do not directly integrate with Spring Data APIs:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
import static org.springframework.data.core.TypedPropertyPath.path;
// Static import variant
path(Person::getAddress)
.then(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()
----
======
[[type-safe-property-references-recommendations]]
=== Type-safe Property-Reference API Recommendations
When using (or building) APIs using type-safe property-references, consider the following recommendations:
* **Use method references**: Accept method references (e.g., `Person::getFirstName`) instead of strings to leverage compile-time validation and IDE refactoring support.
Method references are preferred as they share similary representations with both Java and Kotlin.
Additionally, method references provide a better performance baseline compared to lambdas due to their simpler representation.
* **Leverage the `T` type of `TypedPropertyPath`**: Whenever accepting a typed property path, consider using the generic type `T` of `TypedPropertyPath<T, P>`.
Limiting property paths to a specific domain type that is used within the current operation reduces the potential of using unintended properties from other types. +
+
Whenever accepting or providing multiple property paths, consider using `TypedPropertyPath<T, ?>` to allow for properties within the context of the owning type `T` to limit property paths to a common owning type.
NOTE: When using Graal Native Image compilation, you need to provide reachability metadata for serializable `TypedPropertyPath` lambdas.
When using lambda expressions instead of method references you will have to include the Java source code of the class containing the lambda expression in the native image configuration.

17
src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java

@ -20,6 +20,7 @@ import java.util.function.Consumer; @@ -20,6 +20,7 @@ import java.util.function.Consumer;
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<P extends PersistentProperty<P>> { @@ -50,11 +51,25 @@ public class PropertyValueConverterRegistrar<P extends PersistentProperty<P>> {
*
* @param <T> the domain type
* @param <S> 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 <T, S> WritingConverterRegistrationBuilder<T, S, P> registerConverter(PropertyReference<T, S> property) {
return new WritingConverterRegistrationBuilder<>(property.getOwningType().getType(), property.getName(), this);
}
/**
* Starts a converter registration by pointing to a property of a domain type.
*
* @param <T> the domain type
* @param <S> 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 <T, S> WritingConverterRegistrationBuilder<T, S, P> registerConverter(Class<T> type, Function<T, S> property) {
String propertyName = MethodInvocationRecorder.forProxyOf(type).record(property).getPropertyPath()

250
src/main/java/org/springframework/data/core/MemberDescriptor.java

@ -0,0 +1,250 @@ @@ -0,0 +1,250 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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());
}
}
}

128
src/main/java/org/springframework/data/core/PropertyPath.java

@ -19,21 +19,67 @@ import java.util.Iterator; @@ -19,21 +19,67 @@ import java.util.Iterator;
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.
* <p>
* 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:
*
* <pre class="code">
* PropertyPath.from("address.city.name", Person.class);
* </pre>
*
* Paths are cached on a best-effort basis using a weak reference cache to avoid repeated introspection if GC pressure
* permits.
* <p>
* 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<PropertyPath> {
/**
* Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans property.
* <p>
* 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 <T> owning type.
* @param <P> property type.
* @return the typed property path.
* @since 4.1
*/
static <T, P> TypedPropertyPath<T, P> of(PropertyReference<T, P> property) {
return TypedPropertyPaths.of(property);
}
/**
* Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans collection property.
* <p>
* This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference.
*
* @param property the method reference referring to a property.
* @param <T> owning type.
* @param <P> property type.
* @return the typed property path.
* @since 4.1
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
static <T, P> TypedPropertyPath<T, P> ofMany(PropertyReference<T, ? extends Iterable<P>> property) {
return (TypedPropertyPath) TypedPropertyPaths.of(property);
}
/**
* Returns the owning type of the {@link PropertyPath}.
*
@ -42,22 +88,25 @@ public interface PropertyPath extends Streamable<PropertyPath> { @@ -42,22 +88,25 @@ public interface PropertyPath extends Streamable<PropertyPath> {
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()}).
* <p>
* For example:
*
* <pre class="code">
* PropertyPath.from("a.b.c", Some.class).getSegment();
* PropertyPath.from("address.city.name", Person.class).getSegment();
* </pre>
*
* 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 @@ public interface PropertyPath extends Streamable<PropertyPath> { @@ -74,57 +123,72 @@ public interface PropertyPath extends Streamable<PropertyPath> {
* 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.
*
* <pre class="code">
* PropertyPath.from("a.b.c", Some.class).next().toDotPath();
* PropertyPath.from("address.city.name", Person.class).next().toDotPath();
* </pre>
*
* 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 @@ public interface PropertyPath extends Streamable<PropertyPath> { @@ -133,16 +197,7 @@ public interface PropertyPath extends Streamable<PropertyPath> {
}
/**
* 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 @@ public interface PropertyPath extends Streamable<PropertyPath> { @@ -157,20 +212,19 @@ public interface PropertyPath extends Streamable<PropertyPath> {
}
/**
* 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:
*
* <pre class="code">
* 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());
* </pre>
*
* results in the dot paths:
*
* <pre class="code">
* a.b.c
* b.c
* c
* address.city.name (this object)
* city.name (next() object)
* name (next().next() object)
* </pre>
*/
@Override

138
src/main/java/org/springframework/data/core/PropertyPathUtil.java

@ -0,0 +1,138 @@ @@ -0,0 +1,138 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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<Object, Object> {
@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());
}
}

205
src/main/java/org/springframework/data/core/PropertyReference.java

@ -0,0 +1,205 @@ @@ -0,0 +1,205 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import java.io.Serializable;
import org.jspecify.annotations.Nullable;
/**
* Interface providing type-safe property references.
* <p>
* 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.
* <p>
* Create a typed property reference using the static factory method {@link #property(PropertyReference)} with a method
* reference, for example:
*
* <pre class="code">
* PropertyReference.property(Person::getName);
* </pre>
*
* 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)}:
*
* <pre class="code">
* TypedPropertyPath&lt;Person, String&gt; city = PropertyReference.of(Person::getAddress).then(Address::getCity);
* </pre>
* <p>
* 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.
* <p>
* 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 <T> the owning type of this property.
* @param <P> 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<T, P extends @Nullable Object> extends Serializable {
/**
* Syntax sugar to create a {@link PropertyReference} from a method reference to a Java beans property. Suitable for
* static imports.
* <p>
* This method returns a resolved {@link PropertyReference} by introspecting the given method reference.
*
* @param property the method reference to a Java beans property.
* @param <T> owning type.
* @param <P> property type.
* @return the typed property reference.
*/
static <T, P extends @Nullable Object> PropertyReference<T, P> property(PropertyReference<T, P> property) {
return of(property);
}
/**
* Syntax sugar to create a {@link PropertyReference} from a method reference to a Java beans property.
* <p>
* This method returns a resolved {@link PropertyReference} by introspecting the given method reference.
*
* @param property the method reference to a Java beans property.
* @param <T> owning type.
* @param <P> property type.
* @return the typed property reference.
*/
static <T, P extends @Nullable Object> PropertyReference<T, P> of(PropertyReference<T, P> property) {
return PropertyReferences.of(property);
}
/**
* Syntax sugar to create a {@link PropertyReference} from a method reference to a Java beans property.
* <p>
* 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 &lt;P&gt;} and a single {@code P} cannot represent a collection of items.
*
* @param property the method reference to a Java beans property.
* @param <T> owning type.
* @param <P> property type.
* @return the typed property reference.
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
static <T, P> PropertyReference<T, P> ofMany(PropertyReference<T, ? extends Iterable<P>> 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<T> 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 <N> the new property value type.
* @return a new composed {@code TypedPropertyPath}.
*/
default <N extends @Nullable Object> TypedPropertyPath<T, N> then(PropertyReference<P, N> 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.
* <p>
* Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from
* {@code Iterable &lt;P&gt;} 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 <N> the new property value type.
* @return a new composed {@code TypedPropertyPath}.
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
default <N extends @Nullable Object> TypedPropertyPath<T, N> thenMany(
PropertyReference<P, ? extends Iterable<N>> next) {
return (TypedPropertyPath) TypedPropertyPaths.compose(this, next);
}
}

320
src/main/java/org/springframework/data/core/PropertyReferences.java

@ -0,0 +1,320 @@ @@ -0,0 +1,320 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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<ClassLoader, Map<PropertyReference<?, ?>, 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 <P, T> PropertyReference<T, P> of(PropertyReference<T, P> lambda) {
if (lambda instanceof ResolvedPropertyReferenceSupport<?, ?>) {
return lambda;
}
Map<PropertyReference<?, ?>, ResolvedPropertyReference<?, ?>> cache;
synchronized (resolved) {
cache = resolved.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>());
}
return (PropertyReference<T, P>) cache.computeIfAbsent(lambda,
o -> new ResolvedPropertyReference(o, read(lambda)));
}
/**
* Retrieve {@link PropertyMetadata} for a given {@link PropertyReference}.
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
public static <T, P> PropertyReference<T, P> of(PropertyReference<T, P> 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<? extends RuntimeException> 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 <T> the owning type.
* @param <P> the property type.
*/
static abstract class ResolvedPropertyReferenceSupport<T, P> implements PropertyReference<T, P> {
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<T> getOwningType() {
return (TypeInformation<T>) metadata.owner();
}
@Override
public String getName() {
return metadata.property();
}
@Override
@SuppressWarnings("unchecked")
public TypeInformation<P> getTypeInformation() {
return (TypeInformation<P>) 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 <T> the owning type.
* @param <P> the property type.
*/
static class ResolvedPropertyReference<T, P> extends ResolvedPropertyReferenceSupport<T, P> {
private final PropertyReference<T, P> function;
ResolvedPropertyReference(PropertyReference<T, P> 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 <T> the owning type.
* @param <P> the property type.
*/
static class ResolvedKPropertyReference<T, P> extends ResolvedPropertyReferenceSupport<T, P> {
private final KProperty<P> property;
@SuppressWarnings("unchecked")
ResolvedKPropertyReference(KPropertyMetadata metadata) {
this((KProperty<P>) metadata.getProperty(), metadata);
}
ResolvedKPropertyReference(KProperty<P> property, PropertyMetadata metadata) {
super(metadata);
this.property = property;
}
@Override
public @Nullable P get(T obj) {
return property.call(obj);
}
}
}

569
src/main/java/org/springframework/data/core/SerializableLambdaReader.java

@ -0,0 +1,569 @@ @@ -0,0 +1,569 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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.
* <p>
* The actual interface is not constrained by a base type, however the object must:
* <ul>
* <li>Implement a functional Java interface</li>
* <li>Must be the top-level lambda (i.e. not wrapped or a functional composition)</li>
* <li>Implement Serializable (either through the actual interface or through inference)</li>
* <li>Declare a single method to be implemented</li>
* <li>Accept a single method argument and return a value</li>
* </ul>
* Ideally, the interface has a similar format to {@link Function}, for example:
*
* <pre class="code">
* interface XtoYFunction<X, Y> (optional: extends Serializable) {
* Y &lt;method-name&gt;(X someArgument);
* }
* </pre>
* <p>
* <strong>Supported patterns</strong>
* <ul>
* <li>Method references: {@code Person::getName}</li>
* <li>Property access lambdas: {@code person -> person.getName()}</li>
* <li>Field access lambdas: {@code person -> person.name}</li>
* </ul>
* <strong>Unsupported patterns</strong>
* <ul>
* <li>Constructor references: {@code Person::new}</li>
* <li>Methods with arguments: {@code person -> person.setAge(25)}</li>
* <li>Lambda expressions that do more than property access, e.g. {@code person -> { person.setAge(25); return
* person.getName(); }}</li>
* <li>Arithmetic operations, arbitrary calls</li>
* <li>Functional composition: {@code Function.andThen(...)}</li>
* </ul>
*
* @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<Class<?>> 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.
* <p>
* 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.
* <p>
* 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<String> 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<MemberDescriptor> memberDescriptors = new ArrayList<>();
private final Set<ReadingError> 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("<init>")) {
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<StackTraceElement, StackTraceElement> 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);
}
}
}

47
src/main/java/org/springframework/data/core/SimplePropertyPath.java

@ -148,16 +148,6 @@ class SimplePropertyPath implements PropertyPath { @@ -148,16 +148,6 @@ class SimplePropertyPath implements PropertyPath {
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<PropertyPath> iterator() {
@ -187,45 +177,12 @@ class SimplePropertyPath implements PropertyPath { @@ -187,45 +177,12 @@ class SimplePropertyPath implements PropertyPath {
@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);
}
/**

192
src/main/java/org/springframework/data/core/TypedPropertyPath.java

@ -0,0 +1,192 @@ @@ -0,0 +1,192 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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.
* <p>
* 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.
* <p>
* Create a typed property path using the static factory method {@link #of(TypedPropertyPath)} with a method reference ,
* for example:
*
* <pre class="code">
* TypedPropertyPath.path(Person::getName);
* </pre>
*
* 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)}:
*
* <pre class="code">
* // factory method chaining
* TypedPropertyPath&lt;Person, String&gt; city = TypedPropertyPath.path(Person::getAddress, Address::getCity);
*
* // fluent API
* TypedPropertyPath&lt;Person, String&gt; city = TypedPropertyPath.of(Person::getAddress).then(Address::getCity);
* </pre>
* <p>
* 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.
* <p>
* 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 <T> the owning type of this path segment; the root type for composed paths.
* @param <P> 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<T, P extends @Nullable Object> 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.
* <p>
* This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference.
*
* @param property the method reference to a Java beans property.
* @param <T> owning type.
* @param <P> property type.
* @return the typed property path.
*/
static <T, P extends @Nullable Object> TypedPropertyPath<T, P> path(PropertyReference<T, P> property) {
return TypedPropertyPaths.of(property);
}
/**
* Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans property.
* <p>
* This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference.
*
* @param property the method reference to a Java beans property.
* @param <T> owning type.
* @param <P> property type.
* @return the typed property path.
*/
static <T, P extends @Nullable Object> TypedPropertyPath<T, P> of(TypedPropertyPath<T, P> property) {
return TypedPropertyPaths.of(property);
}
/**
* Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans collection property.
* <p>
* This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference.
* <p>
* Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from
* {@code Iterable &lt;P&gt;} and a single {@code P} cannot represent a collection of items.
*
* @param property the method reference to a Java beans collection property.
* @param <T> owning type.
* @param <P> property type.
* @return the typed property path.
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
static <T, P> TypedPropertyPath<T, P> ofMany(TypedPropertyPath<T, ? extends Iterable<P>> 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<T> getOwningType() {
return TypedPropertyPaths.of(this).getOwningType();
}
@Override
default String getSegment() {
return TypedPropertyPaths.of(this).getSegment();
}
@Override
default TypeInformation<P> getTypeInformation() {
return TypedPropertyPaths.of(this).getTypeInformation();
}
@Override
@Nullable
default PropertyPath next() {
return null;
}
@Override
default boolean hasNext() {
return false;
}
@Override
default Iterator<PropertyPath> 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 <N> the new property value type.
* @return a new composed {@code TypedPropertyPath}.
*/
default <N extends @Nullable Object> TypedPropertyPath<T, N> then(PropertyReference<P, N> next) {
return TypedPropertyPaths.compose(this, next);
}
/**
* Extend the property path by appending the {@code next} path segment and return a new property path instance.
* <p>
* Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from
* {@code Iterable &lt;P&gt;} 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 <N> the new property value type.
* @return a new composed {@code TypedPropertyPath}.
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
default <N extends @Nullable Object> TypedPropertyPath<T, N> thenMany(
PropertyReference<P, ? extends Iterable<N>> next) {
return (TypedPropertyPath) TypedPropertyPaths.compose(this, PropertyReference.of(next));
}
}

571
src/main/java/org/springframework/data/core/TypedPropertyPaths.java

@ -0,0 +1,571 @@ @@ -0,0 +1,571 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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<ClassLoader, Map<Serializable, ResolvedTypedPropertyPath<?, ?>>> 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 <T, M, P> TypedPropertyPath<T, P> compose(PropertyReference<T, M> owner, PropertyReference<M, P> next) {
return compose(of(owner), of(next));
}
/**
* Compose a {@link TypedPropertyPath} by appending {@code next}.
*/
public static <T, M, P> TypedPropertyPath<T, P> compose(TypedPropertyPath<T, M> owner, PropertyReference<M, P> next) {
return compose(of(owner), of(next));
}
/**
* Compose a {@link TypedPropertyPath} by appending {@code next}.
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
public static <T, M, P> TypedPropertyPath<T, P> compose(TypedPropertyPath<T, M> owner, TypedPropertyPath<M, P> next) {
if (owner instanceof ForwardingPropertyPath<?, ?, ?> fwd) {
List<PropertyPath> 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 <P, T> TypedPropertyPath<T, P> of(PropertyReference<T, P> lambda) {
if (lambda instanceof Resolved) {
return (TypedPropertyPath) lambda;
}
Map<PropertyReference<?, ?>, 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 <T, P> TypedPropertyPath<?, ?> doResolvePropertyReference(PropertyReference<T, P> 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 <P, T> TypedPropertyPath<T, P> of(TypedPropertyPath<T, P> lambda) {
if (lambda instanceof Resolved) {
return lambda;
}
Map<TypedPropertyPath<?, ?>, TypedPropertyPath<?, ?>> cache;
synchronized (resolved) {
cache = (Map) resolved.computeIfAbsent(lambda.getClass().getClassLoader(),
k -> new ConcurrentReferenceHashMap<>());
}
return (TypedPropertyPath) cache.computeIfAbsent(lambda,
TypedPropertyPaths::doResolvePropertyPathReference);
}
private static <T, P> TypedPropertyPath<?, ?> doResolvePropertyPathReference(TypedPropertyPath<T, P> 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 <T, P> TypedPropertyPath<T, P> 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 <T> the owning type.
* @param <P> the property type.
*/
static abstract class ResolvedTypedPropertyPathSupport<T, P> implements TypedPropertyPath<T, P>, Resolved {
private final PropertyMetadata metadata;
private final List<PropertyPath> 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<T> getOwningType() {
return (TypeInformation<T>) metadata.owner();
}
@Override
public String getSegment() {
return metadata.property();
}
@Override
@SuppressWarnings("unchecked")
public TypeInformation<P> getTypeInformation() {
return (TypeInformation<P>) metadata.propertyType();
}
@Override
public Iterator<PropertyPath> iterator() {
return list.iterator();
}
@Override
public Stream<PropertyPath> stream() {
return list.stream();
}
@Override
public List<PropertyPath> 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 <T> the owning type.
* @param <P> the property type.
*/
static class PropertyReferenceWrapper<T, P> implements TypedPropertyPath<T, P>, Resolved {
private final PropertyReference<T, P> property;
private final List<PropertyPath> self;
public PropertyReferenceWrapper(PropertyReference<T, P> property) {
this.property = property;
this.self = List.of(this);
}
@Override
public @Nullable P get(T obj) {
return property.get(obj);
}
@Override
public TypeInformation<T> getOwningType() {
return property.getOwningType();
}
@Override
public String getSegment() {
return property.getName();
}
@Override
@SuppressWarnings("unchecked")
public TypeInformation<P> getTypeInformation() {
return (TypeInformation<P>) property.getTypeInformation();
}
@Override
public Iterator<PropertyPath> iterator() {
return self.iterator();
}
@Override
public Stream<PropertyPath> stream() {
return self.stream();
}
@Override
public List<PropertyPath> 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 <T> the owning type.
* @param <P> the property type.
*/
static class ResolvedPropertyReference<T, P> extends ResolvedTypedPropertyPathSupport<T, P> {
private final PropertyReference<T, P> function;
ResolvedPropertyReference(PropertyReference<T, P> 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 <T> the owning type.
* @param <P> the property type.
*/
static class ResolvedTypedPropertyPath<T, P> extends ResolvedTypedPropertyPathSupport<T, P> {
private final TypedPropertyPath<T, P> function;
ResolvedTypedPropertyPath(TypedPropertyPath<T, P> 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 <T> the owning type.
* @param <P> the property type.
*/
static class ResolvedKPropertyPath<T, P> extends ResolvedTypedPropertyPathSupport<T, P> {
private final KProperty<P> property;
@SuppressWarnings("unchecked")
ResolvedKPropertyPath(KPropertyPathMetadata metadata) {
this((KProperty<P>) metadata.getProperty(), metadata);
}
ResolvedKPropertyPath(KProperty<P> 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<T, M, P>(TypedPropertyPath<T, M> self, TypedPropertyPath<M, P> nextSegment,
PropertyPath leaf, String dotPath, String toStringRepresentation) implements TypedPropertyPath<T, P>, Resolved {
public ForwardingPropertyPath(TypedPropertyPath<T, M> self, TypedPropertyPath<M, P> 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<T> 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<P> getTypeInformation() {
return (TypeInformation<P>) self.getTypeInformation();
}
@Override
public boolean hasNext() {
return true;
}
@Override
public PropertyPath next() {
return nextSegment;
}
@Override
public Iterator<PropertyPath> iterator() {
CompositeIterator<PropertyPath> 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;
}
}
}

78
src/main/java/org/springframework/data/domain/ExampleMatcher.java

@ -15,6 +15,7 @@ @@ -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 java.util.function.Function; @@ -24,6 +25,7 @@ import java.util.function.Function;
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 @@ public interface ExampleMatcher { @@ -77,6 +79,21 @@ public interface ExampleMatcher {
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 <T> ExampleMatcher withIgnorePaths(TypedPropertyPath<T, ?>... ignoredPaths) {
return withIgnorePaths(Arrays.stream(ignoredPaths).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 @@ public interface ExampleMatcher { @@ -122,6 +139,22 @@ public interface ExampleMatcher {
@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 <T, P> ExampleMatcher withMatcher(TypedPropertyPath<T, P> propertyPath,
MatcherConfigurer<GenericPropertyMatcher> 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 @@ public interface ExampleMatcher { @@ -143,6 +176,21 @@ public interface ExampleMatcher {
return withMatcher(propertyPath, 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.
*
* @param propertyPath must not be {@literal null}.
* @param genericPropertyMatcher callback to configure a {@link GenericPropertyMatcher}, must not be {@literal null}.
* @return new instance of {@link ExampleMatcher}.
*/
@Contract("_, _ -> new")
@CheckReturnValue
default <T, P> ExampleMatcher withMatcher(TypedPropertyPath<T, P> 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 @@ public interface ExampleMatcher { @@ -155,6 +203,22 @@ public interface ExampleMatcher {
@CheckReturnValue
ExampleMatcher withMatcher(String propertyPath, GenericPropertyMatcher genericPropertyMatcher);
/**
* Returns a copy of this {@link ExampleMatcher} with the specified {@code PropertyValueTransformer} for the
* {@code propertyPath}.
*
* @param propertyPath must not be {@literal null}.
* @param propertyValueTransformer must not be {@literal null}.
* @return new instance of {@link ExampleMatcher}.
* @since 4.1
*/
@Contract("_, _ -> new")
@CheckReturnValue
default <T, P> ExampleMatcher withTransformer(TypedPropertyPath<T, P> 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 @@ public interface ExampleMatcher { @@ -167,6 +231,20 @@ public interface ExampleMatcher {
@CheckReturnValue
ExampleMatcher withTransformer(String propertyPath, PropertyValueTransformer propertyValueTransformer);
/**
* 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.
*
* @param propertyPaths must not be {@literal null} and not empty.
* @return new instance of {@link ExampleMatcher}.
*/
@Contract("_ -> new")
@CheckReturnValue
default <T> ExampleMatcher withIgnoreCase(TypedPropertyPath<T, ?>... propertyPaths) {
return withIgnoreCase(Arrays.stream(propertyPaths).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.

104
src/main/java/org/springframework/data/domain/Sort.java

@ -29,6 +29,8 @@ import java.util.stream.Collectors; @@ -29,6 +29,8 @@ import java.util.stream.Collectors;
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,25 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord @@ -94,6 +96,25 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord
: 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 <T> Sort by(TypedPropertyPath<T, ?>... properties) {
Assert.notNull(properties, "Properties must not be null");
return properties.length == 0 //
? Sort.unsorted() //
: new Sort(DEFAULT_DIRECTION,
Arrays.stream(properties).map(PropertyPath::toDotPath)
.collect(Collectors.toList()));
}
/**
* Creates a new {@link Sort} for the given {@link Order}s.
*
@ -120,6 +141,25 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord @@ -120,6 +141,25 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord
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 <T> Sort by(Direction direction, TypedPropertyPath<T, ?>... 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(PropertyPath::toDotPath)
.map(it -> new Order(direction, it)).toList());
}
/**
* Creates a new {@link Sort} for the given {@link Direction} and properties.
*
@ -144,7 +184,9 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord @@ -144,7 +184,9 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord
* @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 <T> TypedSort<T> sort(Class<T> type) {
return new TypedSort<>(type);
}
@ -460,6 +502,17 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord @@ -460,6 +502,17 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord
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 <T, P> Order by(TypedPropertyPath<T, P> propertyPath) {
return by(propertyPath.toDotPath());
}
/**
* Creates a new {@link Order} instance. Takes a single property. Direction defaults to
* {@link Sort#DEFAULT_DIRECTION}.
@ -471,6 +524,17 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord @@ -471,6 +524,17 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord
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 <T, P> Order asc(TypedPropertyPath<T, P> propertyPath) {
return asc(propertyPath.toDotPath());
}
/**
* Creates a new {@link Order} instance. Takes a single property. Direction is {@link Direction#ASC} and
* NullHandling {@link NullHandling#NATIVE}.
@ -482,6 +546,17 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord @@ -482,6 +546,17 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord
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 <T, P> Order desc(TypedPropertyPath<T, P> propertyPath) {
return desc(propertyPath.toDotPath());
}
/**
* Creates a new {@link Order} instance. Takes a single property. Direction is {@link Direction#DESC} and
* NullHandling {@link NullHandling#NATIVE}.
@ -562,6 +637,19 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord @@ -562,6 +637,19 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord
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 <T, P> Order withProperty(TypedPropertyPath<T, P> propertyPath) {
return withProperty(propertyPath.toDotPath());
}
/**
* Returns a new {@link Order} with the {@code property} name applied.
*
@ -575,6 +663,20 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord @@ -575,6 +663,20 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord
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 <T> Sort withProperties(TypedPropertyPath<T, ?>... 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 +798,9 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord @@ -696,7 +798,9 @@ public class Sort implements Streamable<org.springframework.data.domain.Sort.Ord
* @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<T> extends Sort {
private static final @Serial long serialVersionUID = -3550403511206745880L;

30
src/main/java/org/springframework/data/repository/query/FluentQuery.java

@ -27,6 +27,8 @@ import java.util.stream.Stream; @@ -27,6 +27,8 @@ import java.util.stream.Stream;
import org.jspecify.annotations.Nullable;
import org.springframework.data.core.PropertyPath;
import org.springframework.data.core.TypedPropertyPath;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.ScrollPosition;
@ -98,6 +100,20 @@ public interface FluentQuery<T> { @@ -98,6 +100,20 @@ public interface FluentQuery<T> {
return project(Arrays.asList(properties));
}
/**
* Define which properties or property paths to include in the query.
*
* @param properties must not be {@code null}.
* @return a new instance of {@link FluentQuery}.
* @throws IllegalArgumentException if {@code properties} is {@code null}.
* @since 4.1
*/
@Contract("_ -> new")
@CheckReturnValue
default FluentQuery<T> project(TypedPropertyPath<T, ?>... properties) {
return project(Arrays.stream(properties).map(PropertyPath::toDotPath).toList());
}
/**
* Define which properties or property paths to include in the query.
*
@ -141,6 +157,13 @@ public interface FluentQuery<T> { @@ -141,6 +157,13 @@ public interface FluentQuery<T> {
return project(Arrays.asList(properties));
}
@Override
@Contract("_ -> new")
@CheckReturnValue
default FluentQuery<T> project(TypedPropertyPath<T, ?>... properties) {
return project(Arrays.stream(properties).map(PropertyPath::toDotPath).toList());
}
@Override
@Contract("_ -> new")
@CheckReturnValue
@ -278,6 +301,13 @@ public interface FluentQuery<T> { @@ -278,6 +301,13 @@ public interface FluentQuery<T> {
return project(Arrays.asList(properties));
}
@Override
@Contract("_ -> new")
@CheckReturnValue
default FluentQuery<T> project(TypedPropertyPath<T, ?>... properties) {
return project(Arrays.stream(properties).map(PropertyPath::toDotPath).toList());
}
@Override
@Contract("_ -> new")
@CheckReturnValue

4
src/main/java/org/springframework/data/util/MethodInvocationRecorder.java

@ -44,7 +44,11 @@ import org.springframework.util.StringUtils; @@ -44,7 +44,11 @@ import org.springframework.util.StringUtils;
* @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;

77
src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt

@ -0,0 +1,77 @@ @@ -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 <T : Any, P> KProperty1<T, P>.toPropertyPath(): TypedPropertyPath<T, P> =
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 <T, M, P> KProperty1<T, M?>.div(other: KProperty1<M, P?>): KProperty1<T, P> =
KSinglePropertyReference(this, other) as KProperty1<T, P>
/**
* 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 <T, M, P> KProperty1<T, Collection<M?>?>.div(other: KProperty1<M, P?>): KProperty1<T, P> =
KIterablePropertyReference(this, other) as KProperty1<T, P>

116
src/main/kotlin/org/springframework/data/core/KPropertyPath.kt

@ -0,0 +1,116 @@ @@ -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<T, out P> : KProperty1<T, P> {
val property: KProperty1<T, *>
val leaf: KProperty1<*, P>
}
/**
* Abstraction of a single property reference wrapping [KProperty1].
*
* @author Mark Paluch
* @since 4.1
*/
internal class KSinglePropertyReference<T, M, out P>(
val parent: KProperty1<T, M?>,
val child: KProperty1<M, P>
) : KProperty1<T, P> by child as KProperty1<T, P>, KPropertyPath<T, P> {
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<T, *>
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<T, M, out P>(
val parent: KProperty1<T, Iterable<M?>?>,
val child: KProperty1<M, P>
) : KProperty1<T, P> by child as KProperty1<T, P>, KPropertyPath<T, P> {
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<T, *>
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
}
}

105
src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt

@ -0,0 +1,105 @@ @@ -0,0 +1,105 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@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 <T : Any, P : Any?, N : Any> PropertyReference<T, P>.then(next: KProperty1<T, P?>): TypedPropertyPath<T, N> {
val nextPath = KPropertyReference.of<T, P>(next) as PropertyReference<P, N>
return TypedPropertyPaths.compose(this, nextPath)
}
/**
* Extension function to compose a [TypedPropertyPath] with a [KProperty].
*
* @since 4.1
*/
fun <T : Any, P : Any?, N : Any> PropertyReference<T, P>.then(next: KProperty<P?>): TypedPropertyPath<T, N> {
val nextPath = KPropertyReference.of<T, P>(next) as PropertyReference<P, N>
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 <T : Any, P : Any> of(property: KProperty1<T, P?>): PropertyReference<T, P> {
return of((property as KProperty<P?>))
}
/**
* Create a [PropertyReference] from a collection-like [KProperty1] reference.
* @param property the property reference, must not be a property path.
*/
@JvmName("ofMany")
fun <T : Any, P : Any> of(property: KProperty1<T, Iterable<P?>?>): PropertyReference<T, P> {
return of((property as KProperty<P?>))
}
/**
* Create a [PropertyReference] from a [KProperty].
*/
fun <T, P> of(property: KProperty<P?>): PropertyReference<T, P> {
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")
}
}
}

92
src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt

@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@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 <T : Any, P : Any> of(property: KProperty1<T, P?>): TypedPropertyPath<T, P> {
return of((property as KProperty<P?>))
}
/**
* Create a [TypedPropertyPath] from a collection-like [KProperty1].
*/
@JvmName("ofMany")
fun <T : Any, P : Any> of(property: KProperty1<T, Iterable<P?>?>): TypedPropertyPath<T, P> {
return of((property as KProperty<P?>))
}
/**
* Create a [TypedPropertyPath] from a [KProperty].
*/
fun <T, P> of(property: KProperty<P?>): TypedPropertyPath<T, P> {
if (property is KPropertyPath<*, *>) {
val paths = property as KPropertyPath<*, *>
val parent = of<Any, Any>(paths.property)
val child = of<Any, Any>(paths.leaf)
return TypedPropertyPaths.compose(
parent,
child
) as TypedPropertyPath<T, P>
}
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")
}
}
}

4
src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt

@ -49,7 +49,7 @@ internal class KIterablePropertyPath<T, U>( @@ -49,7 +49,7 @@ internal class KIterablePropertyPath<T, U>(
* @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 { @@ -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 <T, U> KProperty<T?>.div(other: KProperty1<T, U>): KProperty<U> =
KPropertyPath(this, other)
@ -92,5 +93,6 @@ operator fun <T, U> KProperty<T?>.div(other: KProperty1<T, U>): KProperty<U> = @@ -92,5 +93,6 @@ operator fun <T, U> KProperty<T?>.div(other: KProperty1<T, U>): KProperty<U> =
* @since 3.5
*/
@JvmName("divIterable")
@Deprecated("since 4.1, use the org.springframework.data.core extensions instead")
operator fun <T, U> KProperty<Collection<T?>?>.div(other: KProperty1<T, U>): KProperty<U> =
KIterablePropertyPath(this, other)

1
src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt

@ -24,4 +24,5 @@ import kotlin.reflect.KProperty @@ -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)

15
src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java

@ -26,6 +26,7 @@ import org.springframework.data.mapping.context.SamplePersistentProperty; @@ -26,6 +26,7 @@ import org.springframework.data.mapping.context.SamplePersistentProperty;
* Unit tests for {@link ValueConverterRegistry}.
*
* @author Christoph Strobl
* @author Mark Paluch
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
class PropertyValueConverterRegistrarUnitTests {
@ -68,6 +69,20 @@ class PropertyValueConverterRegistrarUnitTests { @@ -68,6 +69,20 @@ class PropertyValueConverterRegistrarUnitTests {
assertThat(name.read("off", null)).isEqualTo("off");
}
@Test // GH-3400
void allowsTypeSafeConverterRegistrationViaPropertyReference() {
PropertyValueConverterRegistrar<SamplePersistentProperty> registrar = new PropertyValueConverterRegistrar<>();
registrar.registerConverter(Person::getName) //
.writing(PropertyValueConverterRegistrarUnitTests::reverse) //
.readingAsIs();
PropertyValueConverter<String, String, ? extends ValueConversionContext<SamplePersistentProperty>> name = registrar
.buildRegistry().getConverter(Person.class, "name");
assertThat(name.write("foo", null)).isEqualTo("oof");
assertThat(name.read("мир", null)).isEqualTo("мир");
}
@Test // GH-1484
void allowsTypeSafeConverterRegistrationViaRecordedProperty() {

70
src/test/java/org/springframework/data/core/PropertyPathTck.java

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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<PropertyPath> actualIterator = actual.iterator();
Iterator<PropertyPath> 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);
}
}
}

20
src/test/java/org/springframework/data/core/PropertyPathUnitTests.java

@ -23,8 +23,12 @@ import java.util.List; @@ -23,8 +23,12 @@ import java.util.List;
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 @@ class PropertyPathUnitTests { @@ -453,6 +457,22 @@ class PropertyPathUnitTests {
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<Arguments.ArgumentSet> 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;

63
src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java

@ -0,0 +1,63 @@ @@ -0,0 +1,63 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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<Person, String> c = convert(Person::getName);
System.out.println(PropertyPathUtil.resolve(c));
}
static <T, P, C extends Converter<T, P> & Serializable> Serializable of(C mapping) {
return mapping;
}
static <A, B, T extends Converter<A, B> & 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;
}
}
}

316
src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java

@ -0,0 +1,316 @@ @@ -0,0 +1,316 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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<Object, Object>) obj -> null));
assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
.isThrownBy(() -> PropertyReference.of((PropertyReference<Object, Object>) obj -> 1));
assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
.isThrownBy(() -> PropertyReference.of((PropertyReference<Object, Object>) obj -> ""));
}
@Test // GH-3400
@SuppressWarnings("Convert2Lambda")
void classImplementationShouldFail() {
assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
.isThrownBy(() -> PropertyReference.of(new PropertyReference<Object, Object>() {
@Override
public @Nullable Object get(Object obj) {
return null;
}
}));
}
@Test // GH-3400
void constructorMethodReferenceShouldFail() {
assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
.isThrownBy(() -> PropertyReference.<PersonQuery, PersonQuery> 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<Address> 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<Address> getAddresses() {
return addresses;
}
public void setAddresses(List<Address> 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<String, String> {
INSTANCE;
@Override
public @Nullable String get(String obj) {
return "";
}
}
}

352
src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java

@ -0,0 +1,352 @@ @@ -0,0 +1,352 @@
/*
* Copyright 2025-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.core;
import 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<Arguments.ArgumentSet> 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 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<Object, Object>) obj -> null));
assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
.isThrownBy(() -> TypedPropertyPath.of((TypedPropertyPath<Object, Object>) obj -> 1));
assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
.isThrownBy(() -> TypedPropertyPath.of((TypedPropertyPath<Object, Object>) obj -> ""));
}
@Test // GH-3400
@SuppressWarnings("Convert2Lambda")
void classImplementationShouldFail() {
assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
.isThrownBy(() -> TypedPropertyPath.of(new TypedPropertyPath<Object, Object>() {
@Override
public @Nullable Object get(Object obj) {
return null;
}
}));
}
@Test // GH-3400
void constructorMethodReferenceShouldFail() {
assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
.isThrownBy(() -> PropertyPath.<PersonQuery, PersonQuery> of(PersonQuery::new));
}
@Test // GH-3400
void resolvesMRRecordPath() {
TypedPropertyPath<PersonQuery, String> 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<Address> 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<Address> getAddresses() {
return addresses;
}
public void setAddresses(List<Address> 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<String, String> {
INSTANCE;
@Override
public @Nullable String get(String obj) {
return "";
}
}
}

61
src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java

@ -121,6 +121,15 @@ class ExampleMatcherUnitTests { @@ -121,6 +121,15 @@ class ExampleMatcherUnitTests {
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 @@ class ExampleMatcherUnitTests { @@ -160,11 +169,11 @@ class ExampleMatcherUnitTests {
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 @@ class ExampleMatcherUnitTests { @@ -182,8 +191,54 @@ class ExampleMatcherUnitTests {
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;
}
}
}

64
src/test/java/org/springframework/data/domain/SortUnitTests.java

@ -21,9 +21,12 @@ import static org.springframework.data.domain.Sort.NullHandling.*; @@ -21,9 +21,12 @@ import static org.springframework.data.domain.Sort.NullHandling.*;
import java.util.Collection;
import org.junit.jupiter.api.Test;
import org.springframework.data.core.TypedPropertyPath;
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 @@ class SortUnitTests { @@ -44,6 +47,37 @@ class SortUnitTests {
assertThat(Sort.by("foo").iterator().next().getDirection()).isEqualTo(Sort.DEFAULT_DIRECTION);
}
@Test // GH-3400
void appliesDefaultForOrderForProperty() {
assertThat(Sort.by(Person::getFirstName).iterator().next().getDirection()).isEqualTo(Sort.DEFAULT_DIRECTION);
}
@Test // GH-3400
void appliesPropertyPath() {
record PersonHolder(Person person) {
}
assertThat(Sort.by(Person::getFirstName).iterator().next().getProperty()).isEqualTo("firstName");
assertThat(
Sort.by(TypedPropertyPath.path(PersonHolder::person).then(Person::getFirstName)).iterator().next()
.getProperty())
.isEqualTo("person.firstName");
}
@Test // GH-3400
void appliesPropertyPaths() {
assertThat(Sort.by(Person::getFirstName, Person::getLastName).stream().map(Order::getProperty))
.containsSequence("firstName", "lastName");
}
@Test // GH-3400
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 @@ class SortUnitTests { @@ -74,7 +108,7 @@ class SortUnitTests {
*/
@Test
void preventsNoProperties() {
assertThatIllegalArgumentException().isThrownBy(() -> Sort.by(Direction.ASC));
assertThatIllegalArgumentException().isThrownBy(() -> Sort.by(Direction.ASC, new String[0]));
}
@Test
@ -108,6 +142,14 @@ class SortUnitTests { @@ -108,6 +142,14 @@ class SortUnitTests {
assertThat(Order.desc("foo").isIgnoreCase()).isFalse();
}
@Test // GH-3400
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 @@ class SortUnitTests { @@ -166,6 +208,26 @@ class SortUnitTests {
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 // GH-3400
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() {

179
src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt

@ -0,0 +1,179 @@ @@ -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<ArgumentSet> {
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<Book>)
class Person {
var name: String? = null
var age: Int = 0
var address: Address? = null
var addresses: List<Address> = emptyList()
}
class Address {
var city: String? = null
var street: String? = null
var country: Country? = null
}
data class Country(val name: String)
}

63
src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt

@ -0,0 +1,63 @@ @@ -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<Address> = 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)
}

75
src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt

@ -0,0 +1,75 @@ @@ -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<ArgumentSet> {
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<Address> = 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)
}

38
src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt

@ -0,0 +1,38 @@ @@ -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)
}

117
src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt

@ -0,0 +1,117 @@ @@ -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<ArgumentSet> {
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>(Person::address)
.then(Address::country),
PropertyPath.from("address.country", Person::class.java)
),
Arguments.argumentSet(
"Person.address.country.name",
TypedPropertyPath.path<Person, Address>(Person::address)
.then<Country>(Address::country).then(Country::name),
PropertyPath.from("address.country.name", Person::class.java)
),
Arguments.argumentSet(
"Person.emergencyContact.address.country.name",
TypedPropertyPath.path<Person, Person>(Person::emergencyContact)
.then<Address>(Person::address).then<Country>(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>(Person::address)
.then(Address::city);
assertThat(path.toDotPath()).isEqualTo("address.city")
}
@Test // GH-3400
fun shouldSupportPropertyLambda() {
assertThat(TypedPropertyPath.path<Person, Address> { it.address }
.toDotPath()).isEqualTo("address")
assertThat(TypedPropertyPath.path<Person, Address> { foo -> foo.address }
.toDotPath()).isEqualTo("address")
}
@Test // GH-3400
fun shouldSupportComposedPropertyLambda() {
val path = TypedPropertyPath.path<Person, Address> { 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)
}
Loading…
Cancel
Save