From 4de4c518ec0d4d93da49ea6a46b1c870d8f34d14 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 8 Oct 2025 11:07:18 +0200 Subject: [PATCH 01/25] Prepare issue branch. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 253b26b609..072feffac7 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 4.1.0-SNAPSHOT + 4.1.0-GH-3400-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. From 209ebfd0429086b612281feb49e1fabbe842995b Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 8 Oct 2025 10:35:28 +0200 Subject: [PATCH 02/25] Introduce TypedPropertyPath. --- .../data/core/PropertyPath.java | 13 + .../data/core/SimplePropertyPath.java | 10 - .../data/mapping/TypedPropertyPath.java | 134 ++++ .../data/mapping/TypedPropertyPaths.java | 639 ++++++++++++++++++ .../data/mapping/KPropertyPath.kt | 2 +- .../mapping/TypedPropertyPathUnitTests.java | 306 +++++++++ 6 files changed, 1093 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/springframework/data/mapping/TypedPropertyPath.java create mode 100644 src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java create mode 100644 src/test/java/org/springframework/data/mapping/TypedPropertyPathUnitTests.java diff --git a/src/main/java/org/springframework/data/core/PropertyPath.java b/src/main/java/org/springframework/data/core/PropertyPath.java index 26a06299c9..be13f23afd 100644 --- a/src/main/java/org/springframework/data/core/PropertyPath.java +++ b/src/main/java/org/springframework/data/core/PropertyPath.java @@ -34,6 +34,19 @@ */ public interface PropertyPath extends Streamable { + /** + * Syntax sugar to create a {@link TypedPropertyPath} from an existing one, ideal for method handles. + * + * @param propertyPath + * @return + * @param owning type. + * @param property type. + * @since xxx + */ + public static TypedPropertyPath of(TypedPropertyPath propertyPath) { + return TypedPropertyPath.of(propertyPath); + } + /** * Returns the owning type of the {@link PropertyPath}. * diff --git a/src/main/java/org/springframework/data/core/SimplePropertyPath.java b/src/main/java/org/springframework/data/core/SimplePropertyPath.java index be48ce3637..bcf9dc2646 100644 --- a/src/main/java/org/springframework/data/core/SimplePropertyPath.java +++ b/src/main/java/org/springframework/data/core/SimplePropertyPath.java @@ -148,16 +148,6 @@ public boolean isCollection() { return isCollection; } - @Override - public SimplePropertyPath nested(String path) { - - Assert.hasText(path, "Path must not be null or empty"); - - String lookup = toDotPath().concat(".").concat(path); - - return SimplePropertyPath.from(lookup, owningType); - } - @Override public Iterator iterator() { diff --git a/src/main/java/org/springframework/data/mapping/TypedPropertyPath.java b/src/main/java/org/springframework/data/mapping/TypedPropertyPath.java new file mode 100644 index 0000000000..de570fb0e7 --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/TypedPropertyPath.java @@ -0,0 +1,134 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Iterator; + +import org.jspecify.annotations.Nullable; +import org.springframework.data.util.TypeInformation; + +/** + * Type-safe representation of a property path expressed through method references. + *

+ * This functional interface extends {@link PropertyPath} to provide compile-time type safety when declaring property + * paths. Instead of using {@link PropertyPath#from(String, TypeInformation) string-based property paths} that represent + * references to properties textually and that are prone to refactoring issues, {@code TypedPropertyPath} leverages + * Java's declarative method references and lambda expressions to ensure type-safe property access. + *

+ * Typed property paths can be created directly they are accepted used or conveniently using the static factory method + * {@link #of(TypedPropertyPath)} with method references: + * + *

+ * PropertyPath.of(Person::getName);
+ * 
+ * + * Property paths can be composed to navigate nested properties using {@link #then(TypedPropertyPath)}: + * + *
+ * PropertyPath.of(Person::getAddress).then(Address::getCountry).then(Country::getName);
+ * 
+ *

+ * The interface maintains type information throughout the property path chain: the {@code T} type parameter represents + * its owning type (root type for composed paths), while {@code P} represents the property value type at this path + * segment. + *

+ * As a functional interface, {@code TypedPropertyPath} should be implemented as method reference (recommended). + * Alternatively, the interface can be implemented as lambda extracting a property value from an object of type + * {@code T}. Implementations must ensure that the method reference or lambda correctly represents a property access + * through a method invocation or by field access. Arbitrary calls to non-getter methods (i.e. methods accepting + * parameters or arbitrary method calls on types other than the owning type are not allowed and will fail with + * {@link org.springframework.dao.InvalidDataAccessApiUsageException}. + *

+ * Note that using lambda expressions requires bytecode analysis of the declaration site classes and therefore presence + * of their class files. + * + * @param the owning type of the property path segment, but typically the root type for composed property paths. + * @param

the property value type at this path segment. + * @author Mark Paluch + * @see PropertyPath + * @see #of(TypedPropertyPath) + * @see #then(TypedPropertyPath) + */ +@FunctionalInterface +public interface TypedPropertyPath extends PropertyPath, Serializable { + + /** + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda. + *

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

property type. + * @return the typed property path. + */ + static TypedPropertyPath of(TypedPropertyPath lambda) { + return TypedPropertyPaths.of(lambda); + } + + /** + * Get the property value for the given object. + * + * @param obj the object to get the property value from. + * @return the property value. + */ + @Nullable + P get(T obj); + + @Override + default TypeInformation getOwningType() { + return TypedPropertyPaths.getPropertyPathInformation(this).owner(); + } + + @Override + default String getSegment() { + return TypedPropertyPaths.getPropertyPathInformation(this).property().getName(); + } + + @Override + default TypeInformation getTypeInformation() { + return TypedPropertyPaths.getPropertyPathInformation(this).propertyType(); + } + + @Override + @Nullable + default PropertyPath next() { + return null; + } + + @Override + default boolean hasNext() { + return false; + } + + @Override + default Iterator iterator() { + return Collections.singletonList((PropertyPath) this).iterator(); + } + + /** + * Extend the property path by appending the {@code next} path segment and returning a new property path instance.. + * + * @param next the next property path segment accepting a property path owned by the {@code P} type. + * @param the new property value type. + * @return a new composed {@code TypedPropertyPath}. + */ + default TypedPropertyPath then(TypedPropertyPath next) { + return TypedPropertyPaths.compose(this, of(next)); + } +} diff --git a/src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java b/src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java new file mode 100644 index 0000000000..30852793af --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java @@ -0,0 +1,639 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping; + +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandleInfo; +import java.lang.invoke.SerializedLambda; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +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.Type; +import org.springframework.beans.BeanUtils; +import org.springframework.core.ResolvableType; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.mapping.model.Property; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.ClassUtils; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; + +/** + * Utility class to parse and resolve {@link TypedPropertyPath} instances. + */ +class TypedPropertyPaths { + + private static final Map> lambdas = new WeakHashMap<>(); + private static final Map>> resolved = new WeakHashMap<>(); + + /** + * Retrieve {@link PropertyPathInformation} for a given {@link TypedPropertyPath}. + */ + public static PropertyPathInformation getPropertyPathInformation(TypedPropertyPath lambda) { + + Map cache; + synchronized (lambdas) { + cache = lambdas.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); + } + Map lambdaMap = cache; + + return lambdaMap.computeIfAbsent(lambda, o -> extractPath(lambda.getClass().getClassLoader(), lambda)); + } + + /** + * Compose a {@link TypedPropertyPath} by appending {@code next}. + */ + public static TypedPropertyPath compose(TypedPropertyPath owner, TypedPropertyPath next) { + return new ComposedPropertyPath<>(owner, next); + } + + /** + * Resolve a {@link TypedPropertyPath} into a {@link ResolvedTypedPropertyPath}. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static TypedPropertyPath of(TypedPropertyPath lambda) { + + if (lambda instanceof ComposedPropertyPath || lambda instanceof ResolvedTypedPropertyPath) { + return lambda; + } + + Map> cache; + synchronized (resolved) { + cache = resolved.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); + } + + return (TypedPropertyPath) cache.computeIfAbsent(lambda, + o -> new ResolvedTypedPropertyPath(o, getPropertyPathInformation(lambda))); + } + + /** + * Value object holding information about a property path segment. + * + * @param owner + * @param propertyType + * @param property + */ + record PropertyPathInformation(TypeInformation owner, TypeInformation propertyType, Property property) { + + public static PropertyPathInformation ofInvokeVirtual(ClassLoader classLoader, SerializedLambda lambda) + throws ClassNotFoundException { + return ofInvokeVirtual(classLoader, Type.getObjectType(lambda.getImplClass()), lambda.getImplMethodName()); + } + + public static PropertyPathInformation ofInvokeVirtual(ClassLoader classLoader, Type ownerType, String name) + throws ClassNotFoundException { + Class owner = ClassUtils.forName(ownerType.getClassName(), classLoader); + return ofInvokeVirtual(owner, name); + } + + public static PropertyPathInformation ofInvokeVirtual(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)); + } + PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method); + + if (descriptor == null) { + String propertyName; + + if (methodName.startsWith("is")) { + propertyName = Introspector.decapitalize(methodName.substring(2)); + } else if (methodName.startsWith("get")) { + propertyName = Introspector.decapitalize(methodName.substring(3)); + } else { + propertyName = methodName; + } + + TypeInformation fallback = TypeInformation.of(owner).getProperty(propertyName); + if (fallback != null) { + try { + return new PropertyPathInformation(TypeInformation.of(owner), fallback, + Property.of(TypeInformation.of(owner), new PropertyDescriptor(propertyName, method, null))); + } catch (IntrospectionException e) { + throw new IllegalArgumentException( + "Cannot find PropertyDescriptor from method %s.%s".formatted(owner.getName(), methodName), e); + } + } + + throw new IllegalArgumentException( + "Cannot find PropertyDescriptor from method %s.%s".formatted(owner.getName(), methodName)); + } + + return new PropertyPathInformation(TypeInformation.of(owner), + TypeInformation.of(ResolvableType.forMethodReturnType(method, owner)), + Property.of(TypeInformation.of(owner), descriptor)); + } + + public static PropertyPathInformation ofFieldAccess(ClassLoader classLoader, Type ownerType, String name, + Type fieldType) throws ClassNotFoundException { + + Class owner = ClassUtils.forName(ownerType.getClassName(), classLoader); + Class type = ClassUtils.forName(fieldType.getClassName(), classLoader); + + return ofFieldAccess(owner, name, type); + } + + public static PropertyPathInformation ofFieldAccess(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(), field)); + } + + return new PropertyPathInformation(TypeInformation.of(owner), + TypeInformation.of(ResolvableType.forField(field, owner)), Property.of(TypeInformation.of(owner), field)); + } + } + + public static PropertyPathInformation extractPath(ClassLoader classLoader, TypedPropertyPath path) { + + try { + // Use serialization to extract method reference info + SerializedLambda lambda = getSerializedLambda(path); + + if (lambda.getImplMethodKind() == MethodHandleInfo.REF_newInvokeSpecial + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeSpecial) { + InvalidDataAccessApiUsageException e = new InvalidDataAccessApiUsageException( + "Method reference must not be a constructor call"); + e.setStackTrace(filterStackTrace(e.getStackTrace())); + throw e; + } + + // method handle + if ((lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeInterface) + && !lambda.getImplMethodName().startsWith("lambda$")) { + return PropertyPathInformation.ofInvokeVirtual(classLoader, lambda); + } + + if (lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeStatic + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual) { + + 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 analysis.".formatted(classFileName)); + } + + try (classFile) { + + ClassReader cr = new ClassReader(classFile); + LambdaClassVisitor classVisitor = new LambdaClassVisitor(classLoader, lambda.getImplMethodName(), owningType); + cr.accept(classVisitor, ClassReader.SKIP_FRAMES); + return classVisitor.getPropertyPathInformation(lambda); + } + } + } catch (ReflectiveOperationException | IOException e) { + throw new RuntimeException("Cannot extract property path", e); + } + + throw new IllegalArgumentException( + "Cannot extract property path from: " + path + ". The given value is not a Lambda and not a Method Reference."); + } + + private static SerializedLambda getSerializedLambda(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); + } + } + + static class LambdaClassVisitor extends ClassVisitor { + + private final ClassLoader classLoader; + private final String implMethodName; + private final Type owningType; + private @Nullable LambdaMethodVisitor methodVisitor; + + public LambdaClassVisitor(ClassLoader classLoader, String implMethodName, Type owningType) { + super(Opcodes.ASM10_EXPERIMENTAL); + this.classLoader = classLoader; + this.implMethodName = implMethodName; + this.owningType = owningType; + } + + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { + // Capture the lambda body methods for later + if (name.equals(implMethodName)) { + + methodVisitor = new LambdaMethodVisitor(classLoader, owningType); + return methodVisitor; + } + + return null; + } + + public PropertyPathInformation getPropertyPathInformation(SerializedLambda lambda) { + return methodVisitor.getPropertyPathInformation(lambda); + } + } + + static class ResolvedTypedPropertyPath implements TypedPropertyPath { + + private final TypedPropertyPath function; + private final PropertyPathInformation information; + private final List list; + + ResolvedTypedPropertyPath(TypedPropertyPath function, PropertyPathInformation information) { + this.function = function; + this.information = information; + this.list = List.of(this); + } + + @Override + public @Nullable P get(T obj) { + return function.get(obj); + } + + @Override + public TypeInformation getOwningType() { + return information.owner(); + } + + @Override + public String getSegment() { + return information.property().getName(); + } + + @Override + public TypeInformation getTypeInformation() { + return information.propertyType(); + } + + @Override + public Iterator iterator() { + return list.iterator(); + } + + public Stream stream() { + return list.stream(); + } + + @Override + public List toList() { + return list; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (obj == null || obj.getClass() != this.getClass()) + return false; + var that = (ResolvedTypedPropertyPath) obj; + return Objects.equals(this.function, that.function) && Objects.equals(this.information, that.information); + } + + @Override + public int hashCode() { + return Objects.hash(function, information); + } + + @Override + public String toString() { + return information.owner().getType().getSimpleName() + "." + toDotPath(); + } + } + + static class LambdaMethodVisitor extends MethodVisitor { + + private final ClassLoader classLoader; + private final Type owningType; + private int line; + + private static final Set BOXING_TYPES = Set.of(Type.getInternalName(Integer.class), + Type.getInternalName(Long.class), Type.getInternalName(Short.class), Type.getInternalName(Byte.class), + Type.getInternalName(Float.class), Type.getInternalName(Double.class), Type.getInternalName(Character.class), + Type.getInternalName(Boolean.class)); + + private static final String BOXING_METHOD = "valueOf"; + + List propertyPathInformations = new ArrayList<>(); + Set errors = new LinkedHashSet<>(); + + public LambdaMethodVisitor(ClassLoader classLoader, Type owningType) { + super(Opcodes.ASM10_EXPERIMENTAL); + 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; + } + + if (opcode >= Opcodes.DUP && opcode <= Opcodes.DUP2_X2) { + return; + } + + visitLdcInsn(""); + } + + @Override + public void visitLdcInsn(Object value) { + errors.add(new ParseError(line, + "Lambda expressions for Typed property path declaration may only contain method calls to getters, record components, and field access", + null)); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String name, String descriptor) { + + if (opcode == Opcodes.PUTSTATIC || opcode == Opcodes.PUTFIELD) { + errors.add(new ParseError(line, "Put field not allowed in property path lambda", null)); + return; + } + + Type fieldType = Type.getType(descriptor); + + try { + this.propertyPathInformations + .add(PropertyPathInformation.ofFieldAccess(classLoader, owningType, name, fieldType)); + } catch (ClassNotFoundException e) { + errors.add(new ParseError(line, e.getMessage())); + } + } + + @Override + public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { + + if (opcode == Opcodes.INVOKESPECIAL && name.equals("")) { + errors.add(new ParseError(line, "Lambda must not call constructor", null)); + return; + } + + int count = Type.getArgumentCount(descriptor); + + if (count != 0) { + + if (BOXING_TYPES.contains(owner) && name.equals(BOXING_METHOD)) { + return; + } + + errors.add(new ParseError(line, "Property path extraction requires calls to no-arg getters")); + return; + } + + Type ownerType = Type.getObjectType(owner); + if (!ownerType.equals(this.owningType)) { + errors.add(new ParseError(line, + "Cannot derive a property path from method call '%s' on a different owning type. Expected owning type: %s, but was: %s" + .formatted(name, this.owningType.getClassName(), ownerType.getClassName()))); + return; + } + + try { + this.propertyPathInformations.add(PropertyPathInformation.ofInvokeVirtual(classLoader, owningType, name)); + } catch (Exception e) { + errors.add(new ParseError(line, e.getMessage(), e)); + } + } + + public PropertyPathInformation getPropertyPathInformation(SerializedLambda lambda) { + + if (!errors.isEmpty()) { + + if (lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeStatic + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual) { + + Pattern hex = Pattern.compile("[0-9a-f]+"); + 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.matcher(probe).matches()) { + methodName = methodName.substring(0, methodName.lastIndexOf('$')); + } + } + } + + InvalidDataAccessApiUsageException e = new InvalidDataAccessApiUsageException("Cannot resolve property path: " + + errors.stream().map(ParseError::message).collect(Collectors.joining("; "))); + + for (ParseError error : errors) { + if (error.e != null) { + e.addSuppressed(error.e); + } + } + + e.setStackTrace(filterStackTrace(lambda, e.getStackTrace(), methodName)); + + throw e; + } + // lambda$resolvesComposedLambdaFieldAccess$d3dc5794$1 + + throw new IllegalStateException("There are errors in property path lambda " + errors); + } + + if (propertyPathInformations.isEmpty()) { + throw new IllegalStateException("There are no property path information available"); + } + + // TODO composite path information + return propertyPathInformations.get(propertyPathInformations.size() - 1); + } + + private StackTraceElement[] filterStackTrace(SerializedLambda lambda, StackTraceElement[] stackTrace, + String methodName) { + + int filterIndex = findEntryPoint(stackTrace); + + if (filterIndex != -1) { + + StackTraceElement[] copy = new StackTraceElement[(stackTrace.length - filterIndex) + 1]; + System.arraycopy(stackTrace, filterIndex, copy, 1, stackTrace.length - filterIndex); + + StackTraceElement userCode = copy[1]; + StackTraceElement synthetic = createSynthetic(lambda, methodName, userCode); + copy[0] = synthetic; + return copy; + } + + return stackTrace; + } + + private StackTraceElement createSynthetic(SerializedLambda lambda, String methodName, StackTraceElement userCode) { + Type type = Type.getObjectType(lambda.getCapturingClass()); + StackTraceElement synthetic = new StackTraceElement(null, userCode.getModuleName(), userCode.getModuleVersion(), + type.getClassName(), methodName, ClassUtils.getShortName(type.getClassName()) + ".java", + errors.iterator().next().line); + return synthetic; + } + } + + private static StackTraceElement[] filterStackTrace(StackTraceElement[] stackTrace) { + + int filterIndex = findEntryPoint(stackTrace); + + if (filterIndex != -1) { + + StackTraceElement[] copy = new StackTraceElement[(stackTrace.length - filterIndex)]; + System.arraycopy(stackTrace, filterIndex, copy, 0, stackTrace.length - filterIndex); + return copy; + } + + return stackTrace; + } + + private static int findEntryPoint(StackTraceElement[] stackTrace) { + + int filterIndex = -1; + + for (int i = 0; i < stackTrace.length; i++) { + + if (stackTrace[i].getClassName().equals(TypedPropertyPaths.class.getName()) + || stackTrace[i].getClassName().equals(TypedPropertyPath.class.getName()) + || stackTrace[i].getClassName().equals(ComposedPropertyPath.class.getName()) + || stackTrace[i].getClassName().equals(PropertyPath.class.getName())) { + filterIndex = i; + } + } + + return filterIndex; + } + + record ParseError(int line, String message, @Nullable Exception e) { + + ParseError(int line, String message) { + this(line, message, null); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ParseError 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); + } + } + + record ComposedPropertyPath(TypedPropertyPath first, TypedPropertyPath second, + String dotPath) implements TypedPropertyPath { + + ComposedPropertyPath(TypedPropertyPath first, TypedPropertyPath second) { + this(first, second, first.toDotPath() + "." + second.toDotPath()); + } + + @Override + public @Nullable R get(T obj) { + M intermediate = first.get(obj); + return intermediate != null ? second.get(intermediate) : null; + } + + @Override + public TypeInformation getOwningType() { + return first.getOwningType(); + } + + @Override + public String getSegment() { + return first().getSegment(); + } + + @Override + public PropertyPath getLeafProperty() { + return second.getLeafProperty(); + } + + @Override + public TypeInformation getTypeInformation() { + return first.getTypeInformation(); + } + + @Override + public PropertyPath next() { + return second; + } + + @Override + public boolean hasNext() { + return true; + } + + @Override + public String toDotPath() { + return dotPath; + } + + @Override + public Stream stream() { + return second.stream(); + } + + @Override + public Iterator iterator() { + return second.iterator(); + } + + @Override + public String toString() { + return getOwningType().getType().getSimpleName() + "." + toDotPath(); + } + } +} diff --git a/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt b/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt index 3224afff8d..b8a94b80ad 100644 --- a/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt +++ b/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt @@ -49,7 +49,7 @@ internal class KIterablePropertyPath( * @author Tjeu Kayim * @author Mikhail Polivakha */ -internal fun asString(property: KProperty<*>): String { +fun asString(property: KProperty<*>): String { return when (property) { is KPropertyPath<*, *> -> "${asString(property.parent)}.${property.child.name}" diff --git a/src/test/java/org/springframework/data/mapping/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/mapping/TypedPropertyPathUnitTests.java new file mode 100644 index 0000000000..8e9a36562d --- /dev/null +++ b/src/test/java/org/springframework/data/mapping/TypedPropertyPathUnitTests.java @@ -0,0 +1,306 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping; + +import static org.assertj.core.api.Assertions.*; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.springframework.dao.InvalidDataAccessApiUsageException; + +/** + * Unit tests for {@link TypedPropertyPath}. + * + * @author Mark Paluch + */ +class TypedPropertyPathUnitTests { + + @Test + void meetsApiContract() { + + TypedPropertyPath typed = PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry); + + PropertyPath path = PropertyPath.from("address.country", PersonQuery.class); + + assertThat(typed.hasNext()).isTrue(); + assertThat(path.hasNext()).isTrue(); + + assertThat(typed.next().hasNext()).isFalse(); + assertThat(path.next().hasNext()).isFalse(); + + assertThat(typed.getType()).isEqualTo(Address.class); + assertThat(path.getType()).isEqualTo(Address.class); + + assertThat(typed.getSegment()).isEqualTo("address"); + assertThat(path.getSegment()).isEqualTo("address"); + + assertThat(typed.getLeafProperty().getType()).isEqualTo(Country.class); + assertThat(path.getLeafProperty().getType()).isEqualTo(Country.class); + } + + @Test + void resolvesMHSimplePath() { + assertThat(PropertyPath.of(PersonQuery::getName).toDotPath()).isEqualTo("name"); + } + + @Test + void resolvesMHComposedPath() { + assertThat(PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry).toDotPath()) + .isEqualTo("address.country"); + } + + @Test + void resolvesInitialLambdaGetter() { + assertThat(PropertyPath.of((PersonQuery person) -> person.getName()).toDotPath()).isEqualTo("name"); + } + + @Test + void resolvesComposedLambdaGetter() { + assertThat(PropertyPath.of(PersonQuery::getAddress).then(it -> it.getCity()).toDotPath()).isEqualTo("address.city"); + } + + @Test + void resolvesComposedLambdaFieldAccess() { + assertThat(PropertyPath.of(PersonQuery::getAddress).then(it -> it.city).toDotPath()).isEqualTo("address.city"); + } + + @Test + void resolvesInterfaceMethodReferenceGetter() { + assertThat(PropertyPath.of(PersonProjection::getName).toDotPath()).isEqualTo("name"); + } + + @Test + void resolvesInterfaceLambdaGetter() { + assertThat(PropertyPath.of((PersonProjection person) -> person.getName()).toDotPath()).isEqualTo("name"); + } + + @Test + void resolvesSuperclassMethodReferenceGetter() { + assertThat(PropertyPath.of(PersonQuery::getTenant).toDotPath()).isEqualTo("tenant"); + } + + @Test + void resolvesSuperclassLambdaGetter() { + assertThat(PropertyPath.of((PersonQuery person) -> person.getTenant()).toDotPath()).isEqualTo("tenant"); + } + + @Test + void resolvesPrivateMethodReference() { + assertThat(PropertyPath.of(Address::getSecret).toDotPath()).isEqualTo("secret"); + } + + @Test + void resolvesPrivateMethodLambda() { + assertThat(PropertyPath.of((Address address) -> address.getSecret()).toDotPath()).isEqualTo("secret"); + } + + @Test + void switchingOwningTypeFails() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> { + return ((SuperClass) person).getTenant(); + })); + } + + @Test + void constructorCallsShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> new PersonQuery(person))); + } + + @Test + void enumShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of(NotSupported.INSTANCE)); + } + + @Test + void returningSomethingShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((TypedPropertyPath) obj -> null)); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((TypedPropertyPath) obj -> 1)); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((TypedPropertyPath) obj -> "")); + } + + @Test + void classImplementationShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of(new TypedPropertyPath() { + @Override + public @Nullable Object get(Object obj) { + return null; + } + })); + } + + @Test + void constructorMethodReferenceShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath. of(PersonQuery::new)); + } + + @Test + void resolvesMRRecordPath() { + + TypedPropertyPath then = PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry) + .then(Country::name); + + assertThat(then.toDotPath()).isEqualTo("address.country.name"); + } + + @Test + void failsResolutionWith$StrangeStuff() { + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> { + int a = 1 + 2; + new Integer(a).toString(); + return person.getName(); + }).toDotPath()); + } + + @Test + void arithmeticOpsFail() { + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> { + PropertyPath.of((PersonQuery person) -> { + int a = 1 + 2; + return person.getName(); + }); + }); + } + + @Test + void failsResolvingCallingLocalMethod() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> { + failsResolutionWith$StrangeStuff(); + return person.getName(); + })); + } + + // Domain entities + + static public class SuperClass { + private int tenant; + + public int getTenant() { + return tenant; + } + + public void setTenant(int tenant) { + this.tenant = tenant; + } + } + + static public class PersonQuery extends SuperClass { + + private String name; + private Integer age; + private Address address; + + public PersonQuery(PersonQuery pq) {} + + public PersonQuery() {} + + // Getters + public String getName() { + return name; + } + + public Integer getAge() { + return age; + } + + public Address getAddress() { + return address; + } + } + + 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) { + + } + + interface PersonProjection { + + String getName(); + } + + static class Criteria { + + public Criteria(String key) { + + } + + public static Criteria where(String key) { + return new Criteria(key); + } + + public static Criteria where(PropertyPath propertyPath) { + return new Criteria(propertyPath.toDotPath()); + } + + public static Criteria where(TypedPropertyPath propertyPath) { + return new Criteria(propertyPath.toDotPath()); + } + } + + enum NotSupported implements TypedPropertyPath { + + INSTANCE; + + @Override + public @Nullable String get(String obj) { + return ""; + } + } +} From 478ed07437570357ffd8d0274b0ad4b6c11a33e0 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 27 Oct 2025 12:10:21 +0100 Subject: [PATCH 03/25] Remove references to Property. --- .../data/mapping/TypedPropertyPath.java | 2 +- .../data/mapping/TypedPropertyPaths.java | 20 ++++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/springframework/data/mapping/TypedPropertyPath.java b/src/main/java/org/springframework/data/mapping/TypedPropertyPath.java index de570fb0e7..36df105aca 100644 --- a/src/main/java/org/springframework/data/mapping/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/mapping/TypedPropertyPath.java @@ -97,7 +97,7 @@ default TypeInformation getOwningType() { @Override default String getSegment() { - return TypedPropertyPaths.getPropertyPathInformation(this).property().getName(); + return TypedPropertyPaths.getPropertyPathInformation(this).property(); } @Override diff --git a/src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java b/src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java index 30852793af..55d2f3f2a6 100644 --- a/src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java @@ -15,7 +15,6 @@ */ package org.springframework.data.mapping; -import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.io.IOException; @@ -37,6 +36,7 @@ import java.util.stream.Stream; import org.jspecify.annotations.Nullable; + import org.springframework.asm.ClassReader; import org.springframework.asm.ClassVisitor; import org.springframework.asm.Label; @@ -103,12 +103,12 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) /** * Value object holding information about a property path segment. - * + * * @param owner * @param propertyType * @param property */ - record PropertyPathInformation(TypeInformation owner, TypeInformation propertyType, Property property) { + record PropertyPathInformation(TypeInformation owner, TypeInformation propertyType, String property) { public static PropertyPathInformation ofInvokeVirtual(ClassLoader classLoader, SerializedLambda lambda) throws ClassNotFoundException { @@ -142,13 +142,8 @@ public static PropertyPathInformation ofInvokeVirtual(Class owner, String met TypeInformation fallback = TypeInformation.of(owner).getProperty(propertyName); if (fallback != null) { - try { return new PropertyPathInformation(TypeInformation.of(owner), fallback, - Property.of(TypeInformation.of(owner), new PropertyDescriptor(propertyName, method, null))); - } catch (IntrospectionException e) { - throw new IllegalArgumentException( - "Cannot find PropertyDescriptor from method %s.%s".formatted(owner.getName(), methodName), e); - } + propertyName); } throw new IllegalArgumentException( @@ -157,7 +152,7 @@ public static PropertyPathInformation ofInvokeVirtual(Class owner, String met return new PropertyPathInformation(TypeInformation.of(owner), TypeInformation.of(ResolvableType.forMethodReturnType(method, owner)), - Property.of(TypeInformation.of(owner), descriptor)); + descriptor.getName()); } public static PropertyPathInformation ofFieldAccess(ClassLoader classLoader, Type ownerType, String name, @@ -177,7 +172,7 @@ public static PropertyPathInformation ofFieldAccess(Class owner, String field } return new PropertyPathInformation(TypeInformation.of(owner), - TypeInformation.of(ResolvableType.forField(field, owner)), Property.of(TypeInformation.of(owner), field)); + TypeInformation.of(ResolvableType.forField(field, owner)), field.getName()); } } @@ -296,7 +291,7 @@ public TypeInformation getOwningType() { @Override public String getSegment() { - return information.property().getName(); + return information.property(); } @Override @@ -309,6 +304,7 @@ public Iterator iterator() { return list.iterator(); } + @Override public Stream stream() { return list.stream(); } From fa1cf89dbc20b9cc8ebd425bdb60344fbdde0868 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 27 Oct 2025 11:54:33 +0100 Subject: [PATCH 04/25] Retrofit Sort with TypedPropertyPath. --- .../org/springframework/data/domain/Sort.java | 103 ++++++++++++++++++ .../data/domain/SortUnitTests.java | 55 +++++++++- 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/springframework/data/domain/Sort.java b/src/main/java/org/springframework/data/domain/Sort.java index f3d39640dd..4b39749334 100644 --- a/src/main/java/org/springframework/data/domain/Sort.java +++ b/src/main/java/org/springframework/data/domain/Sort.java @@ -31,7 +31,9 @@ import org.springframework.data.util.MethodInvocationRecorder; import org.springframework.data.util.MethodInvocationRecorder.Recorded; +import org.springframework.data.util.PropertyPath; import org.springframework.data.util.Streamable; +import org.springframework.data.util.TypedPropertyPath; import org.springframework.lang.CheckReturnValue; import org.springframework.lang.Contract; import org.springframework.util.Assert; @@ -94,6 +96,24 @@ public static Sort by(String... properties) { : new Sort(DEFAULT_DIRECTION, Arrays.asList(properties)); } + /** + * Creates a new {@link Sort} for the given properties. + * + * @param properties must not be {@literal null}. + * @return {@link Sort} for the given properties. + * @since 4.1 + */ + @SafeVarargs + public static Sort by(TypedPropertyPath... properties) { + + Assert.notNull(properties, "Properties must not be null"); + + return properties.length == 0 // + ? Sort.unsorted() // + : new Sort(DEFAULT_DIRECTION, Arrays.stream(properties).map(TypedPropertyPath::of).map(PropertyPath::toDotPath) + .collect(Collectors.toList())); + } + /** * Creates a new {@link Sort} for the given {@link Order}s. * @@ -120,6 +140,25 @@ public static Sort by(Order... orders) { return new Sort(Arrays.asList(orders)); } + /** + * Creates a new {@link Sort} for the given {@link Direction} and properties. + * + * @param direction must not be {@literal null}. + * @param properties must not be {@literal null}. + * @return {@link Sort} for the given {@link Direction} and properties. + * @since 4.1 + */ + @SafeVarargs + public static Sort by(Direction direction, TypedPropertyPath... properties) { + + Assert.notNull(direction, "Direction must not be null"); + Assert.notNull(properties, "Properties must not be null"); + Assert.isTrue(properties.length > 0, "At least one property must be given"); + + return by(Arrays.stream(properties).map(TypedPropertyPath::of).map(PropertyPath::toDotPath) + .map(it -> new Order(direction, it)).toList()); + } + /** * Creates a new {@link Sort} for the given {@link Direction} and properties. * @@ -144,7 +183,9 @@ public static Sort by(Direction direction, String... properties) { * @param type must not be {@literal null}. * @return {@link TypedSort} for the given type. * @since 2.2 + * @deprecated since 4.1 in favor of {@link Sort#by(TypedPropertyPath[])}. */ + @Deprecated(since = "4.1") public static TypedSort sort(Class type) { return new TypedSort<>(type); } @@ -460,6 +501,17 @@ public Order(@Nullable Direction direction, String property, boolean ignoreCase, this.nullHandling = nullHandling; } + /** + * Creates a new {@link Order} instance. Takes a property path. Direction defaults to + * {@link Sort#DEFAULT_DIRECTION}. + * + * @param propertyPath must not be {@literal null} or empty. + * @since 4.1 + */ + public static Order by(TypedPropertyPath propertyPath) { + return by(TypedPropertyPath.of(propertyPath).toDotPath()); + } + /** * Creates a new {@link Order} instance. Takes a single property. Direction defaults to * {@link Sort#DEFAULT_DIRECTION}. @@ -471,6 +523,17 @@ public static Order by(String property) { return new Order(DEFAULT_DIRECTION, property); } + /** + * Creates a new {@link Order} instance. Takes a property path. Direction is {@link Direction#ASC} and NullHandling + * {@link NullHandling#NATIVE}. + * + * @param propertyPath must not be {@literal null} or empty. + * @since 4.1 + */ + public static Order asc(TypedPropertyPath propertyPath) { + return asc(TypedPropertyPath.of(propertyPath).toDotPath()); + } + /** * Creates a new {@link Order} instance. Takes a single property. Direction is {@link Direction#ASC} and * NullHandling {@link NullHandling#NATIVE}. @@ -482,6 +545,17 @@ public static Order asc(String property) { return new Order(Direction.ASC, property, DEFAULT_NULL_HANDLING); } + /** + * Creates a new {@link Order} instance. Takes a property path. Direction is {@link Direction#DESC} and NullHandling + * {@link NullHandling#NATIVE}. + * + * @param propertyPath must not be {@literal null} or empty. + * @since 4.1 + */ + public static Order desc(TypedPropertyPath propertyPath) { + return desc(TypedPropertyPath.of(propertyPath).toDotPath()); + } + /** * Creates a new {@link Order} instance. Takes a single property. Direction is {@link Direction#DESC} and * NullHandling {@link NullHandling#NATIVE}. @@ -562,6 +636,19 @@ public Order reverse() { return with(this.direction == Direction.ASC ? Direction.DESC : Direction.ASC); } + /** + * Returns a new {@link Order} with the {@code propertyPath} applied. + * + * @param propertyPath must not be {@literal null} or empty. + * @return a new {@link Order} with the {@code propertyPath} applied. + * @since 4.1 + */ + @Contract("_ -> new") + @CheckReturnValue + public Order withProperty(TypedPropertyPath propertyPath) { + return withProperty(TypedPropertyPath.of(propertyPath).toDotPath()); + } + /** * Returns a new {@link Order} with the {@code property} name applied. * @@ -575,6 +662,20 @@ public Order withProperty(String property) { return new Order(this.direction, property, this.ignoreCase, this.nullHandling); } + /** + * Returns a new {@link Sort} instance for the given properties using {@link #getDirection()}. + * + * @param propertyPaths properties to sort by. + * @return a new {@link Sort} instance for the given properties using {@link #getDirection()}. + * @since 4.1 + */ + @Contract("_ -> new") + @CheckReturnValue + public Sort withProperties(TypedPropertyPath... propertyPaths) { + return Sort.by(this.direction, + Arrays.stream(propertyPaths).map(TypedPropertyPath::toDotPath).toArray(String[]::new)); + } + /** * Returns a new {@link Sort} instance for the given properties using {@link #getDirection()}. * @@ -696,7 +797,9 @@ public String toString() { * @author Oliver Gierke * @since 2.2 * @soundtrack The Intersphere - Linger (The Grand Delusion) + * @deprecated since 4.1 in favor of {@link Sort#by(org.springframework.data.util.TypedPropertyPath...)} */ + @Deprecated(since = "4.1") public static class TypedSort extends Sort { private static final @Serial long serialVersionUID = -3550403511206745880L; diff --git a/src/test/java/org/springframework/data/domain/SortUnitTests.java b/src/test/java/org/springframework/data/domain/SortUnitTests.java index 7d03e8c6d5..4e0b1c853a 100755 --- a/src/test/java/org/springframework/data/domain/SortUnitTests.java +++ b/src/test/java/org/springframework/data/domain/SortUnitTests.java @@ -24,6 +24,8 @@ 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; +import org.springframework.data.util.TypedPropertyPath; /** * Unit test for {@link Sort}. @@ -44,6 +46,29 @@ void appliesDefaultForOrder() { assertThat(Sort.by("foo").iterator().next().getDirection()).isEqualTo(Sort.DEFAULT_DIRECTION); } + @Test + void appliesDefaultForOrderForProperty() { + assertThat(Sort.by(Person::getFirstName).iterator().next().getDirection()).isEqualTo(Sort.DEFAULT_DIRECTION); + } + + @Test + void appliesPropertyPath() { + + record PersonHolder(Person person) { + } + + assertThat(Sort.by(Person::getFirstName).iterator().next().getProperty()).isEqualTo("firstName"); + assertThat( + Sort.by(TypedPropertyPath.of(PersonHolder::person).then(Person::getFirstName)).iterator().next().getProperty()) + .isEqualTo("person.firstName"); + } + + @Test + void appliesPropertyPaths() { + assertThat(Sort.by(Person::getFirstName, Person::getLastName).stream().map(Order::getProperty)) + .containsSequence("firstName", "lastName"); + } + /** * Asserts that the class rejects {@code null} as properties array. */ @@ -74,7 +99,7 @@ void preventsEmptyProperty() { */ @Test void preventsNoProperties() { - assertThatIllegalArgumentException().isThrownBy(() -> Sort.by(Direction.ASC)); + assertThatIllegalArgumentException().isThrownBy(() -> Sort.by(Direction.ASC, new String[0])); } @Test @@ -108,6 +133,14 @@ void orderDoesNotIgnoreCaseByDefault() { assertThat(Order.desc("foo").isIgnoreCase()).isFalse(); } + @Test + void orderFactoryMethodsConsiderPropertyPath() { + + assertThat(Order.by(Person::getFirstName)).isEqualTo(Order.by("firstName")); + assertThat(Order.asc(Person::getFirstName)).isEqualTo(Order.asc("firstName")); + assertThat(Order.desc(Person::getFirstName)).isEqualTo(Order.desc("firstName")); + } + @Test // DATACMNS-1021 void createsOrderWithDirection() { @@ -166,6 +199,26 @@ void createsNewOrderForDifferentProperty() { assertThat(result.isIgnoreCase()).isEqualTo(source.isIgnoreCase()); } + @Test + void createsNewOrderForDifferentPropertyPath() { + + var source = Order.desc("foo").nullsFirst().ignoreCase(); + var result = source.withProperty(Person::getFirstName); + + assertThat(result.getProperty()).isEqualTo("firstName"); + assertThat(result.getDirection()).isEqualTo(source.getDirection()); + assertThat(result.getNullHandling()).isEqualTo(source.getNullHandling()); + assertThat(result.isIgnoreCase()).isEqualTo(source.isIgnoreCase()); + } + + @Test + void createsNewOrderFromPaths() { + + var sort = Order.desc("foo").withProperties(Person::getFirstName, Person::getLastName); + + assertThat(sort).isEqualTo(Sort.by(Direction.DESC, "firstName", "lastName")); + } + @Test @SuppressWarnings("null") void preventsNullDirection() { From 31cf50192c16a11916a7ba57fbfb9e4cb2219921 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 27 Oct 2025 12:02:36 +0100 Subject: [PATCH 05/25] Retrofit ExampleMatcher with TypedPropertyPath. --- .../{mapping => core}/TypedPropertyPath.java | 11 ++- .../{mapping => core}/TypedPropertyPaths.java | 10 +-- .../data/domain/ExampleMatcher.java | 78 +++++++++++++++++++ .../org/springframework/data/domain/Sort.java | 6 +- .../TypedPropertyPathUnitTests.java | 2 +- .../data/domain/ExampleMatcherUnitTests.java | 61 ++++++++++++++- .../data/domain/SortUnitTests.java | 3 +- 7 files changed, 151 insertions(+), 20 deletions(-) rename src/main/java/org/springframework/data/{mapping => core}/TypedPropertyPath.java (97%) rename src/main/java/org/springframework/data/{mapping => core}/TypedPropertyPaths.java (98%) rename src/test/java/org/springframework/data/{mapping => core}/TypedPropertyPathUnitTests.java (99%) diff --git a/src/main/java/org/springframework/data/mapping/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java similarity index 97% rename from src/main/java/org/springframework/data/mapping/TypedPropertyPath.java rename to src/main/java/org/springframework/data/core/TypedPropertyPath.java index 36df105aca..e0f5d25762 100644 --- a/src/main/java/org/springframework/data/mapping/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.mapping; +package org.springframework.data.core; import java.io.Serializable; import java.util.Collections; import java.util.Iterator; import org.jspecify.annotations.Nullable; -import org.springframework.data.util.TypeInformation; /** * Type-safe representation of a property path expressed through method references. @@ -32,13 +31,13 @@ *

* Typed property paths can be created directly they are accepted used or conveniently using the static factory method * {@link #of(TypedPropertyPath)} with method references: - * + * *

  * PropertyPath.of(Person::getName);
  * 
- * + * * Property paths can be composed to navigate nested properties using {@link #then(TypedPropertyPath)}: - * + * *
  * PropertyPath.of(Person::getAddress).then(Address::getCountry).then(Country::getName);
  * 
@@ -56,7 +55,7 @@ *

* Note that using lambda expressions requires bytecode analysis of the declaration site classes and therefore presence * of their class files. - * + * * @param the owning type of the property path segment, but typically the root type for composed property paths. * @param

the property value type at this path segment. * @author Mark Paluch diff --git a/src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java similarity index 98% rename from src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java rename to src/main/java/org/springframework/data/core/TypedPropertyPaths.java index 55d2f3f2a6..18c36769f9 100644 --- a/src/main/java/org/springframework/data/mapping/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.mapping; +package org.springframework.data.core; import java.beans.Introspector; import java.beans.PropertyDescriptor; @@ -46,8 +46,6 @@ import org.springframework.beans.BeanUtils; import org.springframework.core.ResolvableType; import org.springframework.dao.InvalidDataAccessApiUsageException; -import org.springframework.data.mapping.model.Property; -import org.springframework.data.util.TypeInformation; import org.springframework.util.ClassUtils; import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ObjectUtils; @@ -59,7 +57,7 @@ class TypedPropertyPaths { private static final Map> lambdas = new WeakHashMap<>(); - private static final Map>> resolved = new WeakHashMap<>(); + private static final Map, ResolvedTypedPropertyPath>> resolved = new WeakHashMap<>(); /** * Retrieve {@link PropertyPathInformation} for a given {@link TypedPropertyPath}. @@ -92,7 +90,7 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) return lambda; } - Map> cache; + Map, ResolvedTypedPropertyPath> cache; synchronized (resolved) { cache = resolved.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); } @@ -168,7 +166,7 @@ public static PropertyPathInformation ofFieldAccess(Class owner, String field Field field = ReflectionUtils.findField(owner, fieldName, fieldType); if (field == null) { - throw new IllegalArgumentException("Field %s.%s() not found".formatted(owner.getName(), field)); + throw new IllegalArgumentException("Field %s.%s() not found".formatted(owner.getName(), fieldName)); } return new PropertyPathInformation(TypeInformation.of(owner), diff --git a/src/main/java/org/springframework/data/domain/ExampleMatcher.java b/src/main/java/org/springframework/data/domain/ExampleMatcher.java index e59698a719..cc842b8f39 100644 --- a/src/main/java/org/springframework/data/domain/ExampleMatcher.java +++ b/src/main/java/org/springframework/data/domain/ExampleMatcher.java @@ -15,6 +15,7 @@ */ package org.springframework.data.domain; +import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashMap; import java.util.Map; @@ -24,6 +25,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.data.core.TypedPropertyPath; import org.springframework.lang.CheckReturnValue; import org.springframework.lang.Contract; import org.springframework.util.Assert; @@ -77,6 +79,21 @@ static ExampleMatcher matchingAll() { return new TypedExampleMatcher().withMode(MatchMode.ALL); } + /** + * Returns a copy of this {@link ExampleMatcher} with the specified {@code propertyPaths}. This instance is immutable + * and unaffected by this method call. + * + * @param ignoredPaths must not be {@literal null} and not empty. + * @return new instance of {@link ExampleMatcher}. + * @since 4.1 + */ + @Contract("_ -> new") + @CheckReturnValue + default ExampleMatcher withIgnorePaths(TypedPropertyPath... ignoredPaths) { + return withIgnorePaths(Arrays.stream(ignoredPaths).map(TypedPropertyPath::of).map(TypedPropertyPath::toDotPath) + .toArray(String[]::new)); + } + /** * Returns a copy of this {@link ExampleMatcher} with the specified {@code propertyPaths}. This instance is immutable * and unaffected by this method call. @@ -122,6 +139,22 @@ default ExampleMatcher withIgnoreCase() { @CheckReturnValue ExampleMatcher withIgnoreCase(boolean defaultIgnoreCase); + /** + * Returns a copy of this {@link ExampleMatcher} with the specified {@code GenericPropertyMatcher} for the + * {@code propertyPath}. This instance is immutable and unaffected by this method call. + * + * @param propertyPath must not be {@literal null}. + * @param matcherConfigurer callback to configure a {@link GenericPropertyMatcher}, must not be {@literal null}. + * @return new instance of {@link ExampleMatcher}. + * @since 4.1 + */ + @Contract("_, _ -> new") + @CheckReturnValue + default ExampleMatcher withMatcher(TypedPropertyPath propertyPath, + MatcherConfigurer matcherConfigurer) { + return withMatcher(propertyPath.toDotPath(), matcherConfigurer); + } + /** * Returns a copy of this {@link ExampleMatcher} with the specified {@code GenericPropertyMatcher} for the * {@code propertyPath}. This instance is immutable and unaffected by this method call. @@ -143,6 +176,21 @@ default ExampleMatcher withMatcher(String propertyPath, MatcherConfigurer new") + @CheckReturnValue + default ExampleMatcher withMatcher(TypedPropertyPath propertyPath, + GenericPropertyMatcher genericPropertyMatcher) { + return withMatcher(propertyPath.toDotPath(), genericPropertyMatcher); + } + /** * Returns a copy of this {@link ExampleMatcher} with the specified {@code GenericPropertyMatcher} for the * {@code propertyPath}. This instance is immutable and unaffected by this method call. @@ -155,6 +203,22 @@ default ExampleMatcher withMatcher(String propertyPath, MatcherConfigurer new") + @CheckReturnValue + default ExampleMatcher withTransformer(TypedPropertyPath propertyPath, + PropertyValueTransformer propertyValueTransformer) { + return withTransformer(propertyPath.toDotPath(), propertyValueTransformer); + } + /** * Returns a copy of this {@link ExampleMatcher} with the specified {@code PropertyValueTransformer} for the * {@code propertyPath}. @@ -167,6 +231,20 @@ default ExampleMatcher withMatcher(String propertyPath, MatcherConfigurer new") + @CheckReturnValue + default ExampleMatcher withIgnoreCase(TypedPropertyPath... propertyPaths) { + return withIgnoreCase(Arrays.stream(propertyPaths).map(TypedPropertyPath::of).map(TypedPropertyPath::toDotPath) + .toArray(String[]::new)); + } + /** * Returns a copy of this {@link ExampleMatcher} with ignore case sensitivity for the {@code propertyPaths}. This * instance is immutable and unaffected by this method call. diff --git a/src/main/java/org/springframework/data/domain/Sort.java b/src/main/java/org/springframework/data/domain/Sort.java index 4b39749334..44aad6693d 100644 --- a/src/main/java/org/springframework/data/domain/Sort.java +++ b/src/main/java/org/springframework/data/domain/Sort.java @@ -29,11 +29,11 @@ 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.PropertyPath; import org.springframework.data.util.Streamable; -import org.springframework.data.util.TypedPropertyPath; import org.springframework.lang.CheckReturnValue; import org.springframework.lang.Contract; import org.springframework.util.Assert; @@ -797,7 +797,7 @@ public String toString() { * @author Oliver Gierke * @since 2.2 * @soundtrack The Intersphere - Linger (The Grand Delusion) - * @deprecated since 4.1 in favor of {@link Sort#by(org.springframework.data.util.TypedPropertyPath...)} + * @deprecated since 4.1 in favor of {@link Sort#by(org.springframework.data.core.TypedPropertyPath...)} */ @Deprecated(since = "4.1") public static class TypedSort extends Sort { diff --git a/src/test/java/org/springframework/data/mapping/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java similarity index 99% rename from src/test/java/org/springframework/data/mapping/TypedPropertyPathUnitTests.java rename to src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index 8e9a36562d..b4dd02671e 100644 --- a/src/test/java/org/springframework/data/mapping/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.mapping; +package org.springframework.data.core; import static org.assertj.core.api.Assertions.*; diff --git a/src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java b/src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java index 20875f0b20..72209cd236 100755 --- a/src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java +++ b/src/test/java/org/springframework/data/domain/ExampleMatcherUnitTests.java @@ -121,6 +121,15 @@ void ignoredPathsShouldReturnUniqueProperties() { assertThat(matcher.getIgnoredPaths()).hasSize(2); } + @Test // + void ignoredPropertyPathsShouldReturnUniqueProperties() { + + matcher = matching().withIgnorePaths(Person::getFirstname, Person::getLastname, Person::getFirstname); + + assertThat(matcher.getIgnoredPaths()).contains("firstname", "lastname"); + assertThat(matcher.getIgnoredPaths()).hasSize(2); + } + @Test // DATACMNS-810 void withCreatesNewInstance() { @@ -160,11 +169,11 @@ void anyMatcherYieldsAnyMatching() { void shouldCompareUsingHashCodeAndEquals() { matcher = matching() // - .withIgnorePaths("foo", "bar", "baz") // + .withIgnorePaths(Random::getFoo, Random::getBar, Random::getBaz) // .withNullHandler(NullHandler.IGNORE) // .withIgnoreCase("ignored-case") // - .withMatcher("hello", GenericPropertyMatchers.contains().caseSensitive()) // - .withMatcher("world", GenericPropertyMatcher::endsWith); + .withMatcher(Random::getHello, GenericPropertyMatchers.contains().caseSensitive()) // + .withMatcher(Random::getWorld, GenericPropertyMatcher::endsWith); var sameAsMatcher = matching() // .withIgnorePaths("foo", "bar", "baz") // @@ -182,8 +191,54 @@ void shouldCompareUsingHashCodeAndEquals() { assertThat(matcher).isEqualTo(sameAsMatcher).isNotEqualTo(different); } + static class Random { + + String foo; + String bar; + String baz; + String hello; + String world; + + public String getFoo() { + return foo; + } + + public String getBar() { + return bar; + } + + public String getBaz() { + return baz; + } + + public String getHello() { + return hello; + } + + public String getWorld() { + return world; + } + } + static class Person { String firstname; + String lastname; + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } } } diff --git a/src/test/java/org/springframework/data/domain/SortUnitTests.java b/src/test/java/org/springframework/data/domain/SortUnitTests.java index 4e0b1c853a..498a6ae960 100755 --- a/src/test/java/org/springframework/data/domain/SortUnitTests.java +++ b/src/test/java/org/springframework/data/domain/SortUnitTests.java @@ -21,11 +21,12 @@ 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; -import org.springframework.data.util.TypedPropertyPath; /** * Unit test for {@link Sort}. From 2b1be80376d9c0f85747b0df1885919f724b84e6 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 30 Oct 2025 16:02:30 +0100 Subject: [PATCH 06/25] Extract lambda parsing to SamParser. --- .../springframework/data/core/SamParser.java | 549 ++++++++++++++++++ .../data/core/TypedPropertyPaths.java | 409 +------------ .../data/core/TypedPropertyPathUnitTests.java | 1 + 3 files changed, 575 insertions(+), 384 deletions(-) create mode 100644 src/main/java/org/springframework/data/core/SamParser.java diff --git a/src/main/java/org/springframework/data/core/SamParser.java b/src/main/java/org/springframework/data/core/SamParser.java new file mode 100644 index 0000000000..5ea1a7a250 --- /dev/null +++ b/src/main/java/org/springframework/data/core/SamParser.java @@ -0,0 +1,549 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandleInfo; +import java.lang.invoke.SerializedLambda; +import java.lang.reflect.Field; +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.Type; +import org.springframework.core.ResolvableType; +import org.springframework.core.SpringProperties; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * Utility to parse Single Abstract Method (SAM) method references and lambdas to extract the owning type and the member + * that is being accessed from it. + * + * @author Mark Paluch + */ +class SamParser { + + /** + * System property that instructs Spring Data to filter stack traces of exceptions thrown during SAM parsing. + */ + public static final String FILTER_STACK_TRACE = "spring.date.sam-parser.filter-stacktrace"; + + /** + * System property that instructs Spring Data to include suppressed exceptions during SAM parsing. + */ + public static final String INCLUDE_SUPPRESSED_EXCEPTIONS = "spring.date.sam-parser.include-suppressed-exceptions"; + + private static final Log LOGGER = LogFactory.getLog(SamParser.class); + + private static final boolean filterStackTrace = isEnabled(FILTER_STACK_TRACE, true); + private static final boolean includeSuppressedExceptions = isEnabled(INCLUDE_SUPPRESSED_EXCEPTIONS, false); + + private final List> entryPoints; + + private static boolean isEnabled(String property, boolean defaultValue) { + + String value = SpringProperties.getProperty(property); + if (StringUtils.hasText(value)) { + return Boolean.parseBoolean(value); + } + + return defaultValue; + } + + SamParser(Class... entryPoints) { + this.entryPoints = Arrays.asList(entryPoints); + } + + public MemberReference parse(ClassLoader classLoader, Object lambdaObject) { + + try { + // Use serialization to extract method reference info + SerializedLambda lambda = serialize(lambdaObject); + + 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; + } + + // method handle + if ((lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeInterface) + && !lambda.getImplMethodName().startsWith("lambda$")) { + return MethodInformation.ofInvokeVirtual(classLoader, lambda); + } + + if (lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeStatic + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual) { + + 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 analysis.".formatted(classFileName)); + } + + try (classFile) { + + ClassReader cr = new ClassReader(classFile); + LambdaClassVisitor classVisitor = new LambdaClassVisitor(classLoader, lambda.getImplMethodName(), owningType); + cr.accept(classVisitor, ClassReader.SKIP_FRAMES); + return classVisitor.getPropertyPathInformation(lambda); + } + } + } catch (ReflectiveOperationException | IOException e) { + throw new InvalidDataAccessApiUsageException("Cannot extract property path", e); + } + + throw new InvalidDataAccessApiUsageException("Cannot extract property path from: " + lambdaObject + + ". The given value is not a Lambda and not a Method Reference."); + } + + 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); + } + } + + class LambdaClassVisitor extends ClassVisitor { + + private final ClassLoader classLoader; + private final String implMethodName; + private final Type owningType; + private @Nullable LambdaMethodVisitor methodVisitor; + + public LambdaClassVisitor(ClassLoader classLoader, String implMethodName, Type owningType) { + super(Opcodes.ASM10_EXPERIMENTAL); + this.classLoader = classLoader; + this.implMethodName = implMethodName; + this.owningType = owningType; + } + + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { + // Capture the lambda body methods for later + if (name.equals(implMethodName)) { + + methodVisitor = new LambdaMethodVisitor(classLoader, owningType); + return methodVisitor; + } + + return null; + } + + public MemberReference getPropertyPathInformation(SerializedLambda lambda) { + return methodVisitor.resolve(lambda); + } + } + + class LambdaMethodVisitor extends MethodVisitor { + + private static final Pattern HEX_PATTERN = Pattern.compile("[0-9a-f]+"); + + private static final Set BOXING_TYPES = Set.of(Type.getInternalName(Integer.class), + Type.getInternalName(Long.class), Type.getInternalName(Short.class), Type.getInternalName(Byte.class), + Type.getInternalName(Float.class), Type.getInternalName(Double.class), Type.getInternalName(Character.class), + Type.getInternalName(Boolean.class)); + + private static final String BOXING_METHOD = "valueOf"; + + private final ClassLoader classLoader; + private final Type owningType; + private int line; + List memberReferences = new ArrayList<>(); + Set errors = new LinkedHashSet<>(); + + public LambdaMethodVisitor(ClassLoader classLoader, Type owningType) { + super(Opcodes.ASM10_EXPERIMENTAL); + 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; + } + + if (opcode >= Opcodes.DUP && opcode <= Opcodes.DUP2_X2) { + return; + } + + visitLdcInsn(""); + } + + @Override + public void visitLdcInsn(Object value) { + errors.add(new ParseError(line, + "Lambda expressions may only contain method calls to getters, record components, or field access", null)); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String name, String descriptor) { + + if (opcode == Opcodes.PUTSTATIC || opcode == Opcodes.PUTFIELD) { + errors.add(new ParseError(line, "Setting a field not allowed", null)); + return; + } + + Type fieldType = Type.getType(descriptor); + + try { + this.memberReferences.add(FieldInformation.create(classLoader, owningType, name, fieldType)); + } catch (ReflectiveOperationException e) { + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Failed to resolve field '%s.%s'".formatted(owner, name), e); + } + errors.add(new ParseError(line, e.getMessage())); + } + } + + @Override + public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { + + if (opcode == Opcodes.INVOKESPECIAL && name.equals("")) { + errors.add(new ParseError(line, "Lambda must not invoke constructors", null)); + return; + } + + int count = Type.getArgumentCount(descriptor); + + if (count != 0) { + + if (BOXING_TYPES.contains(owner) && name.equals(BOXING_METHOD)) { + return; + } + + errors.add(new ParseError(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 ParseError(line, + "Cannot derive a method reference from method invocation '%s(%s)' on a different type than the owning one.%nExpected owning type: '%s', but was: '%s'" + .formatted(name, signature, this.owningType.getClassName(), ownerType.getClassName()))); + return; + } + + try { + this.memberReferences.add(MethodInformation.ofInvokeVirtual(classLoader, owningType, name)); + } catch (ReflectiveOperationException e) { + + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Failed to resolve method '%s.%s'".formatted(owner, name), e); + } + errors.add(new ParseError(line, e.getMessage())); + } + } + + public MemberReference resolve(SerializedLambda lambda) { + + // TODO composite path information + if (errors.isEmpty()) { + + if (memberReferences.isEmpty()) { + throw new InvalidDataAccessApiUsageException("There is no method or field access"); + } + + return memberReferences.get(memberReferences.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(ParseError::message).map(args -> formatMessage(args)).collect(Collectors.joining())); + + if (includeSuppressedExceptions) { + for (ParseError error : errors) { + if (error.e != null) { + e.addSuppressed(error.e); + } + } + } + + if (filterStackTrace) { + e.setStackTrace( + filterStackTrace(e.getStackTrace(), userCode -> createSynthetic(lambda, methodName, userCode))); + } + + throw e; + } + + throw new InvalidDataAccessApiUsageException("Error resolving " + errors); + } + + private static String formatMessage(String args) { + + String[] split = args.split("%n".formatted()); + StringBuilder builder = new StringBuilder(); + + for (int i = 0; i < split.length; i++) { + + if (i == 0) { + builder.append("\t* %s%n".formatted(split[i])); + } else { + builder.append("\t %s%n".formatted(split[i])); + } + } + + return builder.toString(); + } + + private static String getDeclaringMethodName(SerializedLambda lambda) { + + String methodName = lambda.getImplMethodName(); + if (methodName.startsWith("lambda$")) { + methodName = methodName.substring("lambda$".length()); + + if (methodName.contains("$")) { + methodName = methodName.substring(0, methodName.lastIndexOf('$')); + } + + if (methodName.contains("$")) { + String probe = methodName.substring(methodName.lastIndexOf('$') + 1); + if (HEX_PATTERN.matcher(probe).matches()) { + methodName = methodName.substring(0, methodName.lastIndexOf('$')); + } + } + } + return methodName; + } + + private StackTraceElement createSynthetic(SerializedLambda lambda, String methodName, StackTraceElement userCode) { + + Type type = Type.getObjectType(lambda.getCapturingClass()); + + return new StackTraceElement(null, userCode.getModuleName(), userCode.getModuleVersion(), type.getClassName(), + methodName, ClassUtils.getShortName(type.getClassName()) + ".java", errors.iterator().next().line); + } + } + + private StackTraceElement[] filterStackTrace(StackTraceElement[] stackTrace, + @Nullable Function syntheticSupplier) { + + int filterIndex = findEntryPoint(stackTrace); + + if (filterIndex != -1) { + + int offset = syntheticSupplier == null ? 0 : 1; + + StackTraceElement[] copy = new StackTraceElement[(stackTrace.length - filterIndex) + offset]; + System.arraycopy(stackTrace, filterIndex, copy, offset, stackTrace.length - filterIndex); + + if (syntheticSupplier != null) { + StackTraceElement userCode = copy[1]; + StackTraceElement synthetic = syntheticSupplier.apply(userCode); + copy[0] = synthetic; + } + return copy; + } + + return stackTrace; + } + + private int findEntryPoint(StackTraceElement[] stackTrace) { + + int filterIndex = -1; + + for (int i = 0; i < stackTrace.length; i++) { + + if (matchesEntrypoint(stackTrace[i].getClassName())) { + filterIndex = i; + } + } + + return filterIndex; + } + + private boolean matchesEntrypoint(String className) { + + if (className.equals(getClass().getName())) { + return true; + } + + for (Class entryPoint : entryPoints) { + if (className.equals(entryPoint.getName())) { + return true; + } + } + + return false; + } + + record ParseError(int line, String message, @Nullable Exception e) { + + ParseError(int line, String message) { + this(line, message, null); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ParseError 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); + } + } + + sealed interface MemberReference permits FieldInformation, MethodInformation { + + Class getOwner(); + + Member getMember(); + + ResolvableType getType(); + } + + /** + * Value object holding information about a property path segment. + * + * @param owner + */ + record FieldInformation(Class owner, Field field) implements MemberReference { + + public static FieldInformation create(ClassLoader classLoader, Type ownerType, String name, Type fieldType) + throws ClassNotFoundException { + + Class owner = ClassUtils.forName(ownerType.getClassName(), classLoader); + Class type = ClassUtils.forName(fieldType.getClassName(), classLoader); + + return create(owner, name, type); + } + + private static FieldInformation 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 FieldInformation(owner, field); + } + + @Override + public Class getOwner() { + return owner(); + } + + @Override + public Field getMember() { + return field(); + } + + @Override + public ResolvableType getType() { + return ResolvableType.forField(field(), owner()); + } + } + + /** + * Value object holding information about a method invocation. + */ + record MethodInformation(Class owner, Method method) implements MemberReference { + + public static MethodInformation ofInvokeVirtual(ClassLoader classLoader, SerializedLambda lambda) + throws ClassNotFoundException { + return ofInvokeVirtual(classLoader, Type.getObjectType(lambda.getImplClass()), lambda.getImplMethodName()); + } + + public static MethodInformation ofInvokeVirtual(ClassLoader classLoader, Type ownerType, String name) + throws ClassNotFoundException { + Class owner = ClassUtils.forName(ownerType.getClassName(), classLoader); + return ofInvokeVirtual(owner, name); + } + + public static MethodInformation ofInvokeVirtual(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 MethodInformation(owner, method); + } + + @Override + public Class getOwner() { + return owner(); + } + + @Override + public Method getMember() { + return method(); + } + + @Override + public ResolvableType getType() { + return ResolvableType.forMethodReturnType(method(), owner()); + } + } +} diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index 18c36769f9..dd6250a15f 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -17,39 +17,17 @@ import java.beans.Introspector; import java.beans.PropertyDescriptor; -import java.io.IOException; -import java.io.InputStream; -import java.lang.invoke.MethodHandleInfo; -import java.lang.invoke.SerializedLambda; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.ArrayList; import java.util.Iterator; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.WeakHashMap; -import java.util.regex.Pattern; -import java.util.stream.Collectors; import java.util.stream.Stream; 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.Type; import org.springframework.beans.BeanUtils; -import org.springframework.core.ResolvableType; -import org.springframework.dao.InvalidDataAccessApiUsageException; -import org.springframework.util.ClassUtils; import org.springframework.util.ConcurrentReferenceHashMap; -import org.springframework.util.ObjectUtils; -import org.springframework.util.ReflectionUtils; /** * Utility class to parse and resolve {@link TypedPropertyPath} instances. @@ -59,6 +37,9 @@ class TypedPropertyPaths { private static final Map> lambdas = new WeakHashMap<>(); private static final Map, ResolvedTypedPropertyPath>> resolved = new WeakHashMap<>(); + private static final SamParser samParser = new SamParser(PropertyPath.class, TypedPropertyPath.class, + TypedPropertyPaths.class); + /** * Retrieve {@link PropertyPathInformation} for a given {@link TypedPropertyPath}. */ @@ -73,6 +54,17 @@ public static PropertyPathInformation getPropertyPathInformation(TypedPropertyPa return lambdaMap.computeIfAbsent(lambda, o -> extractPath(lambda.getClass().getClassLoader(), lambda)); } + private static PropertyPathInformation extractPath(ClassLoader classLoader, TypedPropertyPath lambda) { + + SamParser.MemberReference reference = samParser.parse(classLoader, lambda); + + if (reference instanceof SamParser.MethodInformation method) { + return PropertyPathInformation.ofMethod(method); + } + + return PropertyPathInformation.ofField((SamParser.FieldInformation) reference); + } + /** * Compose a {@link TypedPropertyPath} by appending {@code next}. */ @@ -108,24 +100,10 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) */ record PropertyPathInformation(TypeInformation owner, TypeInformation propertyType, String property) { - public static PropertyPathInformation ofInvokeVirtual(ClassLoader classLoader, SerializedLambda lambda) - throws ClassNotFoundException { - return ofInvokeVirtual(classLoader, Type.getObjectType(lambda.getImplClass()), lambda.getImplMethodName()); - } - - public static PropertyPathInformation ofInvokeVirtual(ClassLoader classLoader, Type ownerType, String name) - throws ClassNotFoundException { - Class owner = ClassUtils.forName(ownerType.getClassName(), classLoader); - return ofInvokeVirtual(owner, name); - } + public static PropertyPathInformation ofMethod(SamParser.MethodInformation method) { - public static PropertyPathInformation ofInvokeVirtual(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)); - } - PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method); + PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method.method()); + String methodName = method.getMember().getName(); if (descriptor == null) { String propertyName; @@ -138,132 +116,27 @@ public static PropertyPathInformation ofInvokeVirtual(Class owner, String met propertyName = methodName; } - TypeInformation fallback = TypeInformation.of(owner).getProperty(propertyName); + TypeInformation owner = TypeInformation.of(method.owner()); + TypeInformation fallback = owner.getProperty(propertyName); if (fallback != null) { - return new PropertyPathInformation(TypeInformation.of(owner), fallback, + return new PropertyPathInformation(owner, fallback, propertyName); } throw new IllegalArgumentException( - "Cannot find PropertyDescriptor from method %s.%s".formatted(owner.getName(), methodName)); + "Cannot find PropertyDescriptor from method %s.%s".formatted(method.owner().getName(), methodName)); } - return new PropertyPathInformation(TypeInformation.of(owner), - TypeInformation.of(ResolvableType.forMethodReturnType(method, owner)), + return new PropertyPathInformation(TypeInformation.of(method.getOwner()), TypeInformation.of(method.getType()), descriptor.getName()); } - public static PropertyPathInformation ofFieldAccess(ClassLoader classLoader, Type ownerType, String name, - Type fieldType) throws ClassNotFoundException { - - Class owner = ClassUtils.forName(ownerType.getClassName(), classLoader); - Class type = ClassUtils.forName(fieldType.getClassName(), classLoader); - - return ofFieldAccess(owner, name, type); - } - - public static PropertyPathInformation ofFieldAccess(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 PropertyPathInformation(TypeInformation.of(owner), - TypeInformation.of(ResolvableType.forField(field, owner)), field.getName()); - } - } - - public static PropertyPathInformation extractPath(ClassLoader classLoader, TypedPropertyPath path) { - - try { - // Use serialization to extract method reference info - SerializedLambda lambda = getSerializedLambda(path); - - if (lambda.getImplMethodKind() == MethodHandleInfo.REF_newInvokeSpecial - || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeSpecial) { - InvalidDataAccessApiUsageException e = new InvalidDataAccessApiUsageException( - "Method reference must not be a constructor call"); - e.setStackTrace(filterStackTrace(e.getStackTrace())); - throw e; - } - - // method handle - if ((lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual - || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeInterface) - && !lambda.getImplMethodName().startsWith("lambda$")) { - return PropertyPathInformation.ofInvokeVirtual(classLoader, lambda); - } - - if (lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeStatic - || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual) { - - 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 analysis.".formatted(classFileName)); - } - - try (classFile) { - - ClassReader cr = new ClassReader(classFile); - LambdaClassVisitor classVisitor = new LambdaClassVisitor(classLoader, lambda.getImplMethodName(), owningType); - cr.accept(classVisitor, ClassReader.SKIP_FRAMES); - return classVisitor.getPropertyPathInformation(lambda); - } - } - } catch (ReflectiveOperationException | IOException e) { - throw new RuntimeException("Cannot extract property path", e); - } - - throw new IllegalArgumentException( - "Cannot extract property path from: " + path + ". The given value is not a Lambda and not a Method Reference."); - } - - private static SerializedLambda getSerializedLambda(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); + public static PropertyPathInformation ofField(SamParser.FieldInformation field) { + return new PropertyPathInformation(TypeInformation.of(field.owner()), TypeInformation.of(field.getType()), + field.getMember().getName()); } } - static class LambdaClassVisitor extends ClassVisitor { - - private final ClassLoader classLoader; - private final String implMethodName; - private final Type owningType; - private @Nullable LambdaMethodVisitor methodVisitor; - - public LambdaClassVisitor(ClassLoader classLoader, String implMethodName, Type owningType) { - super(Opcodes.ASM10_EXPERIMENTAL); - this.classLoader = classLoader; - this.implMethodName = implMethodName; - this.owningType = owningType; - } - - @Override - public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { - // Capture the lambda body methods for later - if (name.equals(implMethodName)) { - - methodVisitor = new LambdaMethodVisitor(classLoader, owningType); - return methodVisitor; - } - - return null; - } - - public PropertyPathInformation getPropertyPathInformation(SerializedLambda lambda) { - return methodVisitor.getPropertyPathInformation(lambda); - } - } static class ResolvedTypedPropertyPath implements TypedPropertyPath { @@ -333,239 +206,7 @@ public String toString() { } } - static class LambdaMethodVisitor extends MethodVisitor { - - private final ClassLoader classLoader; - private final Type owningType; - private int line; - - private static final Set BOXING_TYPES = Set.of(Type.getInternalName(Integer.class), - Type.getInternalName(Long.class), Type.getInternalName(Short.class), Type.getInternalName(Byte.class), - Type.getInternalName(Float.class), Type.getInternalName(Double.class), Type.getInternalName(Character.class), - Type.getInternalName(Boolean.class)); - - private static final String BOXING_METHOD = "valueOf"; - - List propertyPathInformations = new ArrayList<>(); - Set errors = new LinkedHashSet<>(); - - public LambdaMethodVisitor(ClassLoader classLoader, Type owningType) { - super(Opcodes.ASM10_EXPERIMENTAL); - 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; - } - - if (opcode >= Opcodes.DUP && opcode <= Opcodes.DUP2_X2) { - return; - } - - visitLdcInsn(""); - } - - @Override - public void visitLdcInsn(Object value) { - errors.add(new ParseError(line, - "Lambda expressions for Typed property path declaration may only contain method calls to getters, record components, and field access", - null)); - } - - @Override - public void visitFieldInsn(int opcode, String owner, String name, String descriptor) { - - if (opcode == Opcodes.PUTSTATIC || opcode == Opcodes.PUTFIELD) { - errors.add(new ParseError(line, "Put field not allowed in property path lambda", null)); - return; - } - - Type fieldType = Type.getType(descriptor); - try { - this.propertyPathInformations - .add(PropertyPathInformation.ofFieldAccess(classLoader, owningType, name, fieldType)); - } catch (ClassNotFoundException e) { - errors.add(new ParseError(line, e.getMessage())); - } - } - - @Override - public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { - - if (opcode == Opcodes.INVOKESPECIAL && name.equals("")) { - errors.add(new ParseError(line, "Lambda must not call constructor", null)); - return; - } - - int count = Type.getArgumentCount(descriptor); - - if (count != 0) { - - if (BOXING_TYPES.contains(owner) && name.equals(BOXING_METHOD)) { - return; - } - - errors.add(new ParseError(line, "Property path extraction requires calls to no-arg getters")); - return; - } - - Type ownerType = Type.getObjectType(owner); - if (!ownerType.equals(this.owningType)) { - errors.add(new ParseError(line, - "Cannot derive a property path from method call '%s' on a different owning type. Expected owning type: %s, but was: %s" - .formatted(name, this.owningType.getClassName(), ownerType.getClassName()))); - return; - } - - try { - this.propertyPathInformations.add(PropertyPathInformation.ofInvokeVirtual(classLoader, owningType, name)); - } catch (Exception e) { - errors.add(new ParseError(line, e.getMessage(), e)); - } - } - - public PropertyPathInformation getPropertyPathInformation(SerializedLambda lambda) { - - if (!errors.isEmpty()) { - - if (lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeStatic - || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual) { - - Pattern hex = Pattern.compile("[0-9a-f]+"); - 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.matcher(probe).matches()) { - methodName = methodName.substring(0, methodName.lastIndexOf('$')); - } - } - } - - InvalidDataAccessApiUsageException e = new InvalidDataAccessApiUsageException("Cannot resolve property path: " - + errors.stream().map(ParseError::message).collect(Collectors.joining("; "))); - - for (ParseError error : errors) { - if (error.e != null) { - e.addSuppressed(error.e); - } - } - - e.setStackTrace(filterStackTrace(lambda, e.getStackTrace(), methodName)); - - throw e; - } - // lambda$resolvesComposedLambdaFieldAccess$d3dc5794$1 - - throw new IllegalStateException("There are errors in property path lambda " + errors); - } - - if (propertyPathInformations.isEmpty()) { - throw new IllegalStateException("There are no property path information available"); - } - - // TODO composite path information - return propertyPathInformations.get(propertyPathInformations.size() - 1); - } - - private StackTraceElement[] filterStackTrace(SerializedLambda lambda, StackTraceElement[] stackTrace, - String methodName) { - - int filterIndex = findEntryPoint(stackTrace); - - if (filterIndex != -1) { - - StackTraceElement[] copy = new StackTraceElement[(stackTrace.length - filterIndex) + 1]; - System.arraycopy(stackTrace, filterIndex, copy, 1, stackTrace.length - filterIndex); - - StackTraceElement userCode = copy[1]; - StackTraceElement synthetic = createSynthetic(lambda, methodName, userCode); - copy[0] = synthetic; - return copy; - } - - return stackTrace; - } - - private StackTraceElement createSynthetic(SerializedLambda lambda, String methodName, StackTraceElement userCode) { - Type type = Type.getObjectType(lambda.getCapturingClass()); - StackTraceElement synthetic = new StackTraceElement(null, userCode.getModuleName(), userCode.getModuleVersion(), - type.getClassName(), methodName, ClassUtils.getShortName(type.getClassName()) + ".java", - errors.iterator().next().line); - return synthetic; - } - } - - private static StackTraceElement[] filterStackTrace(StackTraceElement[] stackTrace) { - - int filterIndex = findEntryPoint(stackTrace); - - if (filterIndex != -1) { - - StackTraceElement[] copy = new StackTraceElement[(stackTrace.length - filterIndex)]; - System.arraycopy(stackTrace, filterIndex, copy, 0, stackTrace.length - filterIndex); - return copy; - } - - return stackTrace; - } - - private static int findEntryPoint(StackTraceElement[] stackTrace) { - - int filterIndex = -1; - - for (int i = 0; i < stackTrace.length; i++) { - - if (stackTrace[i].getClassName().equals(TypedPropertyPaths.class.getName()) - || stackTrace[i].getClassName().equals(TypedPropertyPath.class.getName()) - || stackTrace[i].getClassName().equals(ComposedPropertyPath.class.getName()) - || stackTrace[i].getClassName().equals(PropertyPath.class.getName())) { - filterIndex = i; - } - } - - return filterIndex; - } - - record ParseError(int line, String message, @Nullable Exception e) { - - ParseError(int line, String message) { - this(line, message, null); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof ParseError 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); - } - } record ComposedPropertyPath(TypedPropertyPath first, TypedPropertyPath second, String dotPath) implements TypedPropertyPath { diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index b4dd02671e..b45d179524 100644 --- a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -171,6 +171,7 @@ void resolvesMRRecordPath() { @Test void failsResolutionWith$StrangeStuff() { + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> { int a = 1 + 2; From 769cb75a3126ed73d1549b80d304930bd6f2636b Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 30 Oct 2025 16:22:30 +0100 Subject: [PATCH 07/25] Add poc for EntityLookupConfiguration replacement. --- .../data/core/MemberReference.java | 43 +++++++++++++++ .../data/core/MemberReferenceTests.java | 54 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/main/java/org/springframework/data/core/MemberReference.java create mode 100644 src/test/java/org/springframework/data/core/MemberReferenceTests.java diff --git a/src/main/java/org/springframework/data/core/MemberReference.java b/src/main/java/org/springframework/data/core/MemberReference.java new file mode 100644 index 0000000000..2317402742 --- /dev/null +++ b/src/main/java/org/springframework/data/core/MemberReference.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import java.io.Serializable; +import java.lang.reflect.Member; +import java.util.function.Function; + +import org.springframework.util.Assert; + +/** + * PoC for REST EntityLookupConfiguration + * + * @author Mark Paluch + */ +public interface MemberReference extends Function, Serializable { + + default Member getMember() { + return resolve(this); + } + + public static Member resolve(Object object) { + + Assert.notNull(object, "Object must not be null"); + Assert.isInstanceOf(Serializable.class, object, "Object must be Serializable"); + + return new SamParser(MemberReference.class).parse(object.getClass().getClassLoader(), object).getMember(); + } + +} diff --git a/src/test/java/org/springframework/data/core/MemberReferenceTests.java b/src/test/java/org/springframework/data/core/MemberReferenceTests.java new file mode 100644 index 0000000000..d4aa7e3258 --- /dev/null +++ b/src/test/java/org/springframework/data/core/MemberReferenceTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import static org.assertj.core.api.Assertions.*; + +import java.io.Serializable; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.convert.converter.Converter; + +/** + * @author Mark Paluch + */ +class MemberReferenceTests { + + @Test + void shouldResolveMember() throws NoSuchMethodException { + + MemberReference reference = Person::name; + + assertThat(reference.getMember()).isEqualTo(Person.class.getMethod("name")); + } + + @Test + void retrofitConverter() throws NoSuchMethodException { + + Converter reference = convert(Person::name); + + assertThat(MemberReference.resolve(reference)).isEqualTo(Person.class.getMethod("name")); + } + + static & Serializable> T convert(T converter) { + return converter; + } + + record Person(String name) { + + } +} From e926f72521a973e9e401a13798837799f81023f1 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Fri, 31 Oct 2025 10:17:12 +0100 Subject: [PATCH 08/25] Add benchmarks. --- pom.xml | 22 ++++++ .../data/BenchmarkSettings.java | 39 +++++++++++ .../SerializableLambdaReaderBenchmarks.java | 51 ++++++++++++++ .../core/TypedPropertyPathBenchmarks.java | 69 +++++++++++++++++++ .../data/core/TypedPropertyPathUnitTests.java | 16 +++++ 5 files changed, 197 insertions(+) create mode 100644 src/jmh/java/org/springframework/data/BenchmarkSettings.java create mode 100644 src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java create mode 100644 src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java diff --git a/pom.xml b/pom.xml index 072feffac7..3ceeab1532 100644 --- a/pom.xml +++ b/pom.xml @@ -388,6 +388,28 @@ + + jmh + + + org.openjdk.jmh + jmh-core + test + ${jmh} + + + org.openjdk.jmh + jmh-generator-annprocess + test + + + + + jitpack.io + https://jitpack.io + + + diff --git a/src/jmh/java/org/springframework/data/BenchmarkSettings.java b/src/jmh/java/org/springframework/data/BenchmarkSettings.java new file mode 100644 index 0000000000..5b7ae8752f --- /dev/null +++ b/src/jmh/java/org/springframework/data/BenchmarkSettings.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data; + +import java.util.concurrent.TimeUnit; + +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Global benchmark settings. + * + * @author Mark Paluch + */ +@Warmup(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS) +@Fork(value = 1, warmups = 0) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +public abstract class BenchmarkSettings { + +} diff --git a/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java b/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java new file mode 100644 index 0000000000..6ef8254dca --- /dev/null +++ b/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import org.junit.platform.commons.annotation.Testable; +import org.openjdk.jmh.annotations.Benchmark; + +import org.springframework.data.BenchmarkSettings; + +/** + * Benchmarks for {@link SerializableLambdaReader}. + * + * @author Mark Paluch + */ +@Testable +public class SerializableLambdaReaderBenchmarks extends BenchmarkSettings { + + private static final SerializableLambdaReader reader = new SerializableLambdaReader(MemberReference.class); + + @Benchmark + public Object benchmarkMethodReference() { + + MemberReference methodReference = Person::firstName; + return reader.read(methodReference); + } + + @Benchmark + public Object benchmarkLambda() { + + MemberReference methodReference = person -> person.firstName(); + return reader.read(methodReference); + } + + record Person(String firstName, String lastName) { + + } + +} diff --git a/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java new file mode 100644 index 0000000000..a875b2a613 --- /dev/null +++ b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import org.junit.platform.commons.annotation.Testable; +import org.openjdk.jmh.annotations.Benchmark; + +import org.springframework.data.BenchmarkSettings; + +/** + * Benchmarks for {@link TypedPropertyPath}. + * + * @author Mark Paluch + */ +@Testable +public class TypedPropertyPathBenchmarks extends BenchmarkSettings { + + @Benchmark + public Object benchmarkMethodReference() { + return TypedPropertyPath.of(Person::firstName); + } + + @Benchmark + public Object benchmarkComposedMethodReference() { + return TypedPropertyPath.of(Person::address).then(Address::city); + } + + @Benchmark + public TypedPropertyPath benchmarkLambda() { + return TypedPropertyPath.of(person -> person.firstName()); + } + + @Benchmark + public TypedPropertyPath benchmarkComposedLambda() { + return TypedPropertyPath.of((Person person) -> person.address()).then(address -> address.city()); + } + + @Benchmark + public Object dotPath() { + return TypedPropertyPath.of(Person::firstName).toDotPath(); + } + + @Benchmark + public Object composedDotPath() { + return TypedPropertyPath.of(Person::address).then(Address::city).toDotPath(); + } + + record Person(String firstName, String lastName, Address address) { + + } + + record Address(String city) { + + } + +} diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index b45d179524..e241e3f3ec 100644 --- a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.*; import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.dao.InvalidDataAccessApiUsageException; @@ -200,6 +201,21 @@ void failsResolvingCallingLocalMethod() { })); } + @Nested + class NestedTestClass { + + @Test + void resolvesInterfaceLambdaGetter() { + assertThat(PropertyPath.of((PersonProjection person) -> person.getName()).toDotPath()).isEqualTo("name"); + } + + @Test + void resolvesSuperclassMethodReferenceGetter() { + assertThat(PropertyPath.of(PersonQuery::getTenant).toDotPath()).isEqualTo("tenant"); + } + + } + // Domain entities static public class SuperClass { From 613952e065866e4cabc3f53d84227fcb294c4767 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 30 Oct 2025 17:23:40 +0100 Subject: [PATCH 09/25] Documentation. --- .../modules/ROOT/pages/property-paths.adoc | 51 +++ .../data/core/MemberDescriptor.java | 147 ++++++++ .../data/core/MemberReference.java | 3 +- .../data/core/PropertyPath.java | 15 +- ...ser.java => SerializableLambdaReader.java} | 338 ++++++++---------- .../data/core/TypedPropertyPath.java | 54 ++- .../data/core/TypedPropertyPaths.java | 183 ++++++---- .../data/domain/SortUnitTests.java | 7 + 8 files changed, 503 insertions(+), 295 deletions(-) create mode 100644 src/main/antora/modules/ROOT/pages/property-paths.adoc create mode 100644 src/main/java/org/springframework/data/core/MemberDescriptor.java rename src/main/java/org/springframework/data/core/{SamParser.java => SerializableLambdaReader.java} (59%) diff --git a/src/main/antora/modules/ROOT/pages/property-paths.adoc b/src/main/antora/modules/ROOT/pages/property-paths.adoc new file mode 100644 index 0000000000..c0cdc552e3 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/property-paths.adoc @@ -0,0 +1,51 @@ +[[type-safe-property-references]] += Type-safe Property References + +Type-safe property references address a common source of friction in data access code: The reliance on literal, dot-separated property path strings. +Such stringly-typed references are fragile during refactoring difficult to identify as they often lack context. +Type-safe property paths favor explicitness and compiler validation by deriving property paths from Java method references. + +A property path is a simple, transportable representation of object navigation. +When expressed as a method-reference the compiler participates in validation and IDEs provide meaningful refactoring support. +The result is code that reads naturally, fails fast on renames, and integrates cleanly with existing query and sorting abstractions, for example: + +[source,java] +---- +TypedPropertyPath.of(Person::getAddress) + .then(Address::getCity); +---- + +The expression above constructs a path equivalent to `address.city` while remaining resilient to refactoring. +Property resolution is performed by inspecting the supplied method references; any mismatch becomes visible at compile time. + +By comparing a literal-based approach as the following example you can immediately spot the same intent while the mechanism of using strings removes any type context: + +.Stringly-typed programming +[source,java] +---- +Sort.by("address.city", "address.street") +---- + +You can also use it inline for operations like sorting: + +.Type-safe Property Path +[source,java] +---- +Sort.by(Person::getFirstName, Person::getLastName); +---- + +`TypedPropertyPath` can integrate seamlessly with query abstractions or criteria builders: + +.Type-safe Property Path +[source,java] +---- +Criteria.where(Person::getAddress) + .then(Address::getCity) + .is("New York"); +---- + +Adopting type-safe property references aligns with modern Spring development principles. +Providing declarative, type-safe, and fluent APIs leads to simpler reasoning about data access eliminating an entire category of potential bugs through IDE refactoring support and early feedback on invalid properties by the compiler. + +Lambda introspection is cached for efficiency enabling repeatable use. +The JVM reuses static lambda instances contributing to minimal overhead of one-time parsing. diff --git a/src/main/java/org/springframework/data/core/MemberDescriptor.java b/src/main/java/org/springframework/data/core/MemberDescriptor.java new file mode 100644 index 0000000000..2d99aa104e --- /dev/null +++ b/src/main/java/org/springframework/data/core/MemberDescriptor.java @@ -0,0 +1,147 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import java.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 + */ +sealed interface MemberDescriptor + permits MemberDescriptor.MethodDescriptor.FieldDescriptor, MemberDescriptor.MethodDescriptor { + + /** + * @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. + */ + public 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. + * + * @param owner + * @param method + */ + 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. + * + * @param owner + * @param field + */ + 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()); + } + + } +} diff --git a/src/main/java/org/springframework/data/core/MemberReference.java b/src/main/java/org/springframework/data/core/MemberReference.java index 2317402742..faa0827adb 100644 --- a/src/main/java/org/springframework/data/core/MemberReference.java +++ b/src/main/java/org/springframework/data/core/MemberReference.java @@ -37,7 +37,8 @@ public static Member resolve(Object object) { Assert.notNull(object, "Object must not be null"); Assert.isInstanceOf(Serializable.class, object, "Object must be Serializable"); - return new SamParser(MemberReference.class).parse(object.getClass().getClassLoader(), object).getMember(); + return new SerializableLambdaReader(MemberReference.class).read(object) + .getMember(); } } diff --git a/src/main/java/org/springframework/data/core/PropertyPath.java b/src/main/java/org/springframework/data/core/PropertyPath.java index be13f23afd..b6a5cd2053 100644 --- a/src/main/java/org/springframework/data/core/PropertyPath.java +++ b/src/main/java/org/springframework/data/core/PropertyPath.java @@ -35,16 +35,17 @@ public interface PropertyPath extends Streamable { /** - * Syntax sugar to create a {@link TypedPropertyPath} from an existing one, ideal for method handles. + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference or lambda. * - * @param propertyPath - * @return + * @param propertyPath the method reference or lambda. * @param owning type. - * @param property type. - * @since xxx + * @param

property type. + * @return the typed property path. */ - public static TypedPropertyPath of(TypedPropertyPath propertyPath) { - return TypedPropertyPath.of(propertyPath); + static TypedPropertyPath of(TypedPropertyPath propertyPath) { + return TypedPropertyPaths.of(propertyPath); } /** diff --git a/src/main/java/org/springframework/data/core/SamParser.java b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java similarity index 59% rename from src/main/java/org/springframework/data/core/SamParser.java rename to src/main/java/org/springframework/data/core/SerializableLambdaReader.java index 5ea1a7a250..42c15bc8f8 100644 --- a/src/main/java/org/springframework/data/core/SamParser.java +++ b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java @@ -19,7 +19,6 @@ import java.io.InputStream; import java.lang.invoke.MethodHandleInfo; import java.lang.invoke.SerializedLambda; -import java.lang.reflect.Field; import java.lang.reflect.Member; import java.lang.reflect.Method; import java.util.ArrayList; @@ -40,104 +39,157 @@ 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.ResolvableType; import org.springframework.core.SpringProperties; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; -import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; /** - * Utility to parse Single Abstract Method (SAM) method references and lambdas to extract the owning type and the member - * that is being accessed from it. + * Reader to extract references to fields and methods from serializable lambda expressions and method references using + * lambda serialization and bytecode analysis. Allows introspection of property access patterns expressed through + * functional interfaces without executing the lambda's behavior. Their declarative nature makes method references in + * general and a constrained subset of lambda expressions suitable to declare property references in the sense of Java + * Beans properties. Although lambdas and method references are primarily used in a declarative functional programming + * models to express behavior, lambda serialization allows for further introspection such as parsing the lambda method + * bytecode for property or member access information and not taking the functional behavior into account. + *

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

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

+ * Supported patterns + *

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

+ * Ideally used with an interface resembling {@link java.util.function.Function}. + * + * @param lambdaObject the actual lambda object, must be {@link java.io.Serializable}. + * @return the member reference. + * @throws InvalidDataAccessApiUsageException if the lambda object does not contain a valid property reference or hits + * any of the mentioned limitations. + */ + public MemberDescriptor read(Object lambdaObject) { - public MemberReference parse(ClassLoader classLoader, Object lambdaObject) { + SerializedLambda lambda = serialize(lambdaObject); + assertNotConstructor(lambda); try { - // Use serialization to extract method reference info - SerializedLambda lambda = serialize(lambdaObject); - 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; - } - - // method handle + // method reference if ((lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeVirtual || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeInterface) && !lambda.getImplMethodName().startsWith("lambda$")) { - return MethodInformation.ofInvokeVirtual(classLoader, 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); + } - String implClass = Type.getObjectType(lambda.getImplClass()).getClassName(); + throw new InvalidDataAccessApiUsageException("Cannot extract method or field from: " + lambdaObject + + ". The given value is not a lambda or method reference."); + } - 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 analysis.".formatted(classFileName)); - } + private void assertNotConstructor(SerializedLambda lambda) { - try (classFile) { + if (lambda.getImplMethodKind() == MethodHandleInfo.REF_newInvokeSpecial + || lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeSpecial) { - ClassReader cr = new ClassReader(classFile); - LambdaClassVisitor classVisitor = new LambdaClassVisitor(classLoader, lambda.getImplMethodName(), owningType); - cr.accept(classVisitor, ClassReader.SKIP_FRAMES); - return classVisitor.getPropertyPathInformation(lambda); - } + InvalidDataAccessApiUsageException e = new InvalidDataAccessApiUsageException( + "Method reference must not be a constructor call"); + + if (filterStackTrace) { + e.setStackTrace(filterStackTrace(e.getStackTrace(), null)); } - } catch (ReflectiveOperationException | IOException e) { - throw new InvalidDataAccessApiUsageException("Cannot extract property path", e); + 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)); } - throw new InvalidDataAccessApiUsageException("Cannot extract property path from: " + lambdaObject - + ". The given value is not a Lambda and not a Method Reference."); + 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) { @@ -152,35 +204,27 @@ private static SerializedLambda serialize(Object lambda) { } } - class LambdaClassVisitor extends ClassVisitor { + class LambdaReadingVisitor extends ClassVisitor { - private final ClassLoader classLoader; private final String implMethodName; - private final Type owningType; - private @Nullable LambdaMethodVisitor methodVisitor; + private final LambdaMethodVisitor methodVisitor; - public LambdaClassVisitor(ClassLoader classLoader, String implMethodName, Type owningType) { - super(Opcodes.ASM10_EXPERIMENTAL); - this.classLoader = classLoader; + public LambdaReadingVisitor(ClassLoader classLoader, String implMethodName, Type owningType) { + super(SpringAsmInfo.ASM_VERSION); this.implMethodName = implMethodName; - this.owningType = owningType; + this.methodVisitor = new LambdaMethodVisitor(classLoader, owningType); } - @Override - public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { - // Capture the lambda body methods for later - if (name.equals(implMethodName)) { - - methodVisitor = new LambdaMethodVisitor(classLoader, owningType); - return methodVisitor; - } - - return null; + public MemberDescriptor getMemberReference(SerializedLambda lambda) { + return methodVisitor.resolve(lambda); } - public MemberReference getPropertyPathInformation(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 { @@ -197,11 +241,11 @@ class LambdaMethodVisitor extends MethodVisitor { private final ClassLoader classLoader; private final Type owningType; private int line; - List memberReferences = new ArrayList<>(); - Set errors = new LinkedHashSet<>(); + private final List memberDescriptors = new ArrayList<>(); + private final Set errors = new LinkedHashSet<>(); public LambdaMethodVisitor(ClassLoader classLoader, Type owningType) { - super(Opcodes.ASM10_EXPERIMENTAL); + super(SpringAsmInfo.ASM_VERSION); this.classLoader = classLoader; this.owningType = owningType; } @@ -219,16 +263,22 @@ public void visitInsn(int opcode) { 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 ParseError(line, + errors.add(new ReadingError(line, "Lambda expressions may only contain method calls to getters, record components, or field access", null)); } @@ -236,19 +286,20 @@ public void visitLdcInsn(Object value) { public void visitFieldInsn(int opcode, String owner, String name, String descriptor) { if (opcode == Opcodes.PUTSTATIC || opcode == Opcodes.PUTFIELD) { - errors.add(new ParseError(line, "Setting a field not allowed", null)); + errors.add(new ReadingError(line, "Setting a field not allowed", null)); return; } Type fieldType = Type.getType(descriptor); try { - this.memberReferences.add(FieldInformation.create(classLoader, owningType, name, fieldType)); + 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 ParseError(line, e.getMessage())); + errors.add(new ReadingError(line, e.getMessage())); } } @@ -256,7 +307,7 @@ public void visitFieldInsn(int opcode, String owner, String name, String descrip public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { if (opcode == Opcodes.INVOKESPECIAL && name.equals("")) { - errors.add(new ParseError(line, "Lambda must not invoke constructors", null)); + errors.add(new ReadingError(line, "Lambda must not invoke constructors", null)); return; } @@ -268,7 +319,7 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri return; } - errors.add(new ParseError(line, "Method references must invoke no-arg methods only")); + errors.add(new ReadingError(line, "Method references must invoke no-arg methods only")); return; } @@ -277,33 +328,39 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri Type[] argumentTypes = Type.getArgumentTypes(descriptor); String signature = Arrays.stream(argumentTypes).map(Type::getClassName).collect(Collectors.joining(", ")); - errors.add(new ParseError(line, + errors.add(new ReadingError(line, "Cannot derive a method reference from method invocation '%s(%s)' on a different type than the owning one.%nExpected owning type: '%s', but was: '%s'" .formatted(name, signature, this.owningType.getClassName(), ownerType.getClassName()))); return; } try { - this.memberReferences.add(MethodInformation.ofInvokeVirtual(classLoader, owningType, name)); + 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 ParseError(line, e.getMessage())); + errors.add(new ReadingError(line, e.getMessage())); } } - public MemberReference resolve(SerializedLambda lambda) { + /** + * 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 (memberReferences.isEmpty()) { + if (memberDescriptors.isEmpty()) { throw new InvalidDataAccessApiUsageException("There is no method or field access"); } - return memberReferences.get(memberReferences.size() - 1); + return memberDescriptors.get(memberDescriptors.size() - 1); } if (lambda.getImplMethodKind() == MethodHandleInfo.REF_invokeStatic @@ -313,10 +370,10 @@ public MemberReference resolve(SerializedLambda lambda) { InvalidDataAccessApiUsageException e = new InvalidDataAccessApiUsageException( "Cannot resolve property path%n%nError%s:%n".formatted(errors.size() > 1 ? "s" : "") + errors.stream() - .map(ParseError::message).map(args -> formatMessage(args)).collect(Collectors.joining())); + .map(ReadingError::message).map(LambdaMethodVisitor::formatMessage).collect(Collectors.joining())); if (includeSuppressedExceptions) { - for (ParseError error : errors) { + for (ReadingError error : errors) { if (error.e != null) { e.addSuppressed(error.e); } @@ -376,7 +433,7 @@ private StackTraceElement createSynthetic(SerializedLambda lambda, String method 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); + methodName, ClassUtils.getShortName(type.getClassName()) + ".java", errors.iterator().next().line()); } } @@ -432,15 +489,22 @@ private boolean matchesEntrypoint(String className) { return false; } - record ParseError(int line, String message, @Nullable Exception e) { + /** + * Value object for reading errors. + * + * @param line + * @param message + * @param e + */ + record ReadingError(int line, String message, @Nullable Exception e) { - ParseError(int line, String message) { + ReadingError(int line, String message) { this(line, message, null); } @Override public boolean equals(Object o) { - if (!(o instanceof ParseError that)) { + if (!(o instanceof ReadingError that)) { return false; } if (!ObjectUtils.nullSafeEquals(e, that.e)) { @@ -453,97 +517,7 @@ public boolean equals(Object o) { public int hashCode() { return ObjectUtils.nullSafeHash(message, e); } - } - - sealed interface MemberReference permits FieldInformation, MethodInformation { - Class getOwner(); - - Member getMember(); - - ResolvableType getType(); } - /** - * Value object holding information about a property path segment. - * - * @param owner - */ - record FieldInformation(Class owner, Field field) implements MemberReference { - - public static FieldInformation create(ClassLoader classLoader, Type ownerType, String name, Type fieldType) - throws ClassNotFoundException { - - Class owner = ClassUtils.forName(ownerType.getClassName(), classLoader); - Class type = ClassUtils.forName(fieldType.getClassName(), classLoader); - - return create(owner, name, type); - } - - private static FieldInformation 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 FieldInformation(owner, field); - } - - @Override - public Class getOwner() { - return owner(); - } - - @Override - public Field getMember() { - return field(); - } - - @Override - public ResolvableType getType() { - return ResolvableType.forField(field(), owner()); - } - } - - /** - * Value object holding information about a method invocation. - */ - record MethodInformation(Class owner, Method method) implements MemberReference { - - public static MethodInformation ofInvokeVirtual(ClassLoader classLoader, SerializedLambda lambda) - throws ClassNotFoundException { - return ofInvokeVirtual(classLoader, Type.getObjectType(lambda.getImplClass()), lambda.getImplMethodName()); - } - - public static MethodInformation ofInvokeVirtual(ClassLoader classLoader, Type ownerType, String name) - throws ClassNotFoundException { - Class owner = ClassUtils.forName(ownerType.getClassName(), classLoader); - return ofInvokeVirtual(owner, name); - } - - public static MethodInformation ofInvokeVirtual(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 MethodInformation(owner, method); - } - - @Override - public Class getOwner() { - return owner(); - } - - @Override - public Method getMember() { - return method(); - } - - @Override - public ResolvableType getType() { - return ResolvableType.forMethodReturnType(method(), owner()); - } - } } diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index e0f5d25762..b964dff255 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -22,12 +22,12 @@ import org.jspecify.annotations.Nullable; /** - * Type-safe representation of a property path expressed through method references. + * Type-safe representation of a property path using getter method references or lambda expressions. *

- * This functional interface extends {@link PropertyPath} to provide compile-time type safety when declaring property - * paths. Instead of using {@link PropertyPath#from(String, TypeInformation) string-based property paths} that represent - * references to properties textually and that are prone to refactoring issues, {@code TypedPropertyPath} leverages - * Java's declarative method references and lambda expressions to ensure type-safe property access. + * 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 and lambda expressions to ensure type-safe property access. *

* Typed property paths can be created directly they are accepted used or conveniently using the static factory method * {@link #of(TypedPropertyPath)} with method references: @@ -39,28 +39,24 @@ * Property paths can be composed to navigate nested properties using {@link #then(TypedPropertyPath)}: * *

- * PropertyPath.of(Person::getAddress).then(Address::getCountry).then(Country::getName);
+ * PropertyPath.of(Person::getAddress).then(Address::getCity);
  * 
*

* The interface maintains type information throughout the property path chain: the {@code T} type parameter represents * its owning type (root type for composed paths), while {@code P} represents the property value type at this path * segment. *

- * As a functional interface, {@code TypedPropertyPath} should be implemented as method reference (recommended). - * Alternatively, the interface can be implemented as lambda extracting a property value from an object of type - * {@code T}. Implementations must ensure that the method reference or lambda correctly represents a property access - * through a method invocation or by field access. Arbitrary calls to non-getter methods (i.e. methods accepting - * parameters or arbitrary method calls on types other than the owning type are not allowed and will fail with - * {@link org.springframework.dao.InvalidDataAccessApiUsageException}. - *

- * Note that using lambda expressions requires bytecode analysis of the declaration site classes and therefore presence - * of their class files. + * Use method references (recommended) or lambdas that access a property getter to implement {@code TypedPropertyPath}. + * Usage of constructor references, method calls with parameters, and complex expressions results in + * {@link org.springframework.dao.InvalidDataAccessApiUsageException}. In contrast to method references, introspection + * of lambda expressions requires bytecode analysis of the declaration site classes and therefore presence of their + * class files. * - * @param the owning type of the property path segment, but typically the root type for composed property paths. - * @param

the property value type at this path segment. + * @param the owning type of the property path segment, root type for composed paths. + * @param

the property type at this path segment. * @author Mark Paluch - * @see PropertyPath - * @see #of(TypedPropertyPath) + * @since 4.1 + * @see PropertyPath#of(TypedPropertyPath) * @see #then(TypedPropertyPath) */ @FunctionalInterface @@ -69,15 +65,15 @@ public interface TypedPropertyPath extends PropertyPath, Serializable { /** * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda. *

- * This method returns a resolved {@link TypedPropertyPath} by introspecting the given lambda. + * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference or lambda. * - * @param lambda the method reference or lambda. + * @param propertyPath the method reference or lambda. * @param owning type. * @param

property type. * @return the typed property path. */ - static TypedPropertyPath of(TypedPropertyPath lambda) { - return TypedPropertyPaths.of(lambda); + static TypedPropertyPath of(TypedPropertyPath propertyPath) { + return TypedPropertyPaths.of(propertyPath); } /** @@ -91,17 +87,17 @@ static TypedPropertyPath of(TypedPropertyPath lambda) { @Override default TypeInformation getOwningType() { - return TypedPropertyPaths.getPropertyPathInformation(this).owner(); + return TypedPropertyPaths.getMetadata(this).owner(); } @Override default String getSegment() { - return TypedPropertyPaths.getPropertyPathInformation(this).property(); + return TypedPropertyPaths.getMetadata(this).property(); } @Override default TypeInformation getTypeInformation() { - return TypedPropertyPaths.getPropertyPathInformation(this).propertyType(); + return TypedPropertyPaths.getMetadata(this).propertyType(); } @Override @@ -121,13 +117,15 @@ default Iterator iterator() { } /** - * Extend the property path by appending the {@code next} path segment and returning a new property path instance.. + * Extend the property path by appending the {@code next} path segment and returning a new property path instance. * - * @param next the next property path segment accepting a property path owned by the {@code P} type. + * @param next the next property path segment as method reference or lambda accepting the owner object {@code P} type + * and returning {@code N} as result of accessing a property. * @param the new property value type. * @return a new composed {@code TypedPropertyPath}. */ default TypedPropertyPath then(TypedPropertyPath next) { return TypedPropertyPaths.compose(this, of(next)); } + } diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index dd6250a15f..269eaf278b 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -27,44 +27,24 @@ import org.jspecify.annotations.Nullable; import org.springframework.beans.BeanUtils; +import org.springframework.util.CompositeIterator; import org.springframework.util.ConcurrentReferenceHashMap; /** - * Utility class to parse and resolve {@link TypedPropertyPath} instances. + * Utility class to read metadata and resolve {@link TypedPropertyPath} instances. + * + * @author Mark Paluch + * @since 4.1 */ class TypedPropertyPaths { - private static final Map> lambdas = new WeakHashMap<>(); + private static final Map> lambdas = new WeakHashMap<>(); private static final Map, ResolvedTypedPropertyPath>> resolved = new WeakHashMap<>(); - private static final SamParser samParser = new SamParser(PropertyPath.class, TypedPropertyPath.class, + private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyPath.class, + TypedPropertyPath.class, TypedPropertyPaths.class); - /** - * Retrieve {@link PropertyPathInformation} for a given {@link TypedPropertyPath}. - */ - public static PropertyPathInformation getPropertyPathInformation(TypedPropertyPath lambda) { - - Map cache; - synchronized (lambdas) { - cache = lambdas.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); - } - Map lambdaMap = cache; - - return lambdaMap.computeIfAbsent(lambda, o -> extractPath(lambda.getClass().getClassLoader(), lambda)); - } - - private static PropertyPathInformation extractPath(ClassLoader classLoader, TypedPropertyPath lambda) { - - SamParser.MemberReference reference = samParser.parse(classLoader, lambda); - - if (reference instanceof SamParser.MethodInformation method) { - return PropertyPathInformation.ofMethod(method); - } - - return PropertyPathInformation.ofField((SamParser.FieldInformation) reference); - } - /** * Compose a {@link TypedPropertyPath} by appending {@code next}. */ @@ -73,7 +53,7 @@ public static TypedPropertyPath compose(TypedPropertyPath } /** - * Resolve a {@link TypedPropertyPath} into a {@link ResolvedTypedPropertyPath}. + * Introspect {@link TypedPropertyPath} and return an introspected {@link ResolvedTypedPropertyPath} variant. */ @SuppressWarnings({ "unchecked", "rawtypes" }) public static TypedPropertyPath of(TypedPropertyPath lambda) { @@ -88,65 +68,99 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) } return (TypedPropertyPath) cache.computeIfAbsent(lambda, - o -> new ResolvedTypedPropertyPath(o, getPropertyPathInformation(lambda))); + o -> new ResolvedTypedPropertyPath(o, getMetadata(lambda))); } /** - * Value object holding information about a property path segment. + * Retrieve {@link PropertyPathMetadata} for a given {@link TypedPropertyPath}. + */ + public static PropertyPathMetadata getMetadata(TypedPropertyPath lambda) { + + Map cache; + synchronized (lambdas) { + cache = lambdas.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); + } + Map lambdaMap = cache; + + return lambdaMap.computeIfAbsent(lambda, o -> read(lambda)); + } + + private static PropertyPathMetadata read(TypedPropertyPath lambda) { + + MemberDescriptor reference = reader.read(lambda); + + if (reference instanceof MemberDescriptor.MethodDescriptor method) { + return PropertyPathMetadata.ofMethod(method); + } + + return PropertyPathMetadata.ofField((MemberDescriptor.MethodDescriptor.FieldDescriptor) reference); + } + + /** + * Metadata describing a single property path segment including its owner type, property type, and name. * - * @param owner - * @param propertyType - * @param property + * @param owner the type that owns the property. + * @param property the property name. + * @param propertyType the type of the property. */ - record PropertyPathInformation(TypeInformation owner, TypeInformation propertyType, String property) { + record PropertyPathMetadata(TypeInformation owner, String property, TypeInformation propertyType) { - public static PropertyPathInformation ofMethod(SamParser.MethodInformation method) { + public static PropertyPathMetadata ofMethod(MemberDescriptor.MethodDescriptor method) { PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method.method()); String methodName = method.getMember().getName(); if (descriptor == null) { - String propertyName; - - if (methodName.startsWith("is")) { - propertyName = Introspector.decapitalize(methodName.substring(2)); - } else if (methodName.startsWith("get")) { - propertyName = Introspector.decapitalize(methodName.substring(3)); - } else { - propertyName = methodName; - } + String propertyName = getPropertyName(methodName); TypeInformation owner = TypeInformation.of(method.owner()); TypeInformation fallback = owner.getProperty(propertyName); + if (fallback != null) { - return new PropertyPathInformation(owner, fallback, - propertyName); + return new PropertyPathMetadata(owner, propertyName, fallback); } throw new IllegalArgumentException( - "Cannot find PropertyDescriptor from method %s.%s".formatted(method.owner().getName(), methodName)); + "Cannot find PropertyDescriptor from method '%s.%s()'".formatted(method.owner().getName(), methodName)); } - return new PropertyPathInformation(TypeInformation.of(method.getOwner()), TypeInformation.of(method.getType()), - descriptor.getName()); + return new PropertyPathMetadata(TypeInformation.of(method.getOwner()), descriptor.getName(), + TypeInformation.of(method.getType())); } - public static PropertyPathInformation ofField(SamParser.FieldInformation field) { - return new PropertyPathInformation(TypeInformation.of(field.owner()), TypeInformation.of(field.getType()), - field.getMember().getName()); + 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 static PropertyPathMetadata ofField(MemberDescriptor.MethodDescriptor.FieldDescriptor field) { + return new PropertyPathMetadata(TypeInformation.of(field.owner()), field.getMember().getName(), + TypeInformation.of(field.getType())); + } + } + + /** + * A {@link TypedPropertyPath} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

the property type. + */ static class ResolvedTypedPropertyPath implements TypedPropertyPath { private final TypedPropertyPath function; - private final PropertyPathInformation information; + private final PropertyPathMetadata metadata; private final List list; - ResolvedTypedPropertyPath(TypedPropertyPath function, PropertyPathInformation information) { + ResolvedTypedPropertyPath(TypedPropertyPath function, PropertyPathMetadata metadata) { this.function = function; - this.information = information; + this.metadata = metadata; this.list = List.of(this); } @@ -157,17 +171,17 @@ static class ResolvedTypedPropertyPath implements TypedPropertyPath @Override public TypeInformation getOwningType() { - return information.owner(); + return metadata.owner(); } @Override public String getSegment() { - return information.property(); + return metadata.property(); } @Override public TypeInformation getTypeInformation() { - return information.propertyType(); + return metadata.propertyType(); } @Override @@ -191,24 +205,34 @@ public boolean equals(Object obj) { return true; if (obj == null || obj.getClass() != this.getClass()) return false; - var that = (ResolvedTypedPropertyPath) obj; - return Objects.equals(this.function, that.function) && Objects.equals(this.information, that.information); + var that = (ResolvedTypedPropertyPath) obj; + return Objects.equals(this.function, that.function) && Objects.equals(this.metadata, that.metadata); } @Override public int hashCode() { - return Objects.hash(function, information); + return Objects.hash(function, metadata); } @Override public String toString() { - return information.owner().getType().getSimpleName() + "." + toDotPath(); + return metadata.owner().getType().getSimpleName() + "." + toDotPath(); } - } - + } - record ComposedPropertyPath(TypedPropertyPath first, TypedPropertyPath second, + /** + * A {@link TypedPropertyPath} that represents the composition of two property paths, enabling navigation through + * nested properties. + * + * @param the root owning type. + * @param the intermediate property type (connecting first and second paths). + * @param the final property type. + * @param base the initial path segment. + * @param next the next path segment. + * @param dotPath the precomputed dot-notation path string. + */ + record ComposedPropertyPath(TypedPropertyPath base, TypedPropertyPath next, String dotPath) implements TypedPropertyPath { ComposedPropertyPath(TypedPropertyPath first, TypedPropertyPath second) { @@ -217,33 +241,33 @@ record ComposedPropertyPath(TypedPropertyPath first, TypedPropert @Override public @Nullable R get(T obj) { - M intermediate = first.get(obj); - return intermediate != null ? second.get(intermediate) : null; + M intermediate = base.get(obj); + return intermediate != null ? next.get(intermediate) : null; } @Override public TypeInformation getOwningType() { - return first.getOwningType(); + return base.getOwningType(); } @Override public String getSegment() { - return first().getSegment(); + return base().getSegment(); } @Override public PropertyPath getLeafProperty() { - return second.getLeafProperty(); + return next.getLeafProperty(); } @Override public TypeInformation getTypeInformation() { - return first.getTypeInformation(); + return base.getTypeInformation(); } @Override - public PropertyPath next() { - return second; + public TypedPropertyPath next() { + return next; } @Override @@ -258,17 +282,22 @@ public String toDotPath() { @Override public Stream stream() { - return second.stream(); + return Stream.concat(base.stream(), next.stream()); } @Override public Iterator iterator() { - return second.iterator(); + CompositeIterator iterator = new CompositeIterator<>(); + iterator.add(base.iterator()); + iterator.add(next.iterator()); + return iterator; } @Override public String toString() { return getOwningType().getType().getSimpleName() + "." + toDotPath(); } + } + } diff --git a/src/test/java/org/springframework/data/domain/SortUnitTests.java b/src/test/java/org/springframework/data/domain/SortUnitTests.java index 498a6ae960..4b9ac5d46c 100755 --- a/src/test/java/org/springframework/data/domain/SortUnitTests.java +++ b/src/test/java/org/springframework/data/domain/SortUnitTests.java @@ -70,6 +70,13 @@ void appliesPropertyPaths() { .containsSequence("firstName", "lastName"); } + @Test + void appliesPropertyPathsWithOrders() { + assertThat( + Sort.by(Order.asc(Person::getFirstName), Order.desc(Person::getLastName)).stream().map(Order::getProperty)) + .containsSequence("firstName", "lastName"); + } + /** * Asserts that the class rejects {@code null} as properties array. */ From 9e7bfa908026c28721d446f64d3c20f39530346a Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Sat, 1 Nov 2025 18:11:29 +0100 Subject: [PATCH 10/25] Add Kotlin support --- .../data/core/MemberDescriptor.java | 60 ++++- .../data/core/SerializableLambdaReader.java | 31 ++- .../data/core/TypedPropertyPath.java | 4 +- .../data/core/TypedPropertyPaths.java | 213 +++++++++++++++--- .../data/core/KPropertyPath.kt | 98 ++++++++ .../data/core/TypedPropertyPathExtensions.kt | 109 +++++++++ .../data/mapping/KPropertyPathExtensions.kt | 2 +- .../data/core/KPropertyPathTests.kt | 127 +++++++++++ .../data/core/KTypedPropertyPathUnitTests.kt | 43 ++++ .../data/core/TypedPropertyPathKtUnitTests.kt | 52 +++++ 10 files changed, 687 insertions(+), 52 deletions(-) create mode 100644 src/main/kotlin/org/springframework/data/core/KPropertyPath.kt create mode 100644 src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt create mode 100644 src/test/kotlin/org/springframework/data/core/KPropertyPathTests.kt create mode 100644 src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt create mode 100644 src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt diff --git a/src/main/java/org/springframework/data/core/MemberDescriptor.java b/src/main/java/org/springframework/data/core/MemberDescriptor.java index 2d99aa104e..11c34cdc9d 100644 --- a/src/main/java/org/springframework/data/core/MemberDescriptor.java +++ b/src/main/java/org/springframework/data/core/MemberDescriptor.java @@ -15,6 +15,9 @@ */ 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; @@ -31,8 +34,7 @@ * @author Mark Paluch * @since 4.1 */ -sealed interface MemberDescriptor - permits MemberDescriptor.MethodDescriptor.FieldDescriptor, MemberDescriptor.MethodDescriptor { +interface MemberDescriptor { /** * @return class owning the member, can be the declaring class or a subclass. @@ -69,7 +71,7 @@ static MethodDescriptor ofMethod(ClassLoader classLoader, String ownerClassName, /** * Create {@link MethodDescriptor.FieldDescriptor} from owner type, field name and field type. */ - public static MethodDescriptor.FieldDescriptor ofField(ClassLoader classLoader, String ownerClassName, String name, + static MethodDescriptor.FieldDescriptor ofField(ClassLoader classLoader, String ownerClassName, String name, String fieldType) throws ClassNotFoundException { Class owner = ClassUtils.forName(ownerClassName, classLoader); @@ -80,9 +82,6 @@ public static MethodDescriptor.FieldDescriptor ofField(ClassLoader classLoader, /** * Value object describing a {@link Method} in the context of an owning class. - * - * @param owner - * @param method */ record MethodDescriptor(Class owner, Method method) implements MemberDescriptor { @@ -113,9 +112,6 @@ public ResolvableType getType() { /** * Value object describing a {@link Field} in the context of an owning class. - * - * @param owner - * @param field */ record FieldDescriptor(Class owner, Field field) implements MemberDescriptor { @@ -144,4 +140,50 @@ public ResolvableType getType() { } } + + /** + * Value object describing a Kotlin property in the context of an owning class. + */ + record KPropertyReferenceDescriptor(Class owner, KProperty1 property) implements MemberDescriptor { + + static KPropertyReferenceDescriptor create(Class owner, KProperty1 property) { + return new KPropertyReferenceDescriptor(owner, 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()); + } + + } + } diff --git a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java index 42c15bc8f8..36a52ffe2f 100644 --- a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java +++ b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java @@ -15,6 +15,11 @@ */ 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; @@ -33,7 +38,6 @@ 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; @@ -41,8 +45,10 @@ 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.KPropertyReferenceDescriptor; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -104,8 +110,8 @@ class SerializableLambdaReader { 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 static final boolean filterStackTrace = isEnabled(FILTER_STACK_TRACE, false); + private static final boolean includeSuppressedExceptions = isEnabled(INCLUDE_SUPPRESSED_EXCEPTIONS, true); private final List> entryPoints; @@ -132,6 +138,18 @@ private static boolean isEnabled(String property, boolean defaultValue) { public MemberDescriptor read(Object lambdaObject) { SerializedLambda lambda = serialize(lambdaObject); + + if (isKotlinPropertyReference(lambda)) { + + Object captured = lambda.getCapturedArg(0); + if (captured != null // + && captured instanceof PropertyReference propRef // + && propRef.getOwner() instanceof KClass owner // + && captured instanceof KProperty1 kProperty) { + return new KPropertyReferenceDescriptor(JvmClassMappingKt.getJavaClass(owner), kProperty); + } + } + assertNotConstructor(lambda); try { @@ -156,6 +174,11 @@ public MemberDescriptor read(Object lambdaObject) { + ". The given value is not a lambda or method reference."); } + public static boolean isKotlinPropertyReference(SerializedLambda lambda) { + return KotlinDetector.isKotlinReflectPresent() && lambda.getCapturedArgCount() == 1 + && lambda.getCapturedArg(0) != null && KotlinDetector.isKotlinType(lambda.getCapturedArg(0).getClass()); + } + private void assertNotConstructor(SerializedLambda lambda) { if (lambda.getImplMethodKind() == MethodHandleInfo.REF_newInvokeSpecial @@ -192,7 +215,7 @@ private MemberDescriptor getMemberDescriptor(Object lambdaObject, SerializedLamb } } - private static SerializedLambda serialize(Object lambda) { + static SerializedLambda serialize(Object lambda) { try { Method method = lambda.getClass().getDeclaredMethod("writeReplace"); diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index b964dff255..e22436553b 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -72,7 +72,7 @@ public interface TypedPropertyPath extends PropertyPath, Serializable { * @param

property type. * @return the typed property path. */ - static TypedPropertyPath of(TypedPropertyPath propertyPath) { + static TypedPropertyPath of(TypedPropertyPath propertyPath) { return TypedPropertyPaths.of(propertyPath); } @@ -124,7 +124,7 @@ default Iterator iterator() { * @param the new property value type. * @return a new composed {@code TypedPropertyPath}. */ - default TypedPropertyPath then(TypedPropertyPath next) { + default TypedPropertyPath then(TypedPropertyPath next) { return TypedPropertyPaths.compose(this, of(next)); } diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index 269eaf278b..f7e0bcba55 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -15,6 +15,8 @@ */ package org.springframework.data.core; +import kotlin.reflect.KProperty; + import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.util.Iterator; @@ -25,8 +27,12 @@ import java.util.stream.Stream; 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.KPropertyReferenceDescriptor; +import org.springframework.data.core.MemberDescriptor.MethodDescriptor; import org.springframework.util.CompositeIterator; import org.springframework.util.ConcurrentReferenceHashMap; @@ -42,8 +48,7 @@ class TypedPropertyPaths { private static final Map, ResolvedTypedPropertyPath>> resolved = new WeakHashMap<>(); private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyPath.class, - TypedPropertyPath.class, - TypedPropertyPaths.class); + TypedPropertyPath.class, TypedPropertyPaths.class); /** * Compose a {@link TypedPropertyPath} by appending {@code next}. @@ -58,7 +63,7 @@ public static TypedPropertyPath compose(TypedPropertyPath @SuppressWarnings({ "unchecked", "rawtypes" }) public static TypedPropertyPath of(TypedPropertyPath lambda) { - if (lambda instanceof ComposedPropertyPath || lambda instanceof ResolvedTypedPropertyPath) { + if (lambda instanceof ComposedPropertyPath || lambda instanceof ResolvedTypedPropertyPathSupport) { return lambda; } @@ -71,6 +76,18 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) o -> new ResolvedTypedPropertyPath(o, getMetadata(lambda))); } + /** + * Retrieve {@link PropertyPathMetadata} for a given {@link TypedPropertyPath}. + */ + public static TypedPropertyPath of(TypedPropertyPath delegate, PropertyPathMetadata metadata) { + + if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyPathMetadata kmp) { + return new ResolvedKPropertyPath(kmp.getProperty(), metadata); + } + + return new ResolvedTypedPropertyPath<>(delegate, metadata); + } + /** * Retrieve {@link PropertyPathMetadata} for a given {@link TypedPropertyPath}. */ @@ -89,7 +106,11 @@ private static PropertyPathMetadata read(TypedPropertyPath lambda) { MemberDescriptor reference = reader.read(lambda); - if (reference instanceof MemberDescriptor.MethodDescriptor method) { + if (KotlinDetector.isKotlinReflectPresent() && reference instanceof KPropertyReferenceDescriptor kProperty) { + return KPropertyPathMetadata.of(kProperty); + } + + if (reference instanceof MethodDescriptor method) { return PropertyPathMetadata.ofMethod(method); } @@ -98,14 +119,27 @@ private static PropertyPathMetadata read(TypedPropertyPath lambda) { /** * Metadata describing a single property path segment including its owner type, property type, and name. - * - * @param owner the type that owns the property. - * @param property the property name. - * @param propertyType the type of the property. */ - record PropertyPathMetadata(TypeInformation owner, String property, TypeInformation propertyType) { + static class PropertyPathMetadata { - public static PropertyPathMetadata ofMethod(MemberDescriptor.MethodDescriptor method) { + private final TypeInformation owner; + private final String property; + private final TypeInformation propertyType; + + PropertyPathMetadata(Class owner, String property, ResolvableType propertyType) { + this(TypeInformation.of(owner), property, TypeInformation.of(propertyType)); + } + + PropertyPathMetadata(TypeInformation owner, String property, TypeInformation propertyType) { + this.owner = owner; + this.property = property; + this.propertyType = propertyType; + } + + /** + * Create a new {@code PropertyPathMetadata} from a method. + */ + public static PropertyPathMetadata ofMethod(MethodDescriptor method) { PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method.method()); String methodName = method.getMember().getName(); @@ -124,8 +158,7 @@ public static PropertyPathMetadata ofMethod(MemberDescriptor.MethodDescriptor me "Cannot find PropertyDescriptor from method '%s.%s()'".formatted(method.owner().getName(), methodName)); } - return new PropertyPathMetadata(TypeInformation.of(method.getOwner()), descriptor.getName(), - TypeInformation.of(method.getType())); + return new PropertyPathMetadata(method.getOwner(), descriptor.getName(), method.getType()); } private static String getPropertyName(String methodName) { @@ -139,34 +172,67 @@ private static String getPropertyName(String methodName) { return methodName; } - public static PropertyPathMetadata ofField(MemberDescriptor.MethodDescriptor.FieldDescriptor field) { - return new PropertyPathMetadata(TypeInformation.of(field.owner()), field.getMember().getName(), - TypeInformation.of(field.getType())); + /** + * Create a new {@code PropertyPathMetadata} from a field. + */ + public static PropertyPathMetadata ofField(FieldDescriptor field) { + return new PropertyPathMetadata(field.owner(), field.getMember().getName(), field.getType()); + } + + public TypeInformation owner() { + return owner; + } + + public String property() { + return property; + } + + public TypeInformation propertyType() { + return propertyType; } } + /** + * Kotlin-specific {@link PropertyPathMetadata} implementation. + */ + static class KPropertyPathMetadata extends PropertyPathMetadata { + + 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()); + } + + public KProperty getProperty() { + return property; + } + } + /** * A {@link TypedPropertyPath} implementation that caches resolved metadata to avoid repeated introspection. * * @param the owning type. * @param

the property type. */ - static class ResolvedTypedPropertyPath implements TypedPropertyPath { + static abstract class ResolvedTypedPropertyPathSupport implements TypedPropertyPath { - private final TypedPropertyPath function; private final PropertyPathMetadata metadata; private final List list; + private final String toString; - ResolvedTypedPropertyPath(TypedPropertyPath function, PropertyPathMetadata metadata) { - this.function = function; + ResolvedTypedPropertyPathSupport(PropertyPathMetadata metadata) { this.metadata = metadata; this.list = List.of(this); - } - - @Override - public @Nullable P get(T obj) { - return function.get(obj); + this.toString = metadata.owner().getType().getSimpleName() + "." + toDotPath(); } @Override @@ -200,23 +266,77 @@ public List toList() { } @Override - public boolean equals(Object obj) { - if (obj == this) + public boolean equals(@Nullable Object obj) { + + if (obj == this) { return true; - if (obj == null || obj.getClass() != this.getClass()) + } + + if (!(obj instanceof PropertyPath that)) { return false; - var that = (ResolvedTypedPropertyPath) obj; - return Objects.equals(this.function, that.function) && Objects.equals(this.metadata, that.metadata); + } + + return Objects.equals(this.toDotPath(), that.toDotPath()) + && Objects.equals(this.getOwningType(), that.getOwningType()); } @Override public int hashCode() { - return Objects.hash(function, metadata); + return toString().hashCode(); } @Override public String toString() { - return metadata.owner().getType().getSimpleName() + "." + toDotPath(); + return toString; + } + + } + + /** + * A {@link TypedPropertyPath} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

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

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

property; + + ResolvedKPropertyPath(KPropertyPathMetadata metadata) { + this((KProperty

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

property, PropertyPathMetadata metadata) { + super(metadata); + this.property = property; + } + + @Override + public @Nullable P get(T obj) { + return property.call(obj); } } @@ -232,11 +352,12 @@ public String toString() { * @param next the next path segment. * @param dotPath the precomputed dot-notation path string. */ - record ComposedPropertyPath(TypedPropertyPath base, TypedPropertyPath next, - String dotPath) implements TypedPropertyPath { + record ComposedPropertyPath(TypedPropertyPath base, TypedPropertyPath next, String dotPath, + String toStringRepresentation) implements TypedPropertyPath { ComposedPropertyPath(TypedPropertyPath first, TypedPropertyPath second) { - this(first, second, first.toDotPath() + "." + second.toDotPath()); + this(first, second, first.toDotPath() + "." + second.toDotPath(), + first.getType().getSimpleName() + "." + first.toDotPath() + "." + second.toDotPath()); } @Override @@ -293,9 +414,29 @@ public Iterator iterator() { return iterator; } + @Override + public boolean equals(Object obj) { + + if (obj == this) { + return true; + } + + if (!(obj instanceof PropertyPath that)) { + return false; + } + + return Objects.equals(this.toDotPath(), that.toDotPath()) + && Objects.equals(this.getOwningType(), that.getOwningType()); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + @Override public String toString() { - return getOwningType().getType().getSimpleName() + "." + toDotPath(); + return toStringRepresentation; } } diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt new file mode 100644 index 0000000000..6b49520e2f --- /dev/null +++ b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt @@ -0,0 +1,98 @@ +/* + * 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 + +/** + * Abstraction of a property path consisting of [KProperty]. + * + * @author Tjeu Kayim + * @author Mark Paluch + * @author Yoann de Martino + * @since 2.5 + */ +internal class KPropertyPath( + val parent: KProperty, + val child: KProperty1 +) : KProperty by 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 3.5 + */ +internal class KIterablePropertyPath( + val parent: KProperty?>, + val child: KProperty1 +) : KProperty by child + +/** + * Recursively construct field name for a nested property. + * @author Tjeu Kayim + * @author Mikhail Polivakha + */ +fun asString(property: KProperty<*>): String { + return when (property) { + is KPropertyPath<*, *> -> + "${asString(property.parent)}.${property.child.name}" + + is KIterablePropertyPath<*, *> -> + "${asString(property.parent)}.${property.child.name}" + + else -> property.name + } +} + +/** + * 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 2.5 + */ +@JvmName("div") +operator fun KProperty.div(other: KProperty1): KProperty = + KPropertyPath(this, other) + +/** + * 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 3.5 + */ +@JvmName("divIterable") +operator fun KProperty?>.div(other: KProperty1): KProperty = + KIterablePropertyPath(this, other) diff --git a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt new file mode 100644 index 0000000000..9506ff8dae --- /dev/null +++ b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2020-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 function to compose a [TypedPropertyPath] with a [KProperty1]. + * + * @since 4.1 + */ +fun TypedPropertyPath.then(next: KProperty1): TypedPropertyPath { + val nextPath = KTypedPropertyPath.of(next) as TypedPropertyPath + return TypedPropertyPaths.ComposedPropertyPath(this, nextPath) +} + +/** + * Extension function to compose a [TypedPropertyPath] with a [KProperty]. + * + * @since 4.1 + */ +fun TypedPropertyPath.then(next: KProperty): TypedPropertyPath { + val nextPath = KTypedPropertyPath.of(next) as TypedPropertyPath + return TypedPropertyPaths.ComposedPropertyPath(this, nextPath) +} + +/** + * Helper to create [TypedPropertyPath] from [KProperty]. + * + * @since 4.1 + */ +class KTypedPropertyPath { + + /** + * Companion object for static factory methods. + */ + companion object { + + /** + * Create a [TypedPropertyPath] from a [KProperty1]. + */ + fun of(property: KProperty1): TypedPropertyPath { + return of((property as KProperty)) + } + + /** + * Create a [TypedPropertyPath] from a [KProperty]. + */ + fun of(property: KProperty): TypedPropertyPath { + + if (property is KProperty1<*, *>) { + val metadata = TypedPropertyPaths.KPropertyPathMetadata.of( + MemberDescriptor.KPropertyReferenceDescriptor.create( + String::class.java, + property as KProperty1<*, *> + ) + ) + return TypedPropertyPaths.ResolvedKPropertyPath(metadata) + } + + if (property is KPropertyPath<*, *>) { + + val paths = property as KPropertyPath<*, *> + + val parent = of(paths.parent) + val child = of(paths.child) + + return TypedPropertyPaths.ComposedPropertyPath(parent, child) as TypedPropertyPath + } + + if (property is KIterablePropertyPath<*, *>) { + + val paths = property as KIterablePropertyPath<*, *> + + val parent = of(paths.parent) + val child = of(paths.child) + + return TypedPropertyPaths.ComposedPropertyPath(parent, child) as TypedPropertyPath + } + + throw IllegalArgumentException("Property ${property.name} is not a KProperty") + } + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt index 2886032f1f..b70249a27a 100644 --- a/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt +++ b/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt @@ -24,4 +24,4 @@ import kotlin.reflect.KProperty * @since 2.5 * @see org.springframework.data.core.PropertyPath.toDotPath */ -fun KProperty<*>.toDotPath(): String = asString(this) +fun KProperty<*>.toDotPath(): String = asString(this) \ No newline at end of file diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyPathTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyPathTests.kt new file mode 100644 index 0000000000..c2ab2d2d0a --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/KPropertyPathTests.kt @@ -0,0 +1,127 @@ +/* + * 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 + +/** + * Unit tests for [KPropertyPath] and its extensions. + * + * @author Tjeu Kayim + * @author Yoann de Martino + * @author Mark Paluch + * @author Mikhail Polivakha + */ +class KPropertyPathTests { + + @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 = asString(AnotherEntity::entity / Entity::book / Book::author / Author::name) + + 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 = asString(Owner::cat / Cat::name) + assertThat(property).isEqualTo("cat.name") + } + + class Book(val title: String, val author: Author) + class Author(val name: String, val books: List) + +} diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt new file mode 100644 index 0000000000..a0ee50ff65 --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -0,0 +1,43 @@ +package org.springframework.data.core + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.data.domain.Sort + +/** + * Unit tests for [KPropertyPath] and related functionality. + * + * @author Mark Paluch + */ +class KTypedPropertyPathUnitTests { + + @Test + fun shouldCreatePropertyPath() { + + val path = KTypedPropertyPath.of(Author::name) + + assertThat(path.toDotPath()).isEqualTo("name") + + Sort.by(Book::author, Book::title); + } + + @Test + fun shouldComposePropertyPath() { + + val path = KTypedPropertyPath.of(Book::author).then(Author::name) + + assertThat(path.toDotPath()).isEqualTo("author.name") + } + + @Test + fun shouldCreateComposed() { + + val path = KTypedPropertyPath.of(Book::author / Author::name) + + assertThat(path.toDotPath()).isEqualTo("author.name") + } + + class Book(val title: String, val author: Author) + class Author(val name: String, val books: List) + +} \ No newline at end of file diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt new file mode 100644 index 0000000000..8a4f276ead --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt @@ -0,0 +1,52 @@ +package org.springframework.data.core + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * Kotlin unit tests for [TypedPropertyPath] and related functionality. + * + * @author Mark Paluch + */ +class TypedPropertyPathKtUnitTests { + + @Test + fun shouldSupportPropertyReference() { + assertThat(TypedPropertyPath.of(Person::address).toDotPath()).isEqualTo("address") + } + + @Test + fun shouldSupportComposedPropertyReference() { + + val path = TypedPropertyPath.of(Person::address).then(Address::city); + assertThat(path.toDotPath()).isEqualTo("address.city") + } + + @Test + fun shouldSupportPropertyLambda() { + assertThat(TypedPropertyPath.of { it.address }.toDotPath()).isEqualTo("address") + assertThat(TypedPropertyPath.of { it -> it.address }.toDotPath()).isEqualTo("address") + } + + @Test + fun shouldSupportComposedPropertyLambda() { + + val path = TypedPropertyPath.of { it.address }; + assertThat(path.then { it.city }.toDotPath()).isEqualTo("address.city") + } + + class Person { + var firstname: String? = null + var lastname: String? = null + var age: Int = 0 + var address: Address? = null + } + + class Address { + var city: String? = null + var street: String? = null + var country: Country? = null + } + + data class Country(val name: String) +} \ No newline at end of file From 21d5d00581c4781c7926f7d7a8e8073d0b466903 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Sat, 1 Nov 2025 18:11:56 +0100 Subject: [PATCH 11/25] Documentation --- .../modules/ROOT/pages/property-paths.adoc | 188 ++++++++++++++++-- 1 file changed, 167 insertions(+), 21 deletions(-) diff --git a/src/main/antora/modules/ROOT/pages/property-paths.adoc b/src/main/antora/modules/ROOT/pages/property-paths.adoc index c0cdc552e3..734d58d61d 100644 --- a/src/main/antora/modules/ROOT/pages/property-paths.adoc +++ b/src/main/antora/modules/ROOT/pages/property-paths.adoc @@ -1,42 +1,188 @@ -[[type-safe-property-references]] -= Type-safe Property References +[[property-paths]] += Property Paths -Type-safe property references address a common source of friction in data access code: The reliance on literal, dot-separated property path strings. -Such stringly-typed references are fragile during refactoring difficult to identify as they often lack context. -Type-safe property paths favor explicitness and compiler validation by deriving property paths from Java method references. +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. -A property path is a simple, transportable representation of object navigation. -When expressed as a method-reference the compiler participates in validation and IDEs provide meaningful refactoring support. -The result is code that reads naturally, fails fast on renames, and integrates cleanly with existing query and sorting abstractions, for example: +[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. -[source,java] +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"] ---- -TypedPropertyPath.of(Person::getAddress) - .then(Address::getCity); +class Person { + String firstname, lastname; + int age; + Address address; + List

previousAddresses; + + String getFirstname() { … }; // other property accessors omitted for brevity + +} + +class Address { + String city, street; + + // accessors omitted for brevity + +} ---- -The expression above constructs a path equivalent to `address.city` while remaining resilient to refactoring. -Property resolution is performed by inspecting the supplied method references; any mismatch becomes visible at compile time. +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +class Person { + var firstname: String? = null + var lastname: String? = null + var age: Int = 0 + var address: Address? = null + var previousAddresses: List
= emptyList() +} + +class Address { + var city: String? = null + var street: String? = null +} +---- +====== -By comparing a literal-based approach as the following example you can immediately spot the same intent while the mechanism of using strings removes any type context: +Property paths use dot-notation to express property references throughout Spring Data operations, such as sorting and filtering: -.Stringly-typed programming +.Dot-notation property references [source,java] ---- -Sort.by("address.city", "address.street") +Sort.by("firstname", "address.city") ---- -You can also use it inline for operations like sorting: +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. -.Type-safe Property Path -[source,java] +Collection and array properties support transparent traversal to their component type, enabling direct reference to nested properties: + +---- +Sort.by("address.city") <1> + +Sort.by("previousAddresses") <2> + +Sort.by("previousAddresses.city") <3> +---- + +<1> Navigate from the top-level `address` property to the `city` field. +<2> Reference the entire `previousAddresses` collection (supported by certain technologies for collection-based sorting). +<3> Navigate through the collection to sort by the `city` field of each address. + +String-based property paths offer simplicity and can be broadly applied but there are tradeoffs to consider: + +* **Flexibility**: Property paths are flexible and can be constructed from constant string, configuration or as result of user input. +* **Untyped**: String paths do not carry compile-time type information. +Typed as textual content they do not have a dependency on the underlying domain type. +* **Refactoring risk**: Renaming domain properties requires often manual updates to string literals; IDEs cannot reliably track these references. + +To improve refactoring safety and type consistency, prefer type-safe property references using method references. +This approach associates property paths with compile-time type information and enables compiler validation and IDE-driven refactoring. +See <> for details. + +NOTE: For implementation details, refer to <> for more information. + +[[property-path-internals]] +=== Property Path Internals + +The `org.springframework.data.core` package is the basis for Spring Data's navigation across domain classes. +The javadoc:org.springframework.data.core.TypeInformation[] inteface provides type introspection capable of resolving the type of a property. javadoc:org.springframework.data.core.PropertyPath[] represents a textual navigation path through a domain class. + +Together they provide: + +* Generic type resolution and introspection +* Property path creation and validation +* Actual type resolution for complex properties such as collections and maps + +[[type-safe-property-references]] +== Type-safe Property-References + +Type-safe property-references eliminate a common source of errors in data access code: Brittle, string-based property references. +This section explains how method references can be used to express refactoring-safe property paths. + +While a property path is a simple representation of object navigation, String-based property paths are inherently fragile during refactoring as they can be easily missed with an increasing distance between the property definition and its usage. +Type-safe alternatives derive property paths from method references, enabling the compiler to validate property names and IDEs to support refactoring operations. + +Consider the practical difference: The following examples express the same intent - sorting by `address.city` - but only the type-safe version benefits from compiler validation and IDE support: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +TypedPropertyPath.of(Person::getAddress) + .then(Address::getCity); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] ---- +TypedPropertyPath.of(Person::address) + .then(Address::city) + +// Kotlin Exension +KTypedPropertyPath.of(Person::address).then(Address::city) +---- +====== + +=== Building Type-safe Property Paths + +Type-safe property paths compose method references to construct navigation expressions. +The following examples demonstrate inline usage and composition: + +.Type-safe Property Path Construction +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +// Inline usage with Sort Sort.by(Person::getFirstName, Person::getLastName); + +// Composed navigation +TypedPropertyPath.of(Person::getAddress) + .then(Address::getCity); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +// Inline usage with Sort +Sort.by(Person::firstName, Person::lastName); + +// Kotlin extension with composed navigation +KTypedPropertyPath.of(Person::address) + .then(Address::city) ---- +====== -`TypedPropertyPath` can integrate seamlessly with query abstractions or criteria builders: +Type-safe property paths integrate seamlessly with query abstractions and criteria builders, enabling declarative query construction without string-based property references: -.Type-safe Property Path +.Integration with Criteria API [source,java] ---- Criteria.where(Person::getAddress) From 32c6b6bbcdc0f9d6e1e2c10da445c624d9d23882 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Sun, 2 Nov 2025 13:01:06 +0100 Subject: [PATCH 12/25] Collection support. --- .../data/core/PropertyPath.java | 17 ++++- .../data/core/TypedPropertyPath.java | 65 ++++++++++++++----- .../data/core/TypedPropertyPathExtensions.kt | 8 +++ .../data/core/TypedPropertyPathUnitTests.java | 17 +++++ .../data/core/KTypedPropertyPathUnitTests.kt | 8 +++ 5 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/springframework/data/core/PropertyPath.java b/src/main/java/org/springframework/data/core/PropertyPath.java index b6a5cd2053..9aca8b9fd8 100644 --- a/src/main/java/org/springframework/data/core/PropertyPath.java +++ b/src/main/java/org/springframework/data/core/PropertyPath.java @@ -19,7 +19,6 @@ import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; - import org.springframework.data.util.Streamable; import org.springframework.util.Assert; @@ -43,11 +42,27 @@ public interface PropertyPath extends Streamable { * @param owning type. * @param

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

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference or lambda. + * + * @param propertyPath the method reference or lambda. + * @param owning type. + * @param

property type. + * @return the typed property path. + * @since 4.1 + */ + static TypedPropertyPath ofMany(TypedPropertyPath> propertyPath) { + return (TypedPropertyPath) TypedPropertyPaths.of(propertyPath); + } + /** * Returns the owning type of the {@link PropertyPath}. * diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index e22436553b..633024151e 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -22,38 +22,41 @@ import org.jspecify.annotations.Nullable; /** - * Type-safe representation of a property path using getter method references or lambda expressions. + * Interface providing type-safe property path navigation through method references or lambda expressions. *

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

- * Typed property paths can be created directly they are accepted used or conveniently using the static factory method - * {@link #of(TypedPropertyPath)} with method references: + * Create a typed property path using the static factory method {@link #of(TypedPropertyPath)} with a method reference + * or lambda, for example: * *

- * PropertyPath.of(Person::getName);
+ * TypedPropertyPath<Person, String> name = TypedPropertyPath.of(Person::getName);
  * 
- * - * Property paths can be composed to navigate nested properties using {@link #then(TypedPropertyPath)}: + * + * The resulting object can be used to obtain the {@link #toDotPath() dot-path} and to interact with the targetting + * property. Typed paths allow for composition to navigate nested object structures using + * {@link #then(TypedPropertyPath)}: * *
- * PropertyPath.of(Person::getAddress).then(Address::getCity);
+ * TypedPropertyPath<Person, String> city = TypedPropertyPath.of(Person::getAddress).then(Address::getCity);
  * 
*

- * The interface maintains type information throughout the property path chain: the {@code T} type parameter represents - * its owning type (root type for composed paths), while {@code P} represents the property value type at this path - * segment. + * 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. *

- * Use method references (recommended) or lambdas that access a property getter to implement {@code TypedPropertyPath}. - * Usage of constructor references, method calls with parameters, and complex expressions results in - * {@link org.springframework.dao.InvalidDataAccessApiUsageException}. In contrast to method references, introspection - * of lambda expressions requires bytecode analysis of the declaration site classes and therefore presence of their - * class files. + * Implement {@code TypedPropertyPath} using method references (strongly recommended) or lambdas that directly access a + * property getter. Constructor references, method calls with parameters, and complex expressions are not supported and + * result in {@link org.springframework.dao.InvalidDataAccessApiUsageException}. Unlike method references, introspection + * of lambda expressions requires bytecode analysis of the declaration site classes and thus depends on their + * availability at runtime. * - * @param the owning type of the property path segment, root type for composed paths. - * @param

the property type at this path segment. + * @param the owning type of this path segment; the root type for composed paths. + * @param

the property value type at this path segment. * @author Mark Paluch * @since 4.1 * @see PropertyPath#of(TypedPropertyPath) @@ -76,6 +79,21 @@ public interface TypedPropertyPath extends PropertyPath, Serializable { return TypedPropertyPaths.of(propertyPath); } + /** + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda for a collection property. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference or lambda. + * + * @param propertyPath the method reference or lambda. + * @param owning type. + * @param

property type. + * @return the typed property path. + * @since 4.1 + */ + static TypedPropertyPath ofMany(TypedPropertyPath> propertyPath) { + return (TypedPropertyPath) TypedPropertyPaths.of(propertyPath); + } + /** * Get the property value for the given object. * @@ -128,4 +146,17 @@ default Iterator iterator() { return TypedPropertyPaths.compose(this, of(next)); } + /** + * Extend the property path by appending the {@code next} path segment and returning a new property path instance. + * + * @param next the next property path segment as method reference or lambda accepting the owner object {@code P} type + * and returning {@code N} as result of accessing a property. + * @param the new property value type. + * @return a new composed {@code TypedPropertyPath}. + */ + default TypedPropertyPath thenMany( + TypedPropertyPath> next) { + return (TypedPropertyPath) TypedPropertyPaths.compose(this, of(next)); + } + } diff --git a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt index 9506ff8dae..b5b42e5621 100644 --- a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt @@ -66,6 +66,14 @@ class KTypedPropertyPath { return of((property as KProperty)) } + /** + * Create a [TypedPropertyPath] from a collection-like [KProperty1]. + */ + @JvmName("ofMany") + fun of(property: KProperty1>): TypedPropertyPath { + return of((property as KProperty)) + } + /** * Create a [TypedPropertyPath] from a [KProperty]. */ diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index e241e3f3ec..9c783df0b7 100644 --- a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -17,6 +17,8 @@ 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; @@ -63,6 +65,12 @@ void resolvesMHComposedPath() { .isEqualTo("address.country"); } + @Test + void resolvesCollectionPath() { + assertThat(PropertyPath.ofMany(PersonQuery::getAddresses).then(Address::getCity).toDotPath()) + .isEqualTo("addresses.city"); + } + @Test void resolvesInitialLambdaGetter() { assertThat(PropertyPath.of((PersonQuery person) -> person.getName()).toDotPath()).isEqualTo("name"); @@ -235,6 +243,7 @@ static public class PersonQuery extends SuperClass { private String name; private Integer age; private Address address; + private List

addresses; public PersonQuery(PersonQuery pq) {} @@ -252,6 +261,14 @@ public Integer getAge() { public Address getAddress() { return address; } + + public List
getAddresses() { + return addresses; + } + + public void setAddresses(List
addresses) { + this.addresses = addresses; + } } class Address { diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt index a0ee50ff65..676af1e981 100644 --- a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -29,6 +29,14 @@ class KTypedPropertyPathUnitTests { assertThat(path.toDotPath()).isEqualTo("author.name") } + @Test + fun shouldComposeManyPropertyPath() { + + val path = KTypedPropertyPath.of(Author::books).then(Book::title) + + assertThat(path.toDotPath()).isEqualTo("books.title") + } + @Test fun shouldCreateComposed() { From 3e3b307d7da70d3dba56f1eff4a1503908d5e325 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 4 Nov 2025 11:49:19 +0100 Subject: [PATCH 13/25] Fix property path composition, introduce TCK for PropertyPath, simplify equality and hashing checks. --- .../data/core/PropertyPath.java | 69 ++++++----- .../data/core/PropertyPathUtil.java | 62 ++++++++++ .../data/core/SimplePropertyPath.java | 37 +----- .../data/core/TypedPropertyPath.java | 2 +- .../data/core/TypedPropertyPaths.java | 116 ++++++++++-------- .../data/core/TypedPropertyPathExtensions.kt | 26 ++-- .../data/core/PropertyPathTck.java | 70 +++++++++++ .../data/core/PropertyPathUnitTests.java | 20 +++ .../data/core/TypedPropertyPathUnitTests.java | 76 ++++++++---- .../data/core/KTypedPropertyPathUnitTests.kt | 61 ++++++++- .../data/core/TypedPropertyPathKtUnitTests.kt | 54 +++++++- 11 files changed, 442 insertions(+), 151 deletions(-) create mode 100644 src/main/java/org/springframework/data/core/PropertyPathUtil.java create mode 100644 src/test/java/org/springframework/data/core/PropertyPathTck.java diff --git a/src/main/java/org/springframework/data/core/PropertyPath.java b/src/main/java/org/springframework/data/core/PropertyPath.java index 9aca8b9fd8..892133d476 100644 --- a/src/main/java/org/springframework/data/core/PropertyPath.java +++ b/src/main/java/org/springframework/data/core/PropertyPath.java @@ -59,6 +59,7 @@ static TypedPropertyPath of(TypedPropertyPath propertyPath) { * @return the typed property path. * @since 4.1 */ + @SuppressWarnings({ "unchecked", "rawtypes" }) static TypedPropertyPath ofMany(TypedPropertyPath> propertyPath) { return (TypedPropertyPath) TypedPropertyPaths.of(propertyPath); } @@ -71,7 +72,9 @@ static TypedPropertyPath ofMany(TypedPropertyPath getOwningType(); /** - * Returns the first part of the {@link PropertyPath}. For example: + * Returns the current property path segment (i.e. first part of {@link #toDotPath()}). + *

+ * For example: * *

 	 * PropertyPath.from("a.b.c", Some.class).getSegment();
@@ -79,14 +82,15 @@ static  TypedPropertyPath ofMany(TypedPropertyPath getLeafType() {
 		return getLeafProperty().getType();
 	}
 
 	/**
-	 * Returns the actual type of the property. Will return the plain resolved type for simple properties, the component
-	 * type for any {@link Iterable} or the value type of a {@link java.util.Map}.
+	 * Returns the actual type of the property at this segment. Will return the plain resolved type for simple properties,
+	 * the component type for any {@link Iterable} or the value type of {@link java.util.Map} properties.
 	 *
 	 * @return the actual type of the property.
+	 * @see #getTypeInformation()
+	 * @see TypeInformation#getRequiredActualType()
 	 */
 	default Class getType() {
 		return getTypeInformation().getRequiredActualType().getType();
 	}
 
 	/**
-	 * Returns the type information of the property.
+	 * Returns the type information for the property at this segment.
 	 *
-	 * @return the actual type of the property.
+	 * @return the type information for the property at this segment.
 	 */
 	TypeInformation getTypeInformation();
 
 	/**
-	 * Returns the {@link PropertyPath} path that results from removing the first element of the current one. For example:
+	 * Returns whether the current property path segment is a collection.
+	 *
+	 * @return {@literal true} if the current property path segment is a collection.
+	 * @see #getTypeInformation()
+	 * @see TypeInformation#isCollectionLike()
+	 */
+	default boolean isCollection() {
+		return getTypeInformation().isCollectionLike();
+	}
+
+	/**
+	 * Returns the next {@code PropertyPath} segment in the property path chain.
 	 *
 	 * 
 	 * PropertyPath.from("a.b.c", Some.class).next().toDotPath();
@@ -134,26 +152,27 @@ default Class getType() {
 	 *
 	 * results in the output: {@code b.c}
 	 *
-	 * @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() {
 
@@ -162,16 +181,7 @@ default String toDotPath() {
 	}
 
 	/**
-	 * Returns whether the {@link PropertyPath} is actually a collection.
-	 *
-	 * @return {@literal true} whether the {@link PropertyPath} is actually a collection.
-	 */
-	default boolean isCollection() {
-		return getTypeInformation().isCollectionLike();
-	}
-
-	/**
-	 * Returns the {@link PropertyPath} for the path nested under the current property.
+	 * Returns the {@code PropertyPath} for the path nested under the current property.
 	 *
 	 * @param path must not be {@literal null} or empty.
 	 * @return will never be {@literal null}.
@@ -186,8 +196,7 @@ default PropertyPath nested(String path) {
 	}
 
 	/**
-	 * Returns an {@link Iterator Iterator of PropertyPath} that iterates over all the partial property paths with the
-	 * same leaf type but decreasing length. For example:
+	 * Returns an {@link Iterator Iterator of PropertyPath} that iterates over all property path segments. For example:
 	 *
 	 * 
 	 * PropertyPath propertyPath = PropertyPath.from("a.b.c", Some.class);
@@ -197,9 +206,9 @@ default PropertyPath nested(String path) {
 	 * results in the dot paths:
 	 *
 	 * 
-	 * a.b.c
-	 * b.c
-	 * c
+	 * a.b.c             (this object)
+	 * b.c               (next() object)
+	 * c                 (next().next() object)
 	 * 
*/ @Override diff --git a/src/main/java/org/springframework/data/core/PropertyPathUtil.java b/src/main/java/org/springframework/data/core/PropertyPathUtil.java new file mode 100644 index 0000000000..92dfa6e1b4 --- /dev/null +++ b/src/main/java/org/springframework/data/core/PropertyPathUtil.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +/** + * Utility class for {@link PropertyPath} implementations. + * + * @author Mark Paluch + * @since 4.1 + */ +class PropertyPathUtil { + + /** + * 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(); + } + + /** + * 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. + */ + public 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()); + } + +} diff --git a/src/main/java/org/springframework/data/core/SimplePropertyPath.java b/src/main/java/org/springframework/data/core/SimplePropertyPath.java index bcf9dc2646..a8dfb2211a 100644 --- a/src/main/java/org/springframework/data/core/SimplePropertyPath.java +++ b/src/main/java/org/springframework/data/core/SimplePropertyPath.java @@ -177,45 +177,12 @@ public boolean hasNext() { @Override public boolean equals(@Nullable Object o) { - - if (this == o) { - return true; - } - - if (!(o instanceof SimplePropertyPath that)) { - return false; - } - - if (isCollection != that.isCollection) { - return false; - } - - return Objects.equals(this.owningType, that.owningType) && Objects.equals(this.name, that.name) - && Objects.equals(this.typeInformation, that.typeInformation) - && Objects.equals(this.actualTypeInformation, that.actualTypeInformation) && Objects.equals(next, that.next); + return PropertyPathUtil.equals(this, o); } @Override public int hashCode() { - return Objects.hash(owningType, name, typeInformation, actualTypeInformation, isCollection, next); - } - - /** - * Returns the next {@link SimplePropertyPath}. - * - * @return the next {@link SimplePropertyPath}. - * @throws IllegalStateException it there's no next one. - */ - private SimplePropertyPath requiredNext() { - - SimplePropertyPath result = next; - - if (result == null) { - throw new IllegalStateException( - "No next path available; Clients should call hasNext() before invoking this method"); - } - - return result; + return PropertyPathUtil.hashCode(this); } /** diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index 633024151e..a2b3155166 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -35,7 +35,7 @@ *
  * TypedPropertyPath<Person, String> name = TypedPropertyPath.of(Person::getName);
  * 
- * + * * The resulting object can be used to obtain the {@link #toDotPath() dot-path} and to interact with the targetting * property. Typed paths allow for composition to navigate nested object structures using * {@link #then(TypedPropertyPath)}: diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index f7e0bcba55..20363fbf8e 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -19,14 +19,17 @@ import java.beans.Introspector; import java.beans.PropertyDescriptor; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.WeakHashMap; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; + import org.springframework.beans.BeanUtils; import org.springframework.core.KotlinDetector; import org.springframework.core.ResolvableType; @@ -53,8 +56,28 @@ class TypedPropertyPaths { /** * Compose a {@link TypedPropertyPath} by appending {@code next}. */ + @SuppressWarnings({ "rawtypes", "unchecked" }) public static TypedPropertyPath compose(TypedPropertyPath owner, TypedPropertyPath next) { - return new ComposedPropertyPath<>(owner, next); + + if (owner instanceof ForwardingPropertyPath fwd) { + + List paths = fwd.stream().map(ForwardingPropertyPath::getSelf).collect(Collectors.toList()); + Collections.reverse(paths); + + ForwardingPropertyPath result = null; + for (PropertyPath path : paths) { + + if (result == null) { + result = new ForwardingPropertyPath((TypedPropertyPath) path, next); + } else { + result = new ForwardingPropertyPath((TypedPropertyPath) path, result); + } + } + + return result; + } + + return new ForwardingPropertyPath<>(owner, next); } /** @@ -63,7 +86,7 @@ public static TypedPropertyPath compose(TypedPropertyPath @SuppressWarnings({ "unchecked", "rawtypes" }) public static TypedPropertyPath of(TypedPropertyPath lambda) { - if (lambda instanceof ComposedPropertyPath || lambda instanceof ResolvedTypedPropertyPathSupport) { + if (lambda instanceof ForwardingPropertyPath || lambda instanceof ResolvedTypedPropertyPathSupport) { return lambda; } @@ -79,6 +102,7 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) /** * Retrieve {@link PropertyPathMetadata} for a given {@link TypedPropertyPath}. */ + @SuppressWarnings({ "unchecked", "rawtypes" }) public static TypedPropertyPath of(TypedPropertyPath delegate, PropertyPathMetadata metadata) { if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyPathMetadata kmp) { @@ -276,8 +300,8 @@ public boolean equals(@Nullable Object obj) { return false; } - return Objects.equals(this.toDotPath(), that.toDotPath()) - && Objects.equals(this.getOwningType(), that.getOwningType()); + return Objects.equals(this.getOwningType(), that.getOwningType()) + && Objects.equals(this.toDotPath(), that.toDotPath()); } @Override @@ -342,53 +366,62 @@ static class ResolvedKPropertyPath extends ResolvedTypedPropertyPathSuppor } /** - * A {@link TypedPropertyPath} that represents the composition of two property paths, enabling navigation through - * nested properties. + * Forwarding implementation to compose a linked {@link TypedPropertyPath} graph. * - * @param the root owning type. - * @param the intermediate property type (connecting first and second paths). - * @param the final property type. - * @param base the initial path segment. - * @param next the next path segment. - * @param dotPath the precomputed dot-notation path string. + * @param self + * @param nextSegment + * @param leaf cached leaf property. + * @param toStringRepresentation cached toString representation. */ - record ComposedPropertyPath(TypedPropertyPath base, TypedPropertyPath next, String dotPath, - String toStringRepresentation) implements TypedPropertyPath { + record ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment, + PropertyPath leaf, String dotPath, String toStringRepresentation) implements TypedPropertyPath { + + public ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment) { + this(self, nextSegment, nextSegment.getLeafProperty(), getDotPath(self, nextSegment), + getToString(self, nextSegment)); + } + + private static String getToString(PropertyPath self, PropertyPath nextSegment) { + return self.getOwningType().getType().getSimpleName() + "." + getDotPath(self, nextSegment); + } - ComposedPropertyPath(TypedPropertyPath first, TypedPropertyPath second) { - this(first, second, first.toDotPath() + "." + second.toDotPath(), - first.getType().getSimpleName() + "." + first.toDotPath() + "." + second.toDotPath()); + 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 R get(T obj) { - M intermediate = base.get(obj); - return intermediate != null ? next.get(intermediate) : null; + M intermediate = self.get(obj); + return intermediate != null ? nextSegment.get(intermediate) : null; } @Override public TypeInformation getOwningType() { - return base.getOwningType(); + return self.getOwningType(); } @Override public String getSegment() { - return base().getSegment(); + return self.getSegment(); } @Override public PropertyPath getLeafProperty() { - return next.getLeafProperty(); + return leaf; } @Override - public TypeInformation getTypeInformation() { - return base.getTypeInformation(); + public String toDotPath() { + return self.getSegment() + "." + nextSegment.toDotPath(); } @Override - public TypedPropertyPath next() { - return next; + public TypeInformation getTypeInformation() { + return self.getTypeInformation(); } @Override @@ -397,48 +430,33 @@ public boolean hasNext() { } @Override - public String toDotPath() { - return dotPath; - } - - @Override - public Stream stream() { - return Stream.concat(base.stream(), next.stream()); + public PropertyPath next() { + return nextSegment; } @Override public Iterator iterator() { + CompositeIterator iterator = new CompositeIterator<>(); - iterator.add(base.iterator()); - iterator.add(next.iterator()); + iterator.add(List.of((PropertyPath) this).iterator()); + iterator.add(nextSegment.iterator()); return iterator; } @Override - public boolean equals(Object obj) { - - if (obj == this) { - return true; - } - - if (!(obj instanceof PropertyPath that)) { - return false; - } - - return Objects.equals(this.toDotPath(), that.toDotPath()) - && Objects.equals(this.getOwningType(), that.getOwningType()); + public boolean equals(@Nullable Object o) { + return PropertyPathUtil.equals(this, o); } @Override public int hashCode() { - return toString().hashCode(); + return PropertyPathUtil.hashCode(this); } @Override public String toString() { return toStringRepresentation; } - } } diff --git a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt index b5b42e5621..7c8c802c4e 100644 --- a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt @@ -17,6 +17,8 @@ package org.springframework.data.core import kotlin.reflect.KProperty import kotlin.reflect.KProperty1 +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaGetter /** * Extension for [KProperty] providing an `toPath` function to render a [KProperty] in dot notation. @@ -34,7 +36,7 @@ fun KProperty<*>.toDotPath(): String = asString(this) */ fun TypedPropertyPath.then(next: KProperty1): TypedPropertyPath { val nextPath = KTypedPropertyPath.of(next) as TypedPropertyPath - return TypedPropertyPaths.ComposedPropertyPath(this, nextPath) + return TypedPropertyPaths.compose(this, nextPath) } /** @@ -44,7 +46,7 @@ fun TypedPropertyPath.then(next: KProperty1 TypedPropertyPath.then(next: KProperty): TypedPropertyPath { val nextPath = KTypedPropertyPath.of(next) as TypedPropertyPath - return TypedPropertyPaths.ComposedPropertyPath(this, nextPath) + return TypedPropertyPaths.compose(this, nextPath) } /** @@ -80,10 +82,14 @@ class KTypedPropertyPath { fun of(property: KProperty): TypedPropertyPath { if (property is KProperty1<*, *>) { + + val property1 = property as KProperty1<*, *>; + val owner = property1.javaField?.declaringClass + ?: property1.javaGetter?.declaringClass val metadata = TypedPropertyPaths.KPropertyPathMetadata.of( MemberDescriptor.KPropertyReferenceDescriptor.create( - String::class.java, - property as KProperty1<*, *> + owner, + property1 ) ) return TypedPropertyPaths.ResolvedKPropertyPath(metadata) @@ -96,7 +102,10 @@ class KTypedPropertyPath { val parent = of(paths.parent) val child = of(paths.child) - return TypedPropertyPaths.ComposedPropertyPath(parent, child) as TypedPropertyPath + return TypedPropertyPaths.compose( + parent, + child + ) as TypedPropertyPath } if (property is KIterablePropertyPath<*, *>) { @@ -106,7 +115,10 @@ class KTypedPropertyPath { val parent = of(paths.parent) val child = of(paths.child) - return TypedPropertyPaths.ComposedPropertyPath(parent, child) as TypedPropertyPath + return TypedPropertyPaths.compose( + parent, + child + ) as TypedPropertyPath } throw IllegalArgumentException("Property ${property.name} is not a KProperty") @@ -114,4 +126,4 @@ class KTypedPropertyPath { } -} \ No newline at end of file +} diff --git a/src/test/java/org/springframework/data/core/PropertyPathTck.java b/src/test/java/org/springframework/data/core/PropertyPathTck.java new file mode 100644 index 0000000000..1d811c71ee --- /dev/null +++ b/src/test/java/org/springframework/data/core/PropertyPathTck.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Iterator; + +/** + * TCK for {@link PropertyPath} implementations. + * + * @author Mark Paluch + */ +class PropertyPathTck { + + /** + * Verify that the given {@link PropertyPath} API behavior matches the expected one. + * + * @param actual + * @param expected + */ + static void verify(PropertyPath actual, PropertyPath expected) { + + assertThat(actual).hasToString(expected.toString()).hasSameHashCodeAs(expected).isEqualTo(expected); + + assertThat(actual.getSegment()).isEqualTo(expected.getSegment()); + assertThat(actual.getType()).isEqualTo(expected.getType()); + + assertThat(actual.getLeafProperty()).isEqualTo(expected.getLeafProperty()); + + assertThat(actual.hasNext()).isEqualTo(expected.hasNext()); + assertThat(actual.next()).isEqualTo(expected.next()); + + Iterator actualIterator = actual.iterator(); + Iterator expectedIterator = actual.iterator(); + + assertThat(actualIterator.hasNext()).isEqualTo(expectedIterator.hasNext()); + + assertThat(actualIterator.next()).isEqualTo(actual); + assertThat(expectedIterator.next()).isEqualTo(expected); + + while (actualIterator.hasNext() && expectedIterator.hasNext()) { + + verify(actualIterator.next(), expectedIterator.next()); + assertThat(actualIterator.hasNext()).isEqualTo(expectedIterator.hasNext()); + } + + while (actual != null && expected != null && actual.hasNext() && expected.hasNext()) { + + actual = actual.next(); + expected = expected.next(); + + verify(actual, expected); + } + } + +} diff --git a/src/test/java/org/springframework/data/core/PropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/PropertyPathUnitTests.java index 1144c2324c..83fbc85127 100755 --- a/src/test/java/org/springframework/data/core/PropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/PropertyPathUnitTests.java @@ -23,8 +23,12 @@ import java.util.Map; import java.util.Set; import java.util.regex.Pattern; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; /** * Unit tests for {@link PropertyPath}. @@ -453,6 +457,22 @@ void detectsNestedSingleCharacterProperty() { assertThat(from("category_B", Product.class).toDotPath()).isEqualTo("category.b"); } + @ParameterizedTest + @MethodSource("propertyPaths") + void verifyTck(PropertyPath actual, PropertyPath expected) { + PropertyPathTck.verify(actual, expected); + } + + static Stream propertyPaths() { + return Stream.of( + Arguments.argumentSet("Sample.userName", PropertyPath.from("userName", Sample.class), + PropertyPath.from("userName", Sample.class)), + Arguments.argumentSet("Sample.user.name", PropertyPath.from("user.name", Sample.class), + PropertyPath.from("user.name", Sample.class)), + Arguments.argumentSet("Sample.bar.user.name", PropertyPath.from("bar.user.name", Sample.class), + PropertyPath.from("bar.user.name", Sample.class))); + } + private class Foo { String userName; diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index 9c783df0b7..376d8386db 100644 --- a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -18,10 +18,15 @@ 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; /** @@ -31,27 +36,26 @@ */ class TypedPropertyPathUnitTests { - @Test - void meetsApiContract() { - - TypedPropertyPath typed = PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry); - - PropertyPath path = PropertyPath.from("address.country", PersonQuery.class); - - assertThat(typed.hasNext()).isTrue(); - assertThat(path.hasNext()).isTrue(); - - assertThat(typed.next().hasNext()).isFalse(); - assertThat(path.next().hasNext()).isFalse(); - - assertThat(typed.getType()).isEqualTo(Address.class); - assertThat(path.getType()).isEqualTo(Address.class); - - assertThat(typed.getSegment()).isEqualTo("address"); - assertThat(path.getSegment()).isEqualTo("address"); + @ParameterizedTest + @MethodSource("propertyPaths") + void verifyTck(TypedPropertyPath actual, PropertyPath expected) { + PropertyPathTck.verify(actual, expected); + } - assertThat(typed.getLeafProperty().getType()).isEqualTo(Country.class); - assertThat(path.getLeafProperty().getType()).isEqualTo(Country.class); + static Stream propertyPaths() { + return Stream.of( + Arguments.argumentSet("PersonQuery.name", PropertyPath.of(PersonQuery::getName), + PropertyPath.from("name", PersonQuery.class)), + Arguments.argumentSet("PersonQuery.address.country", + PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry), + PropertyPath.from("address.country", PersonQuery.class)), + Arguments.argumentSet("PersonQuery.address.country.name", + PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry).then(Country::name), + PropertyPath.from("address.country.name", PersonQuery.class)), + Arguments.argumentSet( + "PersonQuery.emergencyContact.address.country.name", PropertyPath.of(PersonQuery::getEmergencyContact) + .then(PersonQuery::getAddress).then(Address::getCountry).then(Country::name), + PropertyPath.from("emergencyContact.address.country.name", PersonQuery.class))); } @Test @@ -92,6 +96,7 @@ void resolvesInterfaceMethodReferenceGetter() { } @Test + @SuppressWarnings("Convert2MethodRef") void resolvesInterfaceLambdaGetter() { assertThat(PropertyPath.of((PersonProjection person) -> person.getName()).toDotPath()).isEqualTo("name"); } @@ -108,12 +113,13 @@ void resolvesSuperclassLambdaGetter() { @Test void resolvesPrivateMethodReference() { - assertThat(PropertyPath.of(Address::getSecret).toDotPath()).isEqualTo("secret"); + assertThat(PropertyPath.of(Secret::getSecret).toDotPath()).isEqualTo("secret"); } @Test + @SuppressWarnings("Convert2MethodRef") void resolvesPrivateMethodLambda() { - assertThat(PropertyPath.of((Address address) -> address.getSecret()).toDotPath()).isEqualTo("secret"); + assertThat(PropertyPath.of((Secret secret) -> secret.getSecret()).toDotPath()).isEqualTo("secret"); } @Test @@ -151,6 +157,7 @@ void returningSomethingShouldFail() { } @Test + @SuppressWarnings("Convert2Lambda") void classImplementationShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) @@ -226,7 +233,8 @@ void resolvesSuperclassMethodReferenceGetter() { // Domain entities - static public class SuperClass { + static class SuperClass { + private int tenant; public int getTenant() { @@ -238,10 +246,11 @@ public void setTenant(int tenant) { } } - static public class PersonQuery extends SuperClass { + static class PersonQuery extends SuperClass { private String name; private Integer age; + private PersonQuery emergencyContact; private Address address; private List
addresses; @@ -258,6 +267,14 @@ public Integer getAge() { return age; } + public PersonQuery getEmergencyContact() { + return emergencyContact; + } + + public void setEmergencyContact(PersonQuery emergencyContact) { + this.emergencyContact = emergencyContact; + } + public Address getAddress() { return address; } @@ -271,7 +288,7 @@ public void setAddresses(List
addresses) { } } - class Address { + static class Address { String street; String city; @@ -304,6 +321,15 @@ record Country(String name, String code) { } + static class Secret { + private String secret; + + private String getSecret() { + return secret; + } + + } + interface PersonProjection { String getName(); diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt index 676af1e981..3976c4dffc 100644 --- a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -2,7 +2,12 @@ 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 org.springframework.data.domain.Sort +import java.util.stream.Stream /** * Unit tests for [KPropertyPath] and related functionality. @@ -11,6 +16,45 @@ import org.springframework.data.domain.Sort */ class KTypedPropertyPathUnitTests { + @ParameterizedTest + @MethodSource("propertyPaths") + fun verifyTck(actual: TypedPropertyPath<*, *>?, expected: PropertyPath) { + PropertyPathTck.verify(actual, expected) + } + + companion object { + + @JvmStatic + fun propertyPaths(): Stream { + + return Stream.of( + Arguments.argumentSet( + "Person.name", + KTypedPropertyPath.of(Person::name), + PropertyPath.from("name", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country", + KTypedPropertyPath.of(Person::address / Address::country), + PropertyPath.from("address.country", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country.name", + KTypedPropertyPath.of(Person::address / Address::country / Country::name), + PropertyPath.from("address.country.name", Person::class.java) + ), + Arguments.argumentSet( + "Person.emergencyContact.address.country.name", + KTypedPropertyPath.of(Person::emergencyContact / Person::address / Address::country / Country::name), + PropertyPath.from( + "emergencyContact.address.country.name", + Person::class.java + ) + ) + ) + } + } + @Test fun shouldCreatePropertyPath() { @@ -48,4 +92,19 @@ class KTypedPropertyPathUnitTests { class Book(val title: String, val author: Author) class Author(val name: String, val books: List) -} \ No newline at end of file + + 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) +} diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt index 8a4f276ead..d29f4e234f 100644 --- a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt @@ -2,6 +2,11 @@ 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. @@ -10,6 +15,49 @@ import org.junit.jupiter.api.Test */ class TypedPropertyPathKtUnitTests { + @ParameterizedTest + @MethodSource("propertyPaths") + fun verifyTck(actual: TypedPropertyPath<*, *>?, expected: PropertyPath) { + PropertyPathTck.verify(actual, expected) + } + + companion object { + + @JvmStatic + fun propertyPaths(): Stream { + + return Stream.of( + Arguments.argumentSet( + "Person.name", + TypedPropertyPath.of(Person::name), + PropertyPath.from("name", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country", + TypedPropertyPath.of(Person::address) + .then(Address::country), + PropertyPath.from("address.country", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country.name", + TypedPropertyPath.of(Person::address) + .then(Address::country).then(Country::name), + PropertyPath.from("address.country.name", Person::class.java) + ), + Arguments.argumentSet( + "Person.emergencyContact.address.country.name", + TypedPropertyPath.of(Person::emergencyContact) + .then
(Person::address).then(Address::country) + .then(Country::name), + PropertyPath.from( + "emergencyContact.address.country.name", + Person::class.java + ) + ) + ) + } + } + @Test fun shouldSupportPropertyReference() { assertThat(TypedPropertyPath.of(Person::address).toDotPath()).isEqualTo("address") @@ -36,10 +84,10 @@ class TypedPropertyPathKtUnitTests { } class Person { - var firstname: String? = null - var lastname: String? = null + var name: String? = null var age: Int = 0 var address: Address? = null + var emergencyContact: Person? = null } class Address { @@ -49,4 +97,4 @@ class TypedPropertyPathKtUnitTests { } data class Country(val name: String) -} \ No newline at end of file +} From a731d5567789a157152f364430d28fa0daac5de9 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 4 Nov 2025 13:53:09 +0100 Subject: [PATCH 14/25] Refine Kotlin syntax. --- .../modules/ROOT/pages/property-paths.adoc | 25 +++-- .../data/core/TypedPropertyPath.java | 2 +- .../data/core/TypedPropertyPaths.java | 10 +- ...PropertyPath.kt => KPropertyExtensions.kt} | 59 +++------- .../data/core/KPropertyReference.kt | 104 ++++++++++++++++++ .../data/core/TypedPropertyPathExtensions.kt | 65 ++++------- .../data/mapping/KPropertyPath.kt | 2 + .../data/mapping/KPropertyPathExtensions.kt | 3 +- .../data/core/TypedPropertyPathUnitTests.java | 28 ++--- ...thTests.kt => KPropertyExtensionsTests.kt} | 60 +++++++++- .../data/core/KTypedPropertyPathUnitTests.kt | 42 +++---- .../data/core/TypedPropertyPathKtUnitTests.kt | 4 +- 12 files changed, 258 insertions(+), 146 deletions(-) rename src/main/kotlin/org/springframework/data/core/{KPropertyPath.kt => KPropertyExtensions.kt} (53%) create mode 100644 src/main/kotlin/org/springframework/data/core/KPropertyReference.kt rename src/test/kotlin/org/springframework/data/core/{KPropertyPathTests.kt => KPropertyExtensionsTests.kt} (65%) diff --git a/src/main/antora/modules/ROOT/pages/property-paths.adoc b/src/main/antora/modules/ROOT/pages/property-paths.adoc index 734d58d61d..4cd407608e 100644 --- a/src/main/antora/modules/ROOT/pages/property-paths.adoc +++ b/src/main/antora/modules/ROOT/pages/property-paths.adoc @@ -32,14 +32,14 @@ class Person { int age; Address address; List
previousAddresses; - + String getFirstname() { … }; // other property accessors omitted for brevity } class Address { String city, street; - + // accessors omitted for brevity } @@ -139,11 +139,13 @@ Kotlin:: + [source,kotlin,role="secondary"] ---- -TypedPropertyPath.of(Person::address) - .then(Address::city) - -// Kotlin Exension +// Kotlin API KTypedPropertyPath.of(Person::address).then(Address::city) + +// Extension for KProperty1 +KTypedPropertyPath.of(Person::address / Address::city) + +(Person::address / Address::city).toPath() ---- ====== @@ -163,8 +165,8 @@ Java:: Sort.by(Person::getFirstName, Person::getLastName); // Composed navigation -TypedPropertyPath.of(Person::getAddress) - .then(Address::getCity); +Sort.by(TypedPropertyPath.of(Person::getAddress).then(Address::getCity), + Person::getLastName); ---- Kotlin:: @@ -172,11 +174,10 @@ Kotlin:: [source,kotlin,role="secondary"] ---- // Inline usage with Sort -Sort.by(Person::firstName, Person::lastName); +Sort.by(Person::firstName, Person::lastName) -// Kotlin extension with composed navigation -KTypedPropertyPath.of(Person::address) - .then(Address::city) +// Composed navigation +Sort.by((Person::address / Address::city).toPath(), Person::lastName) ---- ====== diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index a2b3155166..69bcbfd276 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -63,7 +63,7 @@ * @see #then(TypedPropertyPath) */ @FunctionalInterface -public interface TypedPropertyPath extends PropertyPath, Serializable { +public interface TypedPropertyPath extends PropertyPath, Serializable { /** * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda. diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index 20363fbf8e..d24172f54c 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -57,7 +57,7 @@ class TypedPropertyPaths { * Compose a {@link TypedPropertyPath} by appending {@code next}. */ @SuppressWarnings({ "rawtypes", "unchecked" }) - public static TypedPropertyPath compose(TypedPropertyPath owner, TypedPropertyPath next) { + public static TypedPropertyPath compose(TypedPropertyPath owner, TypedPropertyPath next) { if (owner instanceof ForwardingPropertyPath fwd) { @@ -373,10 +373,10 @@ static class ResolvedKPropertyPath extends ResolvedTypedPropertyPathSuppor * @param leaf cached leaf property. * @param toStringRepresentation cached toString representation. */ - record ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment, - PropertyPath leaf, String dotPath, String toStringRepresentation) implements TypedPropertyPath { + record ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment, + PropertyPath leaf, String dotPath, String toStringRepresentation) implements TypedPropertyPath { - public ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment) { + public ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment) { this(self, nextSegment, nextSegment.getLeafProperty(), getDotPath(self, nextSegment), getToString(self, nextSegment)); } @@ -394,7 +394,7 @@ public static PropertyPath getSelf(PropertyPath path) { } @Override - public @Nullable R get(T obj) { + public @Nullable P get(T obj) { M intermediate = self.get(obj); return intermediate != null ? nextSegment.get(intermediate) : null; } diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt similarity index 53% rename from src/main/kotlin/org/springframework/data/core/KPropertyPath.kt rename to src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt index 6b49520e2f..dec5e19b80 100644 --- a/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt +++ b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt @@ -18,51 +18,28 @@ package org.springframework.data.core import kotlin.reflect.KProperty import kotlin.reflect.KProperty1 + /** - * Abstraction of a property path consisting of [KProperty]. + * Extension for [KProperty] providing an `toPath` function to render a [KProperty] in dot notation. * - * @author Tjeu Kayim * @author Mark Paluch - * @author Yoann de Martino * @since 2.5 + * @see org.springframework.data.core.PropertyPath.toDotPath */ -internal class KPropertyPath( - val parent: KProperty, - val child: KProperty1 -) : KProperty by child +fun KProperty<*>.toDotPath(): String = asString(this) /** - * 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. + * Extension for [KProperty1] providing an `toPath` function to create a [TypedPropertyPath]. * - * @author Mikhail Polivakha - * @since 3.5 - */ -internal class KIterablePropertyPath( - val parent: KProperty?>, - val child: KProperty1 -) : KProperty by child - -/** - * Recursively construct field name for a nested property. - * @author Tjeu Kayim - * @author Mikhail Polivakha + * @author Mark Paluch + * @since 4.1 + * @see org.springframework.data.core.PropertyPath.toDotPath */ -fun asString(property: KProperty<*>): String { - return when (property) { - is KPropertyPath<*, *> -> - "${asString(property.parent)}.${property.child.name}" - - is KIterablePropertyPath<*, *> -> - "${asString(property.parent)}.${property.child.name}" - - else -> property.name - } -} +fun KProperty1.toPath(): TypedPropertyPath = + KTypedPropertyPath.of(this) /** - * Builds [KPropertyPath] from Property References. + * Builds [KPropertyReference] from Property References. * Refer to a nested property in an embeddable or association. * * For example, referring to the field "author.name": @@ -71,14 +48,14 @@ fun asString(property: KProperty<*>): String { * ``` * @author Tjeu Kayim * @author Yoann de Martino - * @since 2.5 + * @since 4.1 */ @JvmName("div") -operator fun KProperty.div(other: KProperty1): KProperty = - KPropertyPath(this, other) +operator fun KProperty1.div(other: KProperty1): KProperty1 = + KSinglePropertyReference(this, other) as KProperty1 /** - * Builds [KPropertyPath] from Property References. + * Builds [KPropertyReference] from Property References. * Refer to a nested property in an embeddable or association. * * Note, that this function is different from [div] above in the @@ -91,8 +68,8 @@ operator fun KProperty.div(other: KProperty1): KProperty = * Author::books / Book::title contains "Bartleby" * ``` * @author Mikhail Polivakha - * @since 3.5 + * @since 4.1 */ @JvmName("divIterable") -operator fun KProperty?>.div(other: KProperty1): KProperty = - KIterablePropertyPath(this, other) +operator fun KProperty1?>.div(other: KProperty1): KProperty1 = + KIterablePropertyReference(this, other) as KProperty1 diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyReference.kt b/src/main/kotlin/org/springframework/data/core/KPropertyReference.kt new file mode 100644 index 0000000000..93a26cde21 --- /dev/null +++ b/src/main/kotlin/org/springframework/data/core/KPropertyReference.kt @@ -0,0 +1,104 @@ +/* + * 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 KPropertyReference : KProperty1 { + val property: KProperty1 + val leaf: KProperty1<*, P> +} + +internal class KSinglePropertyReference( + val parent: KProperty1, + val child: KProperty1 +) : KProperty1 by child as KProperty1, KPropertyReference { + + override fun get(receiver: T): P { + + val get = parent.get(receiver) + + if (get != null) { + return child.get(get) + } + + throw NullPointerException("Parent property returned null") + } + + override fun getDelegate(receiver: T): Any { + return child + } + + override val property: KProperty1 + get() = parent + override val leaf: KProperty1<*, P> + get() = child +} + +/** + * Abstraction of a property path that consists of parent [KProperty], + * and child property [KProperty], where parent [parent] has an [Iterable] + * of children, so it represents 1-M mapping. + * + * @author Mikhail Polivakha + * @since 4.1 + */ + +internal class KIterablePropertyReference( + val parent: KProperty1?>, + val child: KProperty1 +) : KProperty1 by child as KProperty1, KPropertyReference { + + override fun get(receiver: T): P { + throw UnsupportedOperationException("Collection retrieval not supported") + } + + override fun getDelegate(receiver: T): Any { + throw UnsupportedOperationException("Collection retrieval not supported") + } + + override val property: KProperty1 + get() = parent + override val leaf: KProperty1<*, P> + get() = child +} + + +/** + * Recursively construct field name for a nested property. + * @author Tjeu Kayim + * @author Mikhail Polivakha + */ +internal fun asString(property: KProperty<*>): String { + return when (property) { + is KPropertyReference<*, *> -> + "${asString(property.property)}.${property.leaf.name}" + + else -> property.name + } +} + diff --git a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt index 7c8c802c4e..009f3d8c7f 100644 --- a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,6 +13,11 @@ * 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 @@ -20,21 +25,12 @@ import kotlin.reflect.KProperty1 import kotlin.reflect.jvm.javaField import kotlin.reflect.jvm.javaGetter -/** - * 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 function to compose a [TypedPropertyPath] with a [KProperty1]. * * @since 4.1 */ -fun TypedPropertyPath.then(next: KProperty1): TypedPropertyPath { +fun TypedPropertyPath.then(next: KProperty1): TypedPropertyPath { val nextPath = KTypedPropertyPath.of(next) as TypedPropertyPath return TypedPropertyPaths.compose(this, nextPath) } @@ -44,7 +40,7 @@ fun TypedPropertyPath.then(next: KProperty1 TypedPropertyPath.then(next: KProperty): TypedPropertyPath { +fun TypedPropertyPath.then(next: KProperty): TypedPropertyPath { val nextPath = KTypedPropertyPath.of(next) as TypedPropertyPath return TypedPropertyPaths.compose(this, nextPath) } @@ -72,18 +68,31 @@ class KTypedPropertyPath { * Create a [TypedPropertyPath] from a collection-like [KProperty1]. */ @JvmName("ofMany") - fun of(property: KProperty1>): TypedPropertyPath { + fun of(property: KProperty1?>): TypedPropertyPath { return of((property as KProperty)) } /** * Create a [TypedPropertyPath] from a [KProperty]. */ - fun of(property: KProperty): TypedPropertyPath { + fun of(property: KProperty): TypedPropertyPath { + + if (property is KPropertyReference<*, *>) { + + val paths = property as KPropertyReference<*, *> + + val parent = of(paths.property) + val child = of(paths.leaf) + + return TypedPropertyPaths.compose( + parent, + child + ) as TypedPropertyPath + } if (property is KProperty1<*, *>) { - val property1 = property as KProperty1<*, *>; + val property1 = property as KProperty1<*, *> val owner = property1.javaField?.declaringClass ?: property1.javaGetter?.declaringClass val metadata = TypedPropertyPaths.KPropertyPathMetadata.of( @@ -95,32 +104,6 @@ class KTypedPropertyPath { return TypedPropertyPaths.ResolvedKPropertyPath(metadata) } - if (property is KPropertyPath<*, *>) { - - val paths = property as KPropertyPath<*, *> - - val parent = of(paths.parent) - val child = of(paths.child) - - return TypedPropertyPaths.compose( - parent, - child - ) as TypedPropertyPath - } - - if (property is KIterablePropertyPath<*, *>) { - - val paths = property as KIterablePropertyPath<*, *> - - val parent = of(paths.parent) - val child = of(paths.child) - - return TypedPropertyPaths.compose( - parent, - child - ) as TypedPropertyPath - } - throw IllegalArgumentException("Property ${property.name} is not a KProperty") } diff --git a/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt b/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt index b8a94b80ad..1707918fc6 100644 --- a/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt +++ b/src/main/kotlin/org/springframework/data/mapping/KPropertyPath.kt @@ -72,6 +72,7 @@ fun asString(property: KProperty<*>): String { * @since 2.5 */ @JvmName("div") +@Deprecated("since 4.1, use the org.springframework.data.core extensions instead") operator fun KProperty.div(other: KProperty1): KProperty = KPropertyPath(this, other) @@ -92,5 +93,6 @@ operator fun KProperty.div(other: KProperty1): KProperty = * @since 3.5 */ @JvmName("divIterable") +@Deprecated("since 4.1, use the org.springframework.data.core extensions instead") operator fun KProperty?>.div(other: KProperty1): KProperty = KIterablePropertyPath(this, other) diff --git a/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt index b70249a27a..2eec08497d 100644 --- a/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt +++ b/src/main/kotlin/org/springframework/data/mapping/KPropertyPathExtensions.kt @@ -24,4 +24,5 @@ import kotlin.reflect.KProperty * @since 2.5 * @see org.springframework.data.core.PropertyPath.toDotPath */ -fun KProperty<*>.toDotPath(): String = asString(this) \ No newline at end of file +@Deprecated("since 4.1, use the org.springframework.data.core extensions instead") +fun KProperty<*>.toDotPath(): String = asString(this) diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index 376d8386db..cba821cf81 100644 --- a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -43,6 +43,7 @@ void verifyTck(TypedPropertyPath actual, PropertyPath expected) { } static Stream propertyPaths() { + return Stream.of( Arguments.argumentSet("PersonQuery.name", PropertyPath.of(PersonQuery::getName), PropertyPath.from("name", PersonQuery.class)), @@ -76,11 +77,13 @@ void resolvesCollectionPath() { } @Test + @SuppressWarnings("Convert2MethodRef") void resolvesInitialLambdaGetter() { assertThat(PropertyPath.of((PersonQuery person) -> person.getName()).toDotPath()).isEqualTo("name"); } @Test + @SuppressWarnings("Convert2MethodRef") void resolvesComposedLambdaGetter() { assertThat(PropertyPath.of(PersonQuery::getAddress).then(it -> it.getCity()).toDotPath()).isEqualTo("address.city"); } @@ -220,6 +223,7 @@ void failsResolvingCallingLocalMethod() { class NestedTestClass { @Test + @SuppressWarnings("Convert2MethodRef") void resolvesInterfaceLambdaGetter() { assertThat(PropertyPath.of((PersonProjection person) -> person.getName()).toDotPath()).isEqualTo("name"); } @@ -249,7 +253,7 @@ public void setTenant(int tenant) { static class PersonQuery extends SuperClass { private String name; - private Integer age; + private @Nullable Integer age; private PersonQuery emergencyContact; private Address address; private List
addresses; @@ -263,7 +267,7 @@ public String getName() { return name; } - public Integer getAge() { + public @Nullable Integer getAge() { return age; } @@ -322,6 +326,7 @@ record Country(String name, String code) { } static class Secret { + private String secret; private String getSecret() { @@ -335,25 +340,6 @@ interface PersonProjection { String getName(); } - static class Criteria { - - public Criteria(String key) { - - } - - public static Criteria where(String key) { - return new Criteria(key); - } - - public static Criteria where(PropertyPath propertyPath) { - return new Criteria(propertyPath.toDotPath()); - } - - public static Criteria where(TypedPropertyPath propertyPath) { - return new Criteria(propertyPath.toDotPath()); - } - } - enum NotSupported implements TypedPropertyPath { INSTANCE; diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyPathTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt similarity index 65% rename from src/test/kotlin/org/springframework/data/core/KPropertyPathTests.kt rename to src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt index c2ab2d2d0a..f74135f25c 100644 --- a/src/test/kotlin/org/springframework/data/core/KPropertyPathTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt @@ -17,16 +17,52 @@ 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 [KPropertyPath] and its extensions. + * Unit tests for [kotlin.reflect.KProperty] extensions. * * @author Tjeu Kayim * @author Yoann de Martino * @author Mark Paluch * @author Mikhail Polivakha */ -class KPropertyPathTests { +class KPropertyExtensionsTests { + + @ParameterizedTest + @MethodSource("propertyPaths") + fun verifyTck(actual: TypedPropertyPath<*, *>, expected: PropertyPath) { + PropertyPathTck.verify(actual, expected) + } + + companion object { + + @JvmStatic + fun propertyPaths(): Stream { + + return Stream.of( + Arguments.argumentSet( + "Person.name (toPath)", + Person::name.toPath(), + PropertyPath.from("name", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country.name (toPath)", + (Person::address / Address::country / Country::name).toPath(), + PropertyPath.from("address.country.name", Person::class.java) + ), + Arguments.argumentSet( + "Person.addresses.country.name (toPath)", + (Person::addresses / Address::country / Country::name).toPath(), + PropertyPath.from("addresses.country.name", Person::class.java) + ) + ) + } + } @Test // DATACMNS-1835 fun `Convert normal KProperty to field name`() { @@ -76,7 +112,8 @@ class KPropertyPathTests { class Entity(val book: Book) class AnotherEntity(val entity: Entity) - val property = asString(AnotherEntity::entity / Entity::book / Book::author / Author::name) + val property = + (AnotherEntity::entity / Entity::book / Book::author / Author::name).toDotPath() assertThat(property).isEqualTo("entity.book.author.name") } @@ -117,11 +154,26 @@ class KPropertyPathTests { class Cat(val name: String?) class Owner(val cat: Cat?) - val property = asString(Owner::cat / Cat::name) + val property = (Owner::cat / Cat::name).toDotPath() assertThat(property).isEqualTo("cat.name") } class Book(val title: String, val author: Author) class Author(val name: String, val books: List) + class Person { + var name: String? = null + var age: Int = 0 + var address: Address? = null + var addresses: List
= emptyList() + } + + class Address { + var city: String? = null + var street: String? = null + var country: Country? = null + } + + data class Country(val name: String) + } diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt index 3976c4dffc..298a6cb5f3 100644 --- a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -6,11 +6,10 @@ 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 org.springframework.data.domain.Sort import java.util.stream.Stream /** - * Unit tests for [KPropertyPath] and related functionality. + * Unit tests for [KPropertyReference] and related functionality. * * @author Mark Paluch */ @@ -18,7 +17,7 @@ class KTypedPropertyPathUnitTests { @ParameterizedTest @MethodSource("propertyPaths") - fun verifyTck(actual: TypedPropertyPath<*, *>?, expected: PropertyPath) { + fun verifyTck(actual: TypedPropertyPath<*, *>, expected: PropertyPath) { PropertyPathTck.verify(actual, expected) } @@ -33,19 +32,29 @@ class KTypedPropertyPathUnitTests { KTypedPropertyPath.of(Person::name), PropertyPath.from("name", Person::class.java) ), + Arguments.argumentSet( + "Person.name (toPath)", + Person::name.toPath(), + PropertyPath.from("name", Person::class.java) + ), Arguments.argumentSet( "Person.address.country", - KTypedPropertyPath.of(Person::address / Address::country), + KTypedPropertyPath.of(Person::address / Address::country), PropertyPath.from("address.country", Person::class.java) ), Arguments.argumentSet( "Person.address.country.name", - KTypedPropertyPath.of(Person::address / Address::country / Country::name), + KTypedPropertyPath.of(Person::address / Address::country / Country::name), + PropertyPath.from("address.country.name", Person::class.java) + ), + Arguments.argumentSet( + "Person.address.country.name (toPath)", + (Person::address / Address::country / Country::name).toPath(), PropertyPath.from("address.country.name", Person::class.java) ), Arguments.argumentSet( "Person.emergencyContact.address.country.name", - KTypedPropertyPath.of(Person::emergencyContact / Person::address / Address::country / Country::name), + KTypedPropertyPath.of(Person::emergencyContact / Person::address / Address::country / Country::name), PropertyPath.from( "emergencyContact.address.country.name", Person::class.java @@ -58,45 +67,40 @@ class KTypedPropertyPathUnitTests { @Test fun shouldCreatePropertyPath() { - val path = KTypedPropertyPath.of(Author::name) + val path = KTypedPropertyPath.of(Person::name) assertThat(path.toDotPath()).isEqualTo("name") - - Sort.by(Book::author, Book::title); } @Test fun shouldComposePropertyPath() { - val path = KTypedPropertyPath.of(Book::author).then(Author::name) + val path = KTypedPropertyPath.of(Person::address).then(Address::city) - assertThat(path.toDotPath()).isEqualTo("author.name") + assertThat(path.toDotPath()).isEqualTo("address.city") } @Test fun shouldComposeManyPropertyPath() { - val path = KTypedPropertyPath.of(Author::books).then(Book::title) + val path = KTypedPropertyPath.of(Person::addresses).then(Address::city) - assertThat(path.toDotPath()).isEqualTo("books.title") + assertThat(path.toDotPath()).isEqualTo("addresses.city") } @Test fun shouldCreateComposed() { - val path = KTypedPropertyPath.of(Book::author / Author::name) + val path = KTypedPropertyPath.of(Person::address / Address::city) - assertThat(path.toDotPath()).isEqualTo("author.name") + assertThat(path.toDotPath()).isEqualTo("address.city") } - class Book(val title: String, val author: Author) - class Author(val name: String, val books: List) - - class Person { var name: String? = null var age: Int = 0 var address: Address? = null + var addresses: List
= emptyList() var emergencyContact: Person? = null } diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt index d29f4e234f..72f0489a87 100644 --- a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt @@ -73,7 +73,8 @@ class TypedPropertyPathKtUnitTests { @Test fun shouldSupportPropertyLambda() { assertThat(TypedPropertyPath.of { it.address }.toDotPath()).isEqualTo("address") - assertThat(TypedPropertyPath.of { it -> it.address }.toDotPath()).isEqualTo("address") + assertThat(TypedPropertyPath.of { foo -> foo.address } + .toDotPath()).isEqualTo("address") } @Test @@ -97,4 +98,5 @@ class TypedPropertyPathKtUnitTests { } data class Country(val name: String) + } From df02708690e6407579a4c1a39454b90d00a69404 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Sat, 15 Nov 2025 17:31:07 +0100 Subject: [PATCH 15/25] Refactor TypedPropertyPath into path and PropertyReference. Allow supporting API that requires operations on a single property and not a property path. --- .../core/TypedPropertyPathBenchmarks.java | 13 +- .../data/core/PropertyPath.java | 6 +- .../data/core/PropertyReference.java | 180 ++++++++++ .../data/core/PropertyReferences.java | 317 ++++++++++++++++++ .../data/core/TypedPropertyPath.java | 30 +- .../data/core/TypedPropertyPaths.java | 140 +++++++- .../data/core/KPropertyExtensions.kt | 4 +- ...Reference.kt => KPropertyReferenceImpl.kt} | 8 +- .../data/core/PropertyReferenceExtensions.kt | 100 ++++++ .../data/core/TypedPropertyPathExtensions.kt | 4 +- .../data/core/PropertyReferenceUnitTests.java | 316 +++++++++++++++++ .../data/core/TypedPropertyPathUnitTests.java | 11 +- .../data/domain/SortUnitTests.java | 4 +- .../data/core/KPropertyReferenceUnitTests.kt | 60 ++++ .../data/core/KTypedPropertyPathUnitTests.kt | 2 +- .../data/core/TypedPropertyPathKtUnitTests.kt | 18 +- 16 files changed, 1163 insertions(+), 50 deletions(-) create mode 100644 src/main/java/org/springframework/data/core/PropertyReference.java create mode 100644 src/main/java/org/springframework/data/core/PropertyReferences.java rename src/main/kotlin/org/springframework/data/core/{KPropertyReference.kt => KPropertyReferenceImpl.kt} (94%) create mode 100644 src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt create mode 100644 src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java create mode 100644 src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt diff --git a/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java index a875b2a613..8dc57aaccd 100644 --- a/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java +++ b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java @@ -17,7 +17,6 @@ import org.junit.platform.commons.annotation.Testable; import org.openjdk.jmh.annotations.Benchmark; - import org.springframework.data.BenchmarkSettings; /** @@ -30,32 +29,32 @@ public class TypedPropertyPathBenchmarks extends BenchmarkSettings { @Benchmark public Object benchmarkMethodReference() { - return TypedPropertyPath.of(Person::firstName); + return TypedPropertyPath.ofReference(Person::firstName); } @Benchmark public Object benchmarkComposedMethodReference() { - return TypedPropertyPath.of(Person::address).then(Address::city); + return TypedPropertyPath.ofReference(Person::address).then(Address::city); } @Benchmark public TypedPropertyPath benchmarkLambda() { - return TypedPropertyPath.of(person -> person.firstName()); + return TypedPropertyPath.ofReference(person -> person.firstName()); } @Benchmark public TypedPropertyPath benchmarkComposedLambda() { - return TypedPropertyPath.of((Person person) -> person.address()).then(address -> address.city()); + return TypedPropertyPath.ofReference((Person person) -> person.address()).then(address -> address.city()); } @Benchmark public Object dotPath() { - return TypedPropertyPath.of(Person::firstName).toDotPath(); + return TypedPropertyPath.ofReference(Person::firstName).toDotPath(); } @Benchmark public Object composedDotPath() { - return TypedPropertyPath.of(Person::address).then(Address::city).toDotPath(); + return TypedPropertyPath.ofReference(Person::address).then(Address::city).toDotPath(); } record Person(String firstName, String lastName, Address address) { diff --git a/src/main/java/org/springframework/data/core/PropertyPath.java b/src/main/java/org/springframework/data/core/PropertyPath.java index 892133d476..eff9d76455 100644 --- a/src/main/java/org/springframework/data/core/PropertyPath.java +++ b/src/main/java/org/springframework/data/core/PropertyPath.java @@ -30,6 +30,8 @@ * @author Mark Paluch * @author Mariusz Mączkowski * @author Johannes Englmeier + * @see PropertyReference + * @see TypedPropertyPath */ public interface PropertyPath extends Streamable { @@ -44,7 +46,7 @@ public interface PropertyPath extends Streamable { * @return the typed property path. * @since 4.1 */ - static TypedPropertyPath of(TypedPropertyPath propertyPath) { + static TypedPropertyPath of(PropertyReference propertyPath) { return TypedPropertyPaths.of(propertyPath); } @@ -60,7 +62,7 @@ static TypedPropertyPath of(TypedPropertyPath propertyPath) { * @since 4.1 */ @SuppressWarnings({ "unchecked", "rawtypes" }) - static TypedPropertyPath ofMany(TypedPropertyPath> propertyPath) { + static TypedPropertyPath ofMany(PropertyReference> propertyPath) { return (TypedPropertyPath) TypedPropertyPaths.of(propertyPath); } diff --git a/src/main/java/org/springframework/data/core/PropertyReference.java b/src/main/java/org/springframework/data/core/PropertyReference.java new file mode 100644 index 0000000000..c85b363c86 --- /dev/null +++ b/src/main/java/org/springframework/data/core/PropertyReference.java @@ -0,0 +1,180 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import java.io.Serializable; + +import org.jspecify.annotations.Nullable; + +/** + * Interface providing type-safe property references. + *

+ * This functional interface is typically implemented through method references or lambda expressions 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 TypedPropertyReference} leverages Java's declarative method references and lambda + * expressions to ensure type-safe property access. + *

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

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

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

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

the property value type. + * @author Mark Paluch + * @since 4.1 + * @see #then(PropertyReference) + * @see TypedPropertyPath + */ +@FunctionalInterface +public interface PropertyReference extends Serializable { + + /** + * Syntax sugar to create a {@link PropertyReference} from a method reference or lambda. + *

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

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

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

property type. + * @return the typed property reference. + */ + static PropertyReference ofMany(PropertyReference> property) { + return (PropertyReference) PropertyReferences.of(property); + } + + /** + * Get the property value for the given object. + * + * @param obj the object to get the property value from. + * @return the property value. + */ + @Nullable + P get(T obj); + + /** + * Returns the owning type of the referenced property.. + * + * @return the owningType will never be {@literal null}. + */ + default TypeInformation getOwningType() { + return PropertyReferences.getMetadata(this).owner(); + } + + /** + * Returns the name of the property. + * + * @return the current property name. + */ + default String getName() { + return PropertyReferences.getMetadata(this).property(); + } + + /** + * 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.getMetadata(this).propertyType(); + } + + /** + * 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 or lambda accepting the owner object {@code P} type + * and returning {@code N} as result of accessing a property. + * @param the new property value type. + * @return a new composed {@code TypedPropertyPath}. + */ + default TypedPropertyPath then(PropertyReference next) { + return TypedPropertyPaths.compose(this, next); + } + + /** + * Extend the property 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 or lambda accepting the owner object {@code P} type + * and returning {@code N} as result of accessing a property. + * @param the new property value type. + * @return a new composed {@code TypedPropertyPath}. + */ + default TypedPropertyPath thenMany( + PropertyReference> next) { + return (TypedPropertyPath) TypedPropertyPaths.compose(this, next); + } + +} diff --git a/src/main/java/org/springframework/data/core/PropertyReferences.java b/src/main/java/org/springframework/data/core/PropertyReferences.java new file mode 100644 index 0000000000..67174be6c3 --- /dev/null +++ b/src/main/java/org/springframework/data/core/PropertyReferences.java @@ -0,0 +1,317 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import kotlin.reflect.KProperty; + +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.util.Map; +import java.util.Objects; +import java.util.WeakHashMap; + +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.KPropertyReferenceDescriptor; +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> lambdas = new WeakHashMap<>(); + private static final Map, ResolvedPropertyReference>> resolved = new WeakHashMap<>(); + + private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyReference.class, + TypedPropertyPath.class, TypedPropertyPaths.class, PropertyReferences.class); + + /** + * Introspect {@link PropertyReference} and return an introspected {@link ResolvedPropertyReference} variant. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static PropertyReference of(PropertyReference lambda) { + + if (lambda instanceof ResolvedPropertyReferenceSupport) { + return lambda; + } + + Map, ResolvedPropertyReference> cache; + synchronized (resolved) { + cache = resolved.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); + } + + return (PropertyReference) cache.computeIfAbsent(lambda, + o -> new ResolvedPropertyReference(o, getMetadata(lambda))); + } + + /** + * Retrieve {@link PropertyReferenceMetadata} for a given {@link PropertyReference}. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static PropertyReference of(PropertyReference delegate, + PropertyReferenceMetadata metadata) { + + if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyReferenceMetadata kmp) { + return new ResolvedKPropertyReference(kmp.getProperty(), metadata); + } + + return new ResolvedPropertyReference<>(delegate, metadata); + } + + /** + * Retrieve {@link PropertyReferenceMetadata} for a given {@link PropertyReference}. + */ + public static PropertyReferenceMetadata getMetadata(PropertyReference lambda) { + + Map cache; + synchronized (lambdas) { + cache = lambdas.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); + } + + return cache.computeIfAbsent(lambda, o -> read(lambda)); + } + + private static PropertyReferenceMetadata read(PropertyReference lambda) { + + MemberDescriptor reference = reader.read(lambda); + + if (KotlinDetector.isKotlinReflectPresent() && reference instanceof KPropertyReferenceDescriptor kProperty) { + return KPropertyReferenceMetadata.of(kProperty); + } + + if (reference instanceof MethodDescriptor method) { + return PropertyReferenceMetadata.ofMethod(method); + } + + return PropertyReferenceMetadata.ofField((FieldDescriptor) reference); + } + + /** + * Metadata describing a property reference including its owner type, property type, and name. + */ + static class PropertyReferenceMetadata { + + private final TypeInformation owner; + private final String property; + private final TypeInformation propertyType; + + PropertyReferenceMetadata(Class owner, String property, ResolvableType propertyType) { + this(TypeInformation.of(owner), property, TypeInformation.of(propertyType)); + } + + PropertyReferenceMetadata(TypeInformation owner, String property, TypeInformation propertyType) { + this.owner = owner; + this.property = property; + this.propertyType = propertyType; + } + + /** + * Create a new {@code PropertyReferenceMetadata} from a method. + */ + public static PropertyReferenceMetadata ofMethod(MethodDescriptor method) { + + PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method.method()); + String methodName = method.getMember().getName(); + + if (descriptor == null) { + + String propertyName = getPropertyName(methodName); + TypeInformation owner = TypeInformation.of(method.owner()); + TypeInformation fallback = owner.getProperty(propertyName); + + if (fallback != null) { + return new PropertyReferenceMetadata(owner, propertyName, fallback); + } + + throw new IllegalArgumentException( + "Cannot find PropertyDescriptor from method '%s.%s()'".formatted(method.owner().getName(), methodName)); + } + + return new PropertyReferenceMetadata(method.getOwner(), 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; + } + + /** + * Create a new {@code PropertyReferenceMetadata} from a field. + */ + public static PropertyReferenceMetadata ofField(FieldDescriptor field) { + return new PropertyReferenceMetadata(field.owner(), field.getMember().getName(), field.getType()); + } + + public TypeInformation owner() { + return owner; + } + + public String property() { + return property; + } + + public TypeInformation propertyType() { + return propertyType; + } + + } + + /** + * Kotlin-specific {@link PropertyReferenceMetadata} implementation. + */ + static class KPropertyReferenceMetadata extends PropertyReferenceMetadata { + + private final KProperty property; + + KPropertyReferenceMetadata(Class owner, KProperty property, ResolvableType propertyType) { + super(owner, property.getName(), propertyType); + this.property = property; + } + + /** + * Create a new {@code KPropertyReferenceMetadata}. + */ + public static KPropertyReferenceMetadata of(KPropertyReferenceDescriptor descriptor) { + return new KPropertyReferenceMetadata(descriptor.getOwner(), descriptor.property(), descriptor.getType()); + } + + public KProperty getProperty() { + return property; + } + } + + /** + * A {@link PropertyReference} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

the property type. + */ + static abstract class ResolvedPropertyReferenceSupport implements PropertyReference { + + private final PropertyReferenceMetadata metadata; + private final String toString; + + ResolvedPropertyReferenceSupport(PropertyReferenceMetadata metadata) { + this.metadata = metadata; + this.toString = metadata.owner().getType().getSimpleName() + "." + getName(); + } + + @Override + public TypeInformation getOwningType() { + return metadata.owner(); + } + + @Override + public String getName() { + return metadata.property(); + } + + @Override + public TypeInformation getTypeInformation() { + return metadata.propertyType(); + } + + @Override + public boolean equals(@Nullable Object obj) { + + if (obj == this) { + return true; + } + + if (!(obj instanceof PropertyReference that)) { + return false; + } + + return Objects.equals(this.getOwningType(), that.getOwningType()) + && Objects.equals(this.getName(), that.getName()); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + @Override + public String toString() { + return toString; + } + + } + + /** + * A {@link PropertyReference} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

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

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

property; + + ResolvedKPropertyReference(KPropertyReferenceMetadata metadata) { + this((KProperty

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

property, PropertyReferenceMetadata metadata) { + super(metadata); + this.property = property; + } + + @Override + public @Nullable P get(T obj) { + return property.call(obj); + } + + } + +} diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index 69bcbfd276..73f3dc2a8d 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -36,9 +36,9 @@ * TypedPropertyPath<Person, String> name = TypedPropertyPath.of(Person::getName); *

* - * The resulting object can be used to obtain the {@link #toDotPath() dot-path} and to interact with the targetting + * 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(TypedPropertyPath)}: + * {@link #then(PropertyReference)}: * *
  * TypedPropertyPath<Person, String> city = TypedPropertyPath.of(Person::getAddress).then(Address::getCity);
@@ -59,12 +59,26 @@
  * @param 

the property value type at this path segment. * @author Mark Paluch * @since 4.1 - * @see PropertyPath#of(TypedPropertyPath) - * @see #then(TypedPropertyPath) + * @see PropertyPath#of(PropertyReference) + * @see #then(PropertyReference) */ @FunctionalInterface public interface TypedPropertyPath extends PropertyPath, Serializable { + /** + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda. + *

+ * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference or lambda. + * + * @param propertyPath the method reference or lambda. + * @param owning type. + * @param

property type. + * @return the typed property path. + */ + static TypedPropertyPath ofReference(PropertyReference propertyPath) { + return TypedPropertyPaths.of(propertyPath); + } + /** * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda. *

@@ -142,8 +156,8 @@ default Iterator iterator() { * @param the new property value type. * @return a new composed {@code TypedPropertyPath}. */ - default TypedPropertyPath then(TypedPropertyPath next) { - return TypedPropertyPaths.compose(this, of(next)); + default TypedPropertyPath then(PropertyReference next) { + return TypedPropertyPaths.compose(this, next); } /** @@ -155,8 +169,8 @@ default Iterator iterator() { * @return a new composed {@code TypedPropertyPath}. */ default TypedPropertyPath thenMany( - TypedPropertyPath> next) { - return (TypedPropertyPath) TypedPropertyPaths.compose(this, of(next)); + PropertyReference> next) { + return (TypedPropertyPath) TypedPropertyPaths.compose(this, PropertyReference.of(next)); } } diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index d24172f54c..c7e72454f7 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -19,6 +19,7 @@ import java.beans.Introspector; import java.beans.PropertyDescriptor; +import java.io.Serializable; import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -29,7 +30,6 @@ import java.util.stream.Stream; import org.jspecify.annotations.Nullable; - import org.springframework.beans.BeanUtils; import org.springframework.core.KotlinDetector; import org.springframework.core.ResolvableType; @@ -48,11 +48,25 @@ class TypedPropertyPaths { private static final Map> lambdas = new WeakHashMap<>(); - private static final Map, ResolvedTypedPropertyPath>> resolved = new WeakHashMap<>(); + private static final Map>> resolved = new WeakHashMap<>(); private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyPath.class, TypedPropertyPath.class, TypedPropertyPaths.class); + /** + * Compose a {@link TypedPropertyPath} by appending {@code next}. + */ + public static TypedPropertyPath compose(PropertyReference owner, PropertyReference next) { + return compose(of(owner), of(next)); + } + + /** + * Compose a {@link TypedPropertyPath} by appending {@code next}. + */ + public static TypedPropertyPath compose(TypedPropertyPath owner, PropertyReference next) { + return compose(of(owner), of(next)); + } + /** * Compose a {@link TypedPropertyPath} by appending {@code next}. */ @@ -77,7 +91,14 @@ public static TypedPropertyPath compose(TypedPropertyPath return result; } - return new ForwardingPropertyPath<>(owner, next); + return new ForwardingPropertyPath<>(of(owner), next); + } + + /** + * Create a {@link TypedPropertyPath} from a {@link PropertyReference}. + */ + public static TypedPropertyPath of(PropertyReference lambda) { + return new PropertyReferenceWrapper<>(PropertyReferences.of(lambda)); } /** @@ -86,13 +107,14 @@ public static TypedPropertyPath compose(TypedPropertyPath @SuppressWarnings({ "unchecked", "rawtypes" }) public static TypedPropertyPath of(TypedPropertyPath lambda) { - if (lambda instanceof ForwardingPropertyPath || lambda instanceof ResolvedTypedPropertyPathSupport) { + if (lambda instanceof Resolved) { return lambda; } Map, ResolvedTypedPropertyPath> cache; synchronized (resolved) { - cache = resolved.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); + cache = (Map) resolved.computeIfAbsent(lambda.getClass().getClassLoader(), + k -> new ConcurrentReferenceHashMap<>()); } return (TypedPropertyPath) cache.computeIfAbsent(lambda, @@ -241,13 +263,17 @@ public KProperty getProperty() { } } + interface Resolved { + + } + /** * A {@link TypedPropertyPath} implementation that caches resolved metadata to avoid repeated introspection. * * @param the owning type. * @param

the property type. */ - static abstract class ResolvedTypedPropertyPathSupport implements TypedPropertyPath { + static abstract class ResolvedTypedPropertyPathSupport implements TypedPropertyPath, Resolved { private final PropertyPathMetadata metadata; private final List list; @@ -316,6 +342,106 @@ public String toString() { } + /** + * Wrapper for {@link PropertyReference}. + * + * @param the owning type. + * @param

the property type. + */ + static class PropertyReferenceWrapper implements TypedPropertyPath, Resolved { + + private final PropertyReference property; + private final List self; + + public PropertyReferenceWrapper(PropertyReference property) { + this.property = property; + this.self = List.of(this); + } + + @Override + public @Nullable P get(T obj) { + return property.get(obj); + } + + @Override + public TypeInformation getOwningType() { + return property.getOwningType(); + } + + @Override + public String getSegment() { + return property.getName(); + } + + @Override + public TypeInformation getTypeInformation() { + return property.getTypeInformation(); + } + + @Override + public Iterator iterator() { + return self.iterator(); + } + + @Override + public Stream stream() { + return self.stream(); + } + + @Override + public List toList() { + return self; + } + + @Override + public boolean equals(@Nullable Object obj) { + + if (obj == this) { + return true; + } + + if (!(obj instanceof PropertyPath that)) { + return false; + } + + return Objects.equals(this.getOwningType(), that.getOwningType()) + && Objects.equals(this.toDotPath(), that.toDotPath()); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + @Override + public String toString() { + return property.toString(); + } + + } + + /** + * A {@link TypedPropertyPath} implementation that caches resolved metadata to avoid repeated introspection. + * + * @param the owning type. + * @param

the property type. + */ + static class ResolvedPropertyReference extends ResolvedTypedPropertyPathSupport { + + private final PropertyReference function; + + ResolvedPropertyReference(PropertyReference function, PropertyPathMetadata 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. * @@ -374,7 +500,7 @@ static class ResolvedKPropertyPath extends ResolvedTypedPropertyPathSuppor * @param toStringRepresentation cached toString representation. */ record ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment, - PropertyPath leaf, String dotPath, String toStringRepresentation) implements TypedPropertyPath { + PropertyPath leaf, String dotPath, String toStringRepresentation) implements TypedPropertyPath, Resolved { public ForwardingPropertyPath(TypedPropertyPath self, TypedPropertyPath nextSegment) { this(self, nextSegment, nextSegment.getLeafProperty(), getDotPath(self, nextSegment), diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt index dec5e19b80..2aeb9550b5 100644 --- a/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt @@ -39,7 +39,7 @@ fun KProperty1.toPath(): TypedPropertyPath = KTypedPropertyPath.of(this) /** - * Builds [KPropertyReference] from Property References. + * Builds [KPropertyReferenceImpl] from Property References. * Refer to a nested property in an embeddable or association. * * For example, referring to the field "author.name": @@ -55,7 +55,7 @@ operator fun KProperty1.div(other: KProperty1): KPropert KSinglePropertyReference(this, other) as KProperty1 /** - * Builds [KPropertyReference] from Property References. + * Builds [KPropertyReferenceImpl] from Property References. * Refer to a nested property in an embeddable or association. * * Note, that this function is different from [div] above in the diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyReference.kt b/src/main/kotlin/org/springframework/data/core/KPropertyReferenceImpl.kt similarity index 94% rename from src/main/kotlin/org/springframework/data/core/KPropertyReference.kt rename to src/main/kotlin/org/springframework/data/core/KPropertyReferenceImpl.kt index 93a26cde21..d7d97c7f62 100644 --- a/src/main/kotlin/org/springframework/data/core/KPropertyReference.kt +++ b/src/main/kotlin/org/springframework/data/core/KPropertyReferenceImpl.kt @@ -28,7 +28,7 @@ import kotlin.reflect.KProperty1 * @author Yoann de Martino * @since 4.1 */ -internal interface KPropertyReference : KProperty1 { +internal interface KPropertyReferenceImpl : KProperty1 { val property: KProperty1 val leaf: KProperty1<*, P> } @@ -36,7 +36,7 @@ internal interface KPropertyReference : KProperty1 { internal class KSinglePropertyReference( val parent: KProperty1, val child: KProperty1 -) : KProperty1 by child as KProperty1, KPropertyReference { +) : KProperty1 by child as KProperty1, KPropertyReferenceImpl { override fun get(receiver: T): P { @@ -71,7 +71,7 @@ internal class KSinglePropertyReference( internal class KIterablePropertyReference( val parent: KProperty1?>, val child: KProperty1 -) : KProperty1 by child as KProperty1, KPropertyReference { +) : KProperty1 by child as KProperty1, KPropertyReferenceImpl { override fun get(receiver: T): P { throw UnsupportedOperationException("Collection retrieval not supported") @@ -95,7 +95,7 @@ internal class KIterablePropertyReference( */ internal fun asString(property: KProperty<*>): String { return when (property) { - is KPropertyReference<*, *> -> + is KPropertyReferenceImpl<*, *> -> "${asString(property.property)}.${property.leaf.name}" else -> property.name diff --git a/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt new file mode 100644 index 0000000000..d44776c05f --- /dev/null +++ b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress( + "UPPER_BOUND_VIOLATED_BASED_ON_JAVA_ANNOTATIONS", + "NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS", "UNCHECKED_CAST" +) + +package org.springframework.data.core + +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaGetter + +/** + * Extension function to compose a [TypedPropertyPath] with a [KProperty1]. + * + * @since 4.1 + */ +fun PropertyReference.then(next: KProperty1): TypedPropertyPath { + val nextPath = KPropertyReference.of(next) as PropertyReference + return TypedPropertyPaths.compose(this, nextPath) +} + +/** + * Extension function to compose a [TypedPropertyPath] with a [KProperty]. + * + * @since 4.1 + */ +fun PropertyReference.then(next: KProperty): TypedPropertyPath { + val nextPath = KPropertyReference.of(next) as PropertyReference + return TypedPropertyPaths.compose(this, nextPath) +} + +/** + * Helper to create [PropertyReference] from [KProperty]. + * + * @since 4.1 + */ +class KPropertyReference { + + /** + * Companion object for static factory methods. + */ + companion object { + + /** + * Create a [PropertyReference] from a [KProperty1]. + */ + fun of(property: KProperty1): PropertyReference { + return of((property as KProperty)) + } + + /** + * Create a [PropertyReference] from a collection-like [KProperty1]. + */ + @JvmName("ofMany") + fun of(property: KProperty1?>): PropertyReference { + return of((property as KProperty)) + } + + + /** + * Create a [PropertyReference] from a [KProperty]. + */ + fun of(property: KProperty): PropertyReference { + + if (property is KProperty1<*, *>) { + + val property1 = property as KProperty1<*, *> + val owner = property1.javaField?.declaringClass + ?: property1.javaGetter?.declaringClass + val metadata = PropertyReferences.KPropertyReferenceMetadata.of( + MemberDescriptor.KPropertyReferenceDescriptor.create( + owner, + property1 + ) + ) + return PropertyReferences.ResolvedKPropertyReference(metadata) + } + + throw IllegalArgumentException("Property ${property.name} is not a KProperty") + } + + } + +} diff --git a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt index 009f3d8c7f..a9319a2876 100644 --- a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt @@ -77,9 +77,9 @@ class KTypedPropertyPath { */ fun of(property: KProperty): TypedPropertyPath { - if (property is KPropertyReference<*, *>) { + if (property is KPropertyReferenceImpl<*, *>) { - val paths = property as KPropertyReference<*, *> + val paths = property as KPropertyReferenceImpl<*, *> val parent = of(paths.property) val child = of(paths.leaf) diff --git a/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java b/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java new file mode 100644 index 0000000000..6ea33b0072 --- /dev/null +++ b/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java @@ -0,0 +1,316 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.dao.InvalidDataAccessApiUsageException; + +/** + * Unit tests for {@link PropertyReference}. + * + * @author Mark Paluch + */ +class PropertyReferenceUnitTests { + + @Test + void resolvesMHSimplePath() { + assertThat(PropertyReference.of(PersonQuery::getName).getName()).isEqualTo("name"); + } + + @Test + void resolvesMHComposedPath() { + assertThat(PropertyReference.of(PersonQuery::getAddress).then(Address::getCountry).toDotPath()) + .isEqualTo("address.country"); + } + + @Test + void resolvesCollectionPath() { + assertThat(PropertyReference.ofMany(PersonQuery::getAddresses).then(Address::getCity).toDotPath()) + .isEqualTo("addresses.city"); + } + + @Test + @SuppressWarnings("Convert2MethodRef") + void resolvesInitialLambdaGetter() { + assertThat(PropertyReference.of((PersonQuery person) -> person.getName()).getName()).isEqualTo("name"); + } + + @Test + @SuppressWarnings("Convert2MethodRef") + void resolvesComposedLambdaGetter() { + assertThat(PropertyReference.of(PersonQuery::getAddress).then(it -> it.getCity()).toDotPath()) + .isEqualTo("address.city"); + } + + @Test + void resolvesComposedLambdaFieldAccess() { + assertThat(PropertyReference.of(PersonQuery::getAddress).then(it -> it.city).toDotPath()).isEqualTo("address.city"); + } + + @Test + void resolvesInterfaceMethodReferenceGetter() { + assertThat(PropertyReference.of(PersonProjection::getName).getName()).isEqualTo("name"); + } + + @Test + @SuppressWarnings("Convert2MethodRef") + void resolvesInterfaceLambdaGetter() { + assertThat(PropertyReference.of((PersonProjection person) -> person.getName()).getName()).isEqualTo("name"); + } + + @Test + void resolvesSuperclassMethodReferenceGetter() { + assertThat(PropertyReference.of(PersonQuery::getTenant).getName()).isEqualTo("tenant"); + } + + @Test + void resolvesSuperclassLambdaGetter() { + assertThat(PropertyReference.of((PersonQuery person) -> person.getTenant()).getName()).isEqualTo("tenant"); + } + + @Test + void resolvesPrivateMethodReference() { + assertThat(PropertyReference.of(Secret::getSecret).getName()).isEqualTo("secret"); + } + + @Test + @SuppressWarnings("Convert2MethodRef") + void resolvesPrivateMethodLambda() { + assertThat(PropertyReference.of((Secret secret) -> secret.getSecret()).getName()).isEqualTo("secret"); + } + + @Test + void switchingOwningTypeFails() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PersonQuery person) -> { + return ((SuperClass) person).getTenant(); + })); + } + + @Test + void constructorCallsShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PersonQuery person) -> new PersonQuery(person))); + } + + @Test + void enumShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of(NotSupported.INSTANCE)); + } + + @Test + void returningSomethingShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PropertyReference) obj -> null)); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PropertyReference) obj -> 1)); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PropertyReference) obj -> "")); + } + + @Test + @SuppressWarnings("Convert2Lambda") + void classImplementationShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of(new PropertyReference() { + @Override + public @Nullable Object get(Object obj) { + return null; + } + })); + } + + @Test + void constructorMethodReferenceShouldFail() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference. of(PersonQuery::new)); + } + + @Test + void failsResolutionWith$StrangeStuff() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PersonQuery person) -> { + int a = 1 + 2; + new Integer(a).toString(); + return person.getName(); + }).getName()); + } + + @Test + void arithmeticOpsFail() { + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> { + PropertyReference.of((PersonQuery person) -> { + int a = 1 + 2; + return person.getName(); + }); + }); + } + + @Test + void failsResolvingCallingLocalMethod() { + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> PropertyReference.of((PersonQuery person) -> { + failsResolutionWith$StrangeStuff(); + return person.getName(); + })); + } + + @Nested + class NestedTestClass { + + @Test + @SuppressWarnings("Convert2MethodRef") + void resolvesInterfaceLambdaGetter() { + assertThat(PropertyReference.of((PersonProjection person) -> person.getName()).getName()).isEqualTo("name"); + } + + @Test + void resolvesSuperclassMethodReferenceGetter() { + assertThat(PropertyReference.of(PersonQuery::getTenant).getName()).isEqualTo("tenant"); + } + + } + + // Domain entities + + static class SuperClass { + + private int tenant; + + public int getTenant() { + return tenant; + } + + public void setTenant(int tenant) { + this.tenant = tenant; + } + } + + static class PersonQuery extends SuperClass { + + private String name; + private @Nullable Integer age; + private PersonQuery emergencyContact; + private Address address; + private List

addresses; + + public PersonQuery(PersonQuery pq) {} + + public PersonQuery() {} + + // Getters + public String getName() { + return name; + } + + public @Nullable Integer getAge() { + return age; + } + + public PersonQuery getEmergencyContact() { + return emergencyContact; + } + + public void setEmergencyContact(PersonQuery emergencyContact) { + this.emergencyContact = emergencyContact; + } + + public Address getAddress() { + return address; + } + + public List
getAddresses() { + return addresses; + } + + public void setAddresses(List
addresses) { + this.addresses = addresses; + } + } + + static class Address { + + String street; + String city; + private Country country; + private String secret; + + // Getters + public String getStreet() { + return street; + } + + public String getCity() { + return city; + } + + public Country getCountry() { + return country; + } + + private String getSecret() { + return secret; + } + + private void setSecret(String secret) { + this.secret = secret; + } + } + + record Country(String name, String code) { + + } + + static class Secret { + + private String secret; + + private String getSecret() { + return secret; + } + + } + + interface PersonProjection { + + String getName(); + } + + enum NotSupported implements PropertyReference { + + INSTANCE; + + @Override + public @Nullable String get(String obj) { + return ""; + } + } +} diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index cba821cf81..a790801bb1 100644 --- a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -26,7 +26,6 @@ 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; /** @@ -145,18 +144,18 @@ void constructorCallsShouldFail() { void enumShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) - .isThrownBy(() -> PropertyPath.of(NotSupported.INSTANCE)); + .isThrownBy(() -> TypedPropertyPath.of(NotSupported.INSTANCE)); } @Test void returningSomethingShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) - .isThrownBy(() -> PropertyPath.of((TypedPropertyPath) obj -> null)); + .isThrownBy(() -> TypedPropertyPath.of((TypedPropertyPath) obj -> null)); assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) - .isThrownBy(() -> PropertyPath.of((TypedPropertyPath) obj -> 1)); + .isThrownBy(() -> TypedPropertyPath.of((TypedPropertyPath) obj -> 1)); assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) - .isThrownBy(() -> PropertyPath.of((TypedPropertyPath) obj -> "")); + .isThrownBy(() -> TypedPropertyPath.of((TypedPropertyPath) obj -> "")); } @Test @@ -164,7 +163,7 @@ void returningSomethingShouldFail() { void classImplementationShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) - .isThrownBy(() -> PropertyPath.of(new TypedPropertyPath() { + .isThrownBy(() -> TypedPropertyPath.of(new TypedPropertyPath() { @Override public @Nullable Object get(Object obj) { return null; diff --git a/src/test/java/org/springframework/data/domain/SortUnitTests.java b/src/test/java/org/springframework/data/domain/SortUnitTests.java index 4b9ac5d46c..06d8f78508 100755 --- a/src/test/java/org/springframework/data/domain/SortUnitTests.java +++ b/src/test/java/org/springframework/data/domain/SortUnitTests.java @@ -21,7 +21,6 @@ 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; @@ -60,7 +59,8 @@ record PersonHolder(Person person) { assertThat(Sort.by(Person::getFirstName).iterator().next().getProperty()).isEqualTo("firstName"); assertThat( - Sort.by(TypedPropertyPath.of(PersonHolder::person).then(Person::getFirstName)).iterator().next().getProperty()) + Sort.by(TypedPropertyPath.ofReference(PersonHolder::person).then(Person::getFirstName)).iterator().next() + .getProperty()) .isEqualTo("person.firstName"); } diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt new file mode 100644 index 0000000000..9555605a5a --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt @@ -0,0 +1,60 @@ +package org.springframework.data.core + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * Unit tests for [KPropertyReference] and related functionality. + * + * @author Mark Paluch + */ +class KPropertyReferenceUnitTests { + + @Test + fun shouldCreatePropertyReference() { + + val path = KPropertyReference.of(Person::name) + + assertThat(path.name).isEqualTo("name") + } + + @Test + fun shouldComposePropertyPath() { + + val path = KPropertyReference.of(Person::address).then(Address::city) + + assertThat(path.toDotPath()).isEqualTo("address.city") + } + + @Test + fun shouldComposeManyPropertyPath() { + + val path = KPropertyReference.of(Person::addresses).then(Address::city) + + assertThat(path.toDotPath()).isEqualTo("addresses.city") + } + + @Test + fun shouldCreateComposed() { + + val path = KPropertyReference.of(Person::address / Address::city) + + assertThat(path.name).isEqualTo("city") + } + + class Person { + var name: String? = null + var age: Int = 0 + var address: Address? = null + var addresses: List
= emptyList() + var emergencyContact: Person? = null + } + + class Address { + var city: String? = null + var street: String? = null + var country: Country? = null + } + + data class Country(val name: String) +} diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt index 298a6cb5f3..8100200f2c 100644 --- a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -9,7 +9,7 @@ import org.junit.jupiter.params.provider.MethodSource import java.util.stream.Stream /** - * Unit tests for [KPropertyReference] and related functionality. + * Unit tests for [KPropertyReferenceImpl] and related functionality. * * @author Mark Paluch */ diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt index 72f0489a87..72ff6a75e2 100644 --- a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt @@ -29,24 +29,24 @@ class TypedPropertyPathKtUnitTests { return Stream.of( Arguments.argumentSet( "Person.name", - TypedPropertyPath.of(Person::name), + TypedPropertyPath.ofReference(Person::name), PropertyPath.from("name", Person::class.java) ), Arguments.argumentSet( "Person.address.country", - TypedPropertyPath.of(Person::address) + TypedPropertyPath.ofReference(Person::address) .then(Address::country), PropertyPath.from("address.country", Person::class.java) ), Arguments.argumentSet( "Person.address.country.name", - TypedPropertyPath.of(Person::address) + TypedPropertyPath.ofReference(Person::address) .then(Address::country).then(Country::name), PropertyPath.from("address.country.name", Person::class.java) ), Arguments.argumentSet( "Person.emergencyContact.address.country.name", - TypedPropertyPath.of(Person::emergencyContact) + TypedPropertyPath.ofReference(Person::emergencyContact) .then
(Person::address).then(Address::country) .then(Country::name), PropertyPath.from( @@ -60,27 +60,27 @@ class TypedPropertyPathKtUnitTests { @Test fun shouldSupportPropertyReference() { - assertThat(TypedPropertyPath.of(Person::address).toDotPath()).isEqualTo("address") + assertThat(TypedPropertyPath.ofReference(Person::address).toDotPath()).isEqualTo("address") } @Test fun shouldSupportComposedPropertyReference() { - val path = TypedPropertyPath.of(Person::address).then(Address::city); + val path = TypedPropertyPath.ofReference(Person::address).then(Address::city); assertThat(path.toDotPath()).isEqualTo("address.city") } @Test fun shouldSupportPropertyLambda() { - assertThat(TypedPropertyPath.of { it.address }.toDotPath()).isEqualTo("address") - assertThat(TypedPropertyPath.of { foo -> foo.address } + assertThat(TypedPropertyPath.ofReference { it.address }.toDotPath()).isEqualTo("address") + assertThat(TypedPropertyPath.ofReference { foo -> foo.address } .toDotPath()).isEqualTo("address") } @Test fun shouldSupportComposedPropertyLambda() { - val path = TypedPropertyPath.of { it.address }; + val path = TypedPropertyPath.ofReference { it.address }; assertThat(path.then { it.city }.toDotPath()).isEqualTo("address.city") } From 42ff4230088979e0f2ece9a02f8e2c78900a260c Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 17 Nov 2025 10:08:35 +0100 Subject: [PATCH 16/25] Polishing. --- .../data/core/PropertyReference.java | 18 +++---- .../data/core/TypedPropertyPath.java | 2 +- .../data/core/PropertyReferenceExtensions.kt | 11 ++-- .../data/core/PropertyReferenceUnitTests.java | 46 ++++++++--------- .../data/core/TypedPropertyPathUnitTests.java | 50 +++++++++---------- .../data/core/KPropertyReferenceUnitTests.kt | 16 +++--- .../data/core/KTypedPropertyPathUnitTests.kt | 8 +-- .../data/core/TypedPropertyPathKtUnitTests.kt | 8 +-- 8 files changed, 81 insertions(+), 78 deletions(-) diff --git a/src/main/java/org/springframework/data/core/PropertyReference.java b/src/main/java/org/springframework/data/core/PropertyReference.java index c85b363c86..8940a7dfed 100644 --- a/src/main/java/org/springframework/data/core/PropertyReference.java +++ b/src/main/java/org/springframework/data/core/PropertyReference.java @@ -22,16 +22,16 @@ /** * Interface providing type-safe property references. *

- * This functional interface is typically implemented through method references or lambda expressions that allow for + * This functional interface is typically implemented through method references and lambda expressions 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 TypedPropertyReference} leverages Java's declarative method references and lambda + * changing the domain model, {@code PropertyReference} leverages Java's declarative method references and lambda * expressions to ensure type-safe property access. *

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

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

* The generic type parameters preserve type information across the property path chain: {@code T} represents the owning @@ -47,11 +47,11 @@ * at this segment. Composition automatically flows type information forward, ensuring that {@code then()} preserves the * full chain's type safety. *

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

the property value type. diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index 73f3dc2a8d..1ab23c7e4c 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -22,7 +22,7 @@ import org.jspecify.annotations.Nullable; /** - * Interface providing type-safe property path navigation through method references or lambda expressions. + * Interface providing type-safe property path navigation through method references and lambda expressions. *

* This functional interface extends {@link PropertyPath} to provide compile-time type safety and refactoring support. * Instead of using {@link PropertyPath#from(String, TypeInformation) string-based property paths} for textual property diff --git a/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt index d44776c05f..8b1f817d5c 100644 --- a/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt @@ -58,26 +58,31 @@ class KPropertyReference { companion object { /** - * Create a [PropertyReference] from a [KProperty1]. + * Create a [PropertyReference] from a [KProperty1] reference. + * @param property the property reference, must not be a property path. */ fun of(property: KProperty1): PropertyReference { return of((property as KProperty)) } /** - * Create a [PropertyReference] from a collection-like [KProperty1]. + * Create a [PropertyReference] from a collection-like [KProperty1] reference. + * @param property the property reference, must not be a property path. */ @JvmName("ofMany") fun of(property: KProperty1?>): PropertyReference { return of((property as KProperty)) } - /** * Create a [PropertyReference] from a [KProperty]. */ fun of(property: KProperty): PropertyReference { + if (property is KPropertyReferenceImpl<*, *>) { + throw IllegalArgumentException("Property reference ${property.name} must not be a property path") + } + if (property is KProperty1<*, *>) { val property1 = property as KProperty1<*, *> diff --git a/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java b/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java index 6ea33b0072..42f0b2511b 100644 --- a/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java +++ b/src/test/java/org/springframework/data/core/PropertyReferenceUnitTests.java @@ -31,74 +31,74 @@ */ class PropertyReferenceUnitTests { - @Test + @Test // GH-3400 void resolvesMHSimplePath() { assertThat(PropertyReference.of(PersonQuery::getName).getName()).isEqualTo("name"); } - @Test + @Test // GH-3400 void resolvesMHComposedPath() { assertThat(PropertyReference.of(PersonQuery::getAddress).then(Address::getCountry).toDotPath()) .isEqualTo("address.country"); } - @Test + @Test // GH-3400 void resolvesCollectionPath() { assertThat(PropertyReference.ofMany(PersonQuery::getAddresses).then(Address::getCity).toDotPath()) .isEqualTo("addresses.city"); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesInitialLambdaGetter() { assertThat(PropertyReference.of((PersonQuery person) -> person.getName()).getName()).isEqualTo("name"); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesComposedLambdaGetter() { assertThat(PropertyReference.of(PersonQuery::getAddress).then(it -> it.getCity()).toDotPath()) .isEqualTo("address.city"); } - @Test + @Test // GH-3400 void resolvesComposedLambdaFieldAccess() { assertThat(PropertyReference.of(PersonQuery::getAddress).then(it -> it.city).toDotPath()).isEqualTo("address.city"); } - @Test + @Test // GH-3400 void resolvesInterfaceMethodReferenceGetter() { assertThat(PropertyReference.of(PersonProjection::getName).getName()).isEqualTo("name"); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesInterfaceLambdaGetter() { assertThat(PropertyReference.of((PersonProjection person) -> person.getName()).getName()).isEqualTo("name"); } - @Test + @Test // GH-3400 void resolvesSuperclassMethodReferenceGetter() { assertThat(PropertyReference.of(PersonQuery::getTenant).getName()).isEqualTo("tenant"); } - @Test + @Test // GH-3400 void resolvesSuperclassLambdaGetter() { assertThat(PropertyReference.of((PersonQuery person) -> person.getTenant()).getName()).isEqualTo("tenant"); } - @Test + @Test // GH-3400 void resolvesPrivateMethodReference() { assertThat(PropertyReference.of(Secret::getSecret).getName()).isEqualTo("secret"); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesPrivateMethodLambda() { assertThat(PropertyReference.of((Secret secret) -> secret.getSecret()).getName()).isEqualTo("secret"); } - @Test + @Test // GH-3400 void switchingOwningTypeFails() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) @@ -107,21 +107,21 @@ void switchingOwningTypeFails() { })); } - @Test + @Test // GH-3400 void constructorCallsShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) .isThrownBy(() -> PropertyReference.of((PersonQuery person) -> new PersonQuery(person))); } - @Test + @Test // GH-3400 void enumShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) .isThrownBy(() -> PropertyReference.of(NotSupported.INSTANCE)); } - @Test + @Test // GH-3400 void returningSomethingShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) @@ -132,7 +132,7 @@ void returningSomethingShouldFail() { .isThrownBy(() -> PropertyReference.of((PropertyReference) obj -> "")); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2Lambda") void classImplementationShouldFail() { @@ -145,14 +145,14 @@ void classImplementationShouldFail() { })); } - @Test + @Test // GH-3400 void constructorMethodReferenceShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) .isThrownBy(() -> PropertyReference. of(PersonQuery::new)); } - @Test + @Test // GH-3400 void failsResolutionWith$StrangeStuff() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) @@ -163,7 +163,7 @@ void constructorMethodReferenceShouldFail() { }).getName()); } - @Test + @Test // GH-3400 void arithmeticOpsFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> { PropertyReference.of((PersonQuery person) -> { @@ -173,7 +173,7 @@ void arithmeticOpsFail() { }); } - @Test + @Test // GH-3400 void failsResolvingCallingLocalMethod() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) @@ -186,13 +186,13 @@ void failsResolvingCallingLocalMethod() { @Nested class NestedTestClass { - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesInterfaceLambdaGetter() { assertThat(PropertyReference.of((PersonProjection person) -> person.getName()).getName()).isEqualTo("name"); } - @Test + @Test // GH-3400 void resolvesSuperclassMethodReferenceGetter() { assertThat(PropertyReference.of(PersonQuery::getTenant).getName()).isEqualTo("tenant"); } diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index a790801bb1..0232554848 100644 --- a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -35,7 +35,7 @@ */ class TypedPropertyPathUnitTests { - @ParameterizedTest + @ParameterizedTest // GH-3400 @MethodSource("propertyPaths") void verifyTck(TypedPropertyPath actual, PropertyPath expected) { PropertyPathTck.verify(actual, expected); @@ -58,73 +58,73 @@ static Stream propertyPaths() { PropertyPath.from("emergencyContact.address.country.name", PersonQuery.class))); } - @Test + @Test // GH-3400 void resolvesMHSimplePath() { assertThat(PropertyPath.of(PersonQuery::getName).toDotPath()).isEqualTo("name"); } - @Test + @Test // GH-3400 void resolvesMHComposedPath() { assertThat(PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry).toDotPath()) .isEqualTo("address.country"); } - @Test + @Test // GH-3400 void resolvesCollectionPath() { assertThat(PropertyPath.ofMany(PersonQuery::getAddresses).then(Address::getCity).toDotPath()) .isEqualTo("addresses.city"); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesInitialLambdaGetter() { assertThat(PropertyPath.of((PersonQuery person) -> person.getName()).toDotPath()).isEqualTo("name"); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesComposedLambdaGetter() { assertThat(PropertyPath.of(PersonQuery::getAddress).then(it -> it.getCity()).toDotPath()).isEqualTo("address.city"); } - @Test + @Test // GH-3400 void resolvesComposedLambdaFieldAccess() { assertThat(PropertyPath.of(PersonQuery::getAddress).then(it -> it.city).toDotPath()).isEqualTo("address.city"); } - @Test + @Test // GH-3400 void resolvesInterfaceMethodReferenceGetter() { assertThat(PropertyPath.of(PersonProjection::getName).toDotPath()).isEqualTo("name"); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesInterfaceLambdaGetter() { assertThat(PropertyPath.of((PersonProjection person) -> person.getName()).toDotPath()).isEqualTo("name"); } - @Test + @Test // GH-3400 void resolvesSuperclassMethodReferenceGetter() { assertThat(PropertyPath.of(PersonQuery::getTenant).toDotPath()).isEqualTo("tenant"); } - @Test + @Test // GH-3400 void resolvesSuperclassLambdaGetter() { assertThat(PropertyPath.of((PersonQuery person) -> person.getTenant()).toDotPath()).isEqualTo("tenant"); } - @Test + @Test // GH-3400 void resolvesPrivateMethodReference() { assertThat(PropertyPath.of(Secret::getSecret).toDotPath()).isEqualTo("secret"); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesPrivateMethodLambda() { assertThat(PropertyPath.of((Secret secret) -> secret.getSecret()).toDotPath()).isEqualTo("secret"); } - @Test + @Test // GH-3400 void switchingOwningTypeFails() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) @@ -133,21 +133,21 @@ void switchingOwningTypeFails() { })); } - @Test + @Test // GH-3400 void constructorCallsShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) .isThrownBy(() -> PropertyPath.of((PersonQuery person) -> new PersonQuery(person))); } - @Test + @Test // GH-3400 void enumShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) .isThrownBy(() -> TypedPropertyPath.of(NotSupported.INSTANCE)); } - @Test + @Test // GH-3400 void returningSomethingShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) @@ -158,7 +158,7 @@ void returningSomethingShouldFail() { .isThrownBy(() -> TypedPropertyPath.of((TypedPropertyPath) obj -> "")); } - @Test + @Test // GH-3400 @SuppressWarnings("Convert2Lambda") void classImplementationShouldFail() { @@ -171,14 +171,14 @@ void classImplementationShouldFail() { })); } - @Test + @Test // GH-3400 void constructorMethodReferenceShouldFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) .isThrownBy(() -> PropertyPath. of(PersonQuery::new)); } - @Test + @Test // GH-3400 void resolvesMRRecordPath() { TypedPropertyPath then = PropertyPath.of(PersonQuery::getAddress).then(Address::getCountry) @@ -187,7 +187,7 @@ void resolvesMRRecordPath() { assertThat(then.toDotPath()).isEqualTo("address.country.name"); } - @Test + @Test // GH-3400 void failsResolutionWith$StrangeStuff() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) @@ -198,7 +198,7 @@ void resolvesMRRecordPath() { }).toDotPath()); } - @Test + @Test // GH-3400 void arithmeticOpsFail() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> { PropertyPath.of((PersonQuery person) -> { @@ -208,7 +208,7 @@ void arithmeticOpsFail() { }); } - @Test + @Test // GH-3400 void failsResolvingCallingLocalMethod() { assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) @@ -221,13 +221,13 @@ void failsResolvingCallingLocalMethod() { @Nested class NestedTestClass { - @Test + @Test // GH-3400 @SuppressWarnings("Convert2MethodRef") void resolvesInterfaceLambdaGetter() { assertThat(PropertyPath.of((PersonProjection person) -> person.getName()).toDotPath()).isEqualTo("name"); } - @Test + @Test // GH-3400 void resolvesSuperclassMethodReferenceGetter() { assertThat(PropertyPath.of(PersonQuery::getTenant).toDotPath()).isEqualTo("tenant"); } diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt index 9555605a5a..a2c5aaf1bf 100644 --- a/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt @@ -1,6 +1,7 @@ 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 /** @@ -10,7 +11,7 @@ import org.junit.jupiter.api.Test */ class KPropertyReferenceUnitTests { - @Test + @Test // GH-3400 fun shouldCreatePropertyReference() { val path = KPropertyReference.of(Person::name) @@ -18,7 +19,7 @@ class KPropertyReferenceUnitTests { assertThat(path.name).isEqualTo("name") } - @Test + @Test // GH-3400 fun shouldComposePropertyPath() { val path = KPropertyReference.of(Person::address).then(Address::city) @@ -26,7 +27,7 @@ class KPropertyReferenceUnitTests { assertThat(path.toDotPath()).isEqualTo("address.city") } - @Test + @Test // GH-3400 fun shouldComposeManyPropertyPath() { val path = KPropertyReference.of(Person::addresses).then(Address::city) @@ -34,12 +35,9 @@ class KPropertyReferenceUnitTests { assertThat(path.toDotPath()).isEqualTo("addresses.city") } - @Test - fun shouldCreateComposed() { - - val path = KPropertyReference.of(Person::address / Address::city) - - assertThat(path.name).isEqualTo("city") + @Test // GH-3400 + fun composedReferenceCreationShouldFail() { + assertThatIllegalArgumentException().isThrownBy { KPropertyReference.of(Person::address / Address::city) } } class Person { diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt index 8100200f2c..a1328f1251 100644 --- a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -64,7 +64,7 @@ class KTypedPropertyPathUnitTests { } } - @Test + @Test // GH-3400 fun shouldCreatePropertyPath() { val path = KTypedPropertyPath.of(Person::name) @@ -72,7 +72,7 @@ class KTypedPropertyPathUnitTests { assertThat(path.toDotPath()).isEqualTo("name") } - @Test + @Test // GH-3400 fun shouldComposePropertyPath() { val path = KTypedPropertyPath.of(Person::address).then(Address::city) @@ -80,7 +80,7 @@ class KTypedPropertyPathUnitTests { assertThat(path.toDotPath()).isEqualTo("address.city") } - @Test + @Test // GH-3400 fun shouldComposeManyPropertyPath() { val path = KTypedPropertyPath.of(Person::addresses).then(Address::city) @@ -88,7 +88,7 @@ class KTypedPropertyPathUnitTests { assertThat(path.toDotPath()).isEqualTo("addresses.city") } - @Test + @Test // GH-3400 fun shouldCreateComposed() { val path = KTypedPropertyPath.of(Person::address / Address::city) diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt index 72ff6a75e2..ece047f499 100644 --- a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt @@ -58,26 +58,26 @@ class TypedPropertyPathKtUnitTests { } } - @Test + @Test // GH-3400 fun shouldSupportPropertyReference() { assertThat(TypedPropertyPath.ofReference(Person::address).toDotPath()).isEqualTo("address") } - @Test + @Test // GH-3400 fun shouldSupportComposedPropertyReference() { val path = TypedPropertyPath.ofReference(Person::address).then(Address::city); assertThat(path.toDotPath()).isEqualTo("address.city") } - @Test + @Test // GH-3400 fun shouldSupportPropertyLambda() { assertThat(TypedPropertyPath.ofReference { it.address }.toDotPath()).isEqualTo("address") assertThat(TypedPropertyPath.ofReference { foo -> foo.address } .toDotPath()).isEqualTo("address") } - @Test + @Test // GH-3400 fun shouldSupportComposedPropertyLambda() { val path = TypedPropertyPath.ofReference { it.address }; From 3faceb0b3c74a52d663e3b0a7a0fcff25f907813 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 17 Nov 2025 11:12:09 +0100 Subject: [PATCH 17/25] Adapt Kotlin property reference usage through TypedPropertyPaths. --- .../data/core/MemberDescriptor.java | 63 ++++++++++- .../data/core/PropertyReferences.java | 16 ++- .../data/core/SerializableLambdaReader.java | 6 + .../data/core/TypedPropertyPaths.java | 106 ++++++++++++++++-- .../data/core/KPropertyReferenceUnitTests.kt | 1 + .../data/core/KTypedPropertyPathUnitTests.kt | 4 + 6 files changed, 182 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/springframework/data/core/MemberDescriptor.java b/src/main/java/org/springframework/data/core/MemberDescriptor.java index 11c34cdc9d..1c6fb8660e 100644 --- a/src/main/java/org/springframework/data/core/MemberDescriptor.java +++ b/src/main/java/org/springframework/data/core/MemberDescriptor.java @@ -141,15 +141,26 @@ public ResolvableType getType() { } + 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 MemberDescriptor { + 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(); @@ -186,4 +197,54 @@ public ResolvableType getType() { } + /** + * Value object describing a Kotlin property in the context of an owning class. + */ + record KPropertyPathDescriptor(KPropertyReferenceImpl property) implements KotlinMemberDescriptor { + + static KPropertyPathDescriptor create(KPropertyReferenceImpl propertyReference) { + return new KPropertyPathDescriptor(propertyReference); + } + + @Override + public KProperty1 getKotlinProperty() { + return property(); + } + + @Override + public Class getOwner() { + return getMember().getDeclaringClass(); + } + + @Override + public Member getMember() { + + Method javaGetter = ReflectJvmMapping.getJavaGetter(property()); + if (javaGetter != null) { + return javaGetter; + } + + Field javaField = ReflectJvmMapping.getJavaField(property()); + + if (javaField != null) { + return javaField; + } + + throw new IllegalStateException("Cannot resolve member for property '%s'".formatted(property().getName())); + } + + @Override + public ResolvableType getType() { + + Member member = getMember(); + + if (member instanceof Method m) { + return ResolvableType.forMethodReturnType(m, getOwner()); + } + + return ResolvableType.forField((Field) member, getOwner()); + } + + } + } diff --git a/src/main/java/org/springframework/data/core/PropertyReferences.java b/src/main/java/org/springframework/data/core/PropertyReferences.java index 67174be6c3..46a8f50ab0 100644 --- a/src/main/java/org/springframework/data/core/PropertyReferences.java +++ b/src/main/java/org/springframework/data/core/PropertyReferences.java @@ -24,11 +24,11 @@ import java.util.WeakHashMap; 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.KPropertyReferenceDescriptor; import org.springframework.data.core.MemberDescriptor.MethodDescriptor; import org.springframework.util.ConcurrentReferenceHashMap; @@ -96,7 +96,14 @@ private static PropertyReferenceMetadata read(PropertyReference lambda) { MemberDescriptor reference = reader.read(lambda); - if (KotlinDetector.isKotlinReflectPresent() && reference instanceof KPropertyReferenceDescriptor kProperty) { + 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 KPropertyReferenceMetadata.of(kProperty); } @@ -198,8 +205,9 @@ static class KPropertyReferenceMetadata extends PropertyReferenceMetadata { /** * Create a new {@code KPropertyReferenceMetadata}. */ - public static KPropertyReferenceMetadata of(KPropertyReferenceDescriptor descriptor) { - return new KPropertyReferenceMetadata(descriptor.getOwner(), descriptor.property(), descriptor.getType()); + public static KPropertyReferenceMetadata of(MemberDescriptor.KotlinMemberDescriptor descriptor) { + return new KPropertyReferenceMetadata(descriptor.getOwner(), descriptor.getKotlinProperty(), + descriptor.getType()); } public KProperty getProperty() { diff --git a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java index 36a52ffe2f..f4b70eabb3 100644 --- a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java +++ b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java @@ -48,6 +48,7 @@ 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; @@ -148,6 +149,11 @@ public MemberDescriptor read(Object lambdaObject) { && captured instanceof KProperty1 kProperty) { return new KPropertyReferenceDescriptor(JvmClassMappingKt.getJavaClass(owner), kProperty); } + + if (captured != null // + && captured instanceof KPropertyReferenceImpl propRef) { + return KPropertyPathDescriptor.create(propRef); + } } assertNotConstructor(lambda); diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index c7e72454f7..d8a0077688 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -16,6 +16,9 @@ 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.beans.Introspector; import java.beans.PropertyDescriptor; @@ -30,6 +33,7 @@ import java.util.stream.Stream; import org.jspecify.annotations.Nullable; + import org.springframework.beans.BeanUtils; import org.springframework.core.KotlinDetector; import org.springframework.core.ResolvableType; @@ -95,10 +99,69 @@ public static TypedPropertyPath compose(TypedPropertyPath } /** - * Create a {@link TypedPropertyPath} from a {@link PropertyReference}. + * Introspect {@link PropertyReference} and return an introspected {@link ResolvedTypedPropertyPath} variant. */ + @SuppressWarnings({ "unchecked", "rawtypes" }) public static TypedPropertyPath of(PropertyReference lambda) { - return new PropertyReferenceWrapper<>(PropertyReferences.of(lambda)); + + if (lambda instanceof Resolved) { + return (TypedPropertyPath) lambda; + } + + Map, TypedPropertyPath> cache; + synchronized (resolved) { + cache = (Map) resolved.computeIfAbsent(lambda.getClass().getClassLoader(), + k -> new ConcurrentReferenceHashMap<>()); + } + + return (TypedPropertyPath) cache.computeIfAbsent(lambda, o -> doResolvePropertyReference(lambda)); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static TypedPropertyPath doResolvePropertyReference(PropertyReference lambda) { + + if (lambda instanceof PropertyReferences.ResolvedPropertyReferenceSupport resolved) { + return new PropertyReferenceWrapper<>(resolved); + } + + PropertyPathMetadata metadata = getMetadata(lambda); + + if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyPathMetadata kMetadata + && kMetadata.getProperty() instanceof KPropertyReferenceImpl ref) { + return KotlinDelegate.of(ref); + } + + return new ResolvedPropertyReference<>(lambda, metadata); + } + + static class KotlinDelegate { + + @SuppressWarnings({ "rawtypes", "unchecked" }) + public static TypedPropertyPath of(KProperty1 property) { + + if (property instanceof KPropertyReferenceImpl 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, property)); + return new TypedPropertyPaths.ResolvedKPropertyPath(metadata); + } + + if (property.getGetter().getProperty() instanceof KProperty1Impl impl) { + return of(impl); + } + + throw new IllegalArgumentException("Property " + property.getName() + " is not a KProperty"); + } } /** @@ -117,8 +180,8 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) k -> new ConcurrentReferenceHashMap<>()); } - return (TypedPropertyPath) cache.computeIfAbsent(lambda, - o -> new ResolvedTypedPropertyPath(o, getMetadata(lambda))); + return (TypedPropertyPath) cache.computeIfAbsent(lambda, + o -> new ResolvedTypedPropertyPath(o, doGetMetadata(lambda))); } /** @@ -134,26 +197,44 @@ public static TypedPropertyPath of(TypedPropertyPath delegate return new ResolvedTypedPropertyPath<>(delegate, metadata); } + /** + * Retrieve {@link PropertyPathMetadata} for a given {@link PropertyReference}. + */ + public static PropertyPathMetadata getMetadata(PropertyReference lambda) { + return doGetMetadata(lambda); + } + /** * Retrieve {@link PropertyPathMetadata} for a given {@link TypedPropertyPath}. */ public static PropertyPathMetadata getMetadata(TypedPropertyPath lambda) { + return doGetMetadata(lambda); + } + + private static PropertyPathMetadata doGetMetadata(Object lambda) { Map cache; + synchronized (lambdas) { cache = lambdas.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); } - Map lambdaMap = cache; - return lambdaMap.computeIfAbsent(lambda, o -> read(lambda)); + return cache.computeIfAbsent(lambda, o -> read(lambda)); } - private static PropertyPathMetadata read(TypedPropertyPath lambda) { + private static PropertyPathMetadata read(Object lambda) { MemberDescriptor reference = reader.read(lambda); - if (KotlinDetector.isKotlinReflectPresent() && reference instanceof KPropertyReferenceDescriptor kProperty) { - return KPropertyPathMetadata.of(kProperty); + if (KotlinDetector.isKotlinReflectPresent()) { + + if (reference instanceof KPropertyReferenceDescriptor kProperty) { + return KPropertyPathMetadata.of(kProperty); + } + + if (reference instanceof MemberDescriptor.KPropertyPathDescriptor kProperty) { + return KPropertyPathMetadata.of(kProperty); + } } if (reference instanceof MethodDescriptor method) { @@ -258,6 +339,13 @@ public static KPropertyPathMetadata of(KPropertyReferenceDescriptor descriptor) return new KPropertyPathMetadata(descriptor.getOwner(), descriptor.property(), descriptor.getType()); } + /** + * Create a new {@code KPropertyPathMetadata}. + */ + public static KPropertyPathMetadata of(MemberDescriptor.KPropertyPathDescriptor descriptor) { + return new KPropertyPathMetadata(descriptor.getOwner(), descriptor.property(), descriptor.getType()); + } + public KProperty getProperty() { return property; } diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt index a2c5aaf1bf..b9fa84a731 100644 --- a/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt @@ -37,6 +37,7 @@ class KPropertyReferenceUnitTests { @Test // GH-3400 fun composedReferenceCreationShouldFail() { + assertThatIllegalArgumentException().isThrownBy { PropertyReference.of(Person::address / Address::city) } assertThatIllegalArgumentException().isThrownBy { KPropertyReference.of(Person::address / Address::city) } } diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt index a1328f1251..a90ed4c741 100644 --- a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -91,6 +91,10 @@ class KTypedPropertyPathUnitTests { @Test // GH-3400 fun shouldCreateComposed() { + assertThat( + PropertyPath.of(Person::address / Address::city).toDotPath() + ).isEqualTo("address.city") + val path = KTypedPropertyPath.of(Person::address / Address::city) assertThat(path.toDotPath()).isEqualTo("address.city") From 3cbdefda0a5e6357d7003801e79d71367059dc2e Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 17 Nov 2025 12:25:30 +0100 Subject: [PATCH 18/25] Avoid accidental KProperty1 loading. --- .../data/core/TypedPropertyPaths.java | 40 ++++++++++++------- .../data/core/KPropertyExtensions.kt | 2 + 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index d8a0077688..231f0c82ac 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -38,6 +38,7 @@ import org.springframework.core.KotlinDetector; import org.springframework.core.ResolvableType; import org.springframework.data.core.MemberDescriptor.FieldDescriptor; +import org.springframework.data.core.MemberDescriptor.KPropertyPathDescriptor; import org.springframework.data.core.MemberDescriptor.KPropertyReferenceDescriptor; import org.springframework.data.core.MemberDescriptor.MethodDescriptor; import org.springframework.util.CompositeIterator; @@ -126,18 +127,23 @@ public static TypedPropertyPath of(PropertyReference lambda) PropertyPathMetadata metadata = getMetadata(lambda); - if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyPathMetadata kMetadata + if (KotlinDetector.isKotlinReflectPresent()) { + if (metadata instanceof KPropertyPathMetadata kMetadata && kMetadata.getProperty() instanceof KPropertyReferenceImpl ref) { - return KotlinDelegate.of(ref); + return KotlinDelegate.of(ref); + } } return new ResolvedPropertyReference<>(lambda, metadata); } + /** + * Delegate to handle property path composition of single-property and property-path KProperty1 references. + */ static class KotlinDelegate { @SuppressWarnings({ "rawtypes", "unchecked" }) - public static TypedPropertyPath of(KProperty1 property) { + public static TypedPropertyPath of(Object property) { if (property instanceof KPropertyReferenceImpl paths) { @@ -152,16 +158,22 @@ public static TypedPropertyPath of(KProperty1 property) { Class owner = impl.getJavaField() != null ? impl.getJavaField().getDeclaringClass() : impl.getGetter().getCaller().getMember().getDeclaringClass(); KPropertyPathMetadata metadata = TypedPropertyPaths.KPropertyPathMetadata - .of(MemberDescriptor.KPropertyReferenceDescriptor.create(owner, property)); + .of(MemberDescriptor.KPropertyReferenceDescriptor.create(owner, (KProperty1) impl)); return new TypedPropertyPaths.ResolvedKPropertyPath(metadata); } - if (property.getGetter().getProperty() instanceof KProperty1Impl impl) { - return of(impl); + 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.getName() + " is not a KProperty"); + throw new IllegalArgumentException("Property " + property + " is not a KProperty"); } + } /** @@ -190,8 +202,8 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) @SuppressWarnings({ "unchecked", "rawtypes" }) public static TypedPropertyPath of(TypedPropertyPath delegate, PropertyPathMetadata metadata) { - if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyPathMetadata kmp) { - return new ResolvedKPropertyPath(kmp.getProperty(), metadata); + if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyPathMetadata) { + return new ResolvedKPropertyPath(((KPropertyPathMetadata) metadata).getProperty(), metadata); } return new ResolvedTypedPropertyPath<>(delegate, metadata); @@ -228,12 +240,12 @@ private static PropertyPathMetadata read(Object lambda) { if (KotlinDetector.isKotlinReflectPresent()) { - if (reference instanceof KPropertyReferenceDescriptor kProperty) { - return KPropertyPathMetadata.of(kProperty); + if (reference instanceof KPropertyReferenceDescriptor descriptor) { + return KPropertyPathMetadata.of(descriptor); } - if (reference instanceof MemberDescriptor.KPropertyPathDescriptor kProperty) { - return KPropertyPathMetadata.of(kProperty); + if (reference instanceof KPropertyPathDescriptor descriptor) { + return KPropertyPathMetadata.of(descriptor); } } @@ -342,7 +354,7 @@ public static KPropertyPathMetadata of(KPropertyReferenceDescriptor descriptor) /** * Create a new {@code KPropertyPathMetadata}. */ - public static KPropertyPathMetadata of(MemberDescriptor.KPropertyPathDescriptor descriptor) { + public static KPropertyPathMetadata of(KPropertyPathDescriptor descriptor) { return new KPropertyPathMetadata(descriptor.getOwner(), descriptor.property(), descriptor.getType()); } diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt index 2aeb9550b5..94097f270c 100644 --- a/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt @@ -51,6 +51,7 @@ fun KProperty1.toPath(): TypedPropertyPath = * @since 4.1 */ @JvmName("div") +@Suppress("UNCHECKED_CAST") operator fun KProperty1.div(other: KProperty1): KProperty1 = KSinglePropertyReference(this, other) as KProperty1 @@ -71,5 +72,6 @@ operator fun KProperty1.div(other: KProperty1): KPropert * @since 4.1 */ @JvmName("divIterable") +@Suppress("UNCHECKED_CAST") operator fun KProperty1?>.div(other: KProperty1): KProperty1 = KIterablePropertyReference(this, other) as KProperty1 From fc05f184582b757808b64bcfce2b91bad78265c0 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 17 Nov 2025 14:07:54 +0100 Subject: [PATCH 19/25] Avoid using internal Kotlin types in Java to avoid Javadoc failures. --- .../java/org/springframework/data/core/MemberDescriptor.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/springframework/data/core/MemberDescriptor.java b/src/main/java/org/springframework/data/core/MemberDescriptor.java index 1c6fb8660e..4ce02107f5 100644 --- a/src/main/java/org/springframework/data/core/MemberDescriptor.java +++ b/src/main/java/org/springframework/data/core/MemberDescriptor.java @@ -200,9 +200,9 @@ public ResolvableType getType() { /** * Value object describing a Kotlin property in the context of an owning class. */ - record KPropertyPathDescriptor(KPropertyReferenceImpl property) implements KotlinMemberDescriptor { + record KPropertyPathDescriptor(KProperty1 property) implements KotlinMemberDescriptor { - static KPropertyPathDescriptor create(KPropertyReferenceImpl propertyReference) { + static KPropertyPathDescriptor create(KProperty1 propertyReference) { return new KPropertyPathDescriptor(propertyReference); } From ef595f0065d69042118658680bd03d8631e3e105 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 17 Nov 2025 14:08:04 +0100 Subject: [PATCH 20/25] Polishing. Refine type naming. --- .../data/core/SerializableLambdaReader.java | 2 +- .../org/springframework/data/core/TypedPropertyPaths.java | 4 ++-- .../org/springframework/data/core/KPropertyExtensions.kt | 4 ++-- .../core/{KPropertyReferenceImpl.kt => KPropertyPath.kt} | 8 ++++---- .../data/core/PropertyReferenceExtensions.kt | 6 +++--- .../data/core/TypedPropertyPathExtensions.kt | 6 +++--- .../data/core/KTypedPropertyPathUnitTests.kt | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) rename src/main/kotlin/org/springframework/data/core/{KPropertyReferenceImpl.kt => KPropertyPath.kt} (90%) diff --git a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java index f4b70eabb3..4505f1d5f3 100644 --- a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java +++ b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java @@ -151,7 +151,7 @@ public MemberDescriptor read(Object lambdaObject) { } if (captured != null // - && captured instanceof KPropertyReferenceImpl propRef) { + && captured instanceof KPropertyPath propRef) { return KPropertyPathDescriptor.create(propRef); } } diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index 231f0c82ac..7afe4f6e10 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -129,7 +129,7 @@ public static TypedPropertyPath of(PropertyReference lambda) if (KotlinDetector.isKotlinReflectPresent()) { if (metadata instanceof KPropertyPathMetadata kMetadata - && kMetadata.getProperty() instanceof KPropertyReferenceImpl ref) { + && kMetadata.getProperty() instanceof KPropertyPath ref) { return KotlinDelegate.of(ref); } } @@ -145,7 +145,7 @@ static class KotlinDelegate { @SuppressWarnings({ "rawtypes", "unchecked" }) public static TypedPropertyPath of(Object property) { - if (property instanceof KPropertyReferenceImpl paths) { + if (property instanceof KPropertyPath paths) { TypedPropertyPath parent = of(paths.getProperty()); TypedPropertyPath child = of(paths.getLeaf()); diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt index 94097f270c..9602689c50 100644 --- a/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt @@ -39,7 +39,7 @@ fun KProperty1.toPath(): TypedPropertyPath = KTypedPropertyPath.of(this) /** - * Builds [KPropertyReferenceImpl] from Property References. + * Builds [KPropertyPath] from Property References. * Refer to a nested property in an embeddable or association. * * For example, referring to the field "author.name": @@ -56,7 +56,7 @@ operator fun KProperty1.div(other: KProperty1): KPropert KSinglePropertyReference(this, other) as KProperty1 /** - * Builds [KPropertyReferenceImpl] from Property References. + * 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 diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyReferenceImpl.kt b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt similarity index 90% rename from src/main/kotlin/org/springframework/data/core/KPropertyReferenceImpl.kt rename to src/main/kotlin/org/springframework/data/core/KPropertyPath.kt index d7d97c7f62..51377944dd 100644 --- a/src/main/kotlin/org/springframework/data/core/KPropertyReferenceImpl.kt +++ b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt @@ -28,7 +28,7 @@ import kotlin.reflect.KProperty1 * @author Yoann de Martino * @since 4.1 */ -internal interface KPropertyReferenceImpl : KProperty1 { +internal interface KPropertyPath : KProperty1 { val property: KProperty1 val leaf: KProperty1<*, P> } @@ -36,7 +36,7 @@ internal interface KPropertyReferenceImpl : KProperty1 { internal class KSinglePropertyReference( val parent: KProperty1, val child: KProperty1 -) : KProperty1 by child as KProperty1, KPropertyReferenceImpl { +) : KProperty1 by child as KProperty1, KPropertyPath { override fun get(receiver: T): P { @@ -71,7 +71,7 @@ internal class KSinglePropertyReference( internal class KIterablePropertyReference( val parent: KProperty1?>, val child: KProperty1 -) : KProperty1 by child as KProperty1, KPropertyReferenceImpl { +) : KProperty1 by child as KProperty1, KPropertyPath { override fun get(receiver: T): P { throw UnsupportedOperationException("Collection retrieval not supported") @@ -95,7 +95,7 @@ internal class KIterablePropertyReference( */ internal fun asString(property: KProperty<*>): String { return when (property) { - is KPropertyReferenceImpl<*, *> -> + is KPropertyPath<*, *> -> "${asString(property.property)}.${property.leaf.name}" else -> property.name diff --git a/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt index 8b1f817d5c..b8ed3a8888 100644 --- a/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt @@ -79,8 +79,8 @@ class KPropertyReference { */ fun of(property: KProperty): PropertyReference { - if (property is KPropertyReferenceImpl<*, *>) { - throw IllegalArgumentException("Property reference ${property.name} must not be a property path") + if (property is KPropertyPath<*, *>) { + throw IllegalArgumentException("Property reference '${property.toDotPath()}' must be a single property reference, not a property path") } if (property is KProperty1<*, *>) { @@ -97,7 +97,7 @@ class KPropertyReference { return PropertyReferences.ResolvedKPropertyReference(metadata) } - throw IllegalArgumentException("Property ${property.name} is not a KProperty") + throw IllegalArgumentException("Property '${property.name}' is not a KProperty") } } diff --git a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt index a9319a2876..d306b913a5 100644 --- a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt @@ -77,9 +77,9 @@ class KTypedPropertyPath { */ fun of(property: KProperty): TypedPropertyPath { - if (property is KPropertyReferenceImpl<*, *>) { + if (property is KPropertyPath<*, *>) { - val paths = property as KPropertyReferenceImpl<*, *> + val paths = property as KPropertyPath<*, *> val parent = of(paths.property) val child = of(paths.leaf) @@ -104,7 +104,7 @@ class KTypedPropertyPath { return TypedPropertyPaths.ResolvedKPropertyPath(metadata) } - throw IllegalArgumentException("Property ${property.name} is not a KProperty") + throw IllegalArgumentException("Property '${property.name}' is not a KProperty") } } diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt index a90ed4c741..81af3764a9 100644 --- a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -9,7 +9,7 @@ import org.junit.jupiter.params.provider.MethodSource import java.util.stream.Stream /** - * Unit tests for [KPropertyReferenceImpl] and related functionality. + * Unit tests for [KPropertyPath] and related functionality. * * @author Mark Paluch */ From b313dfe9109a9f713e584a4f2abcc75cb57cb095 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 18 Nov 2025 15:52:10 +0100 Subject: [PATCH 21/25] Revise lambda caching. Remove lambda cache in favor of resolved property references/PropertyPaths. --- .../data/core/PropertyReference.java | 13 +- .../data/core/PropertyReferences.java | 16 +-- .../data/core/TypedPropertyPath.java | 15 ++- .../data/core/TypedPropertyPaths.java | 113 +++++++----------- 4 files changed, 67 insertions(+), 90 deletions(-) diff --git a/src/main/java/org/springframework/data/core/PropertyReference.java b/src/main/java/org/springframework/data/core/PropertyReference.java index 8940a7dfed..be1758de88 100644 --- a/src/main/java/org/springframework/data/core/PropertyReference.java +++ b/src/main/java/org/springframework/data/core/PropertyReference.java @@ -81,12 +81,15 @@ public interface PropertyReference extends Serial * Syntax sugar to create a {@link PropertyReference} from a method reference or lambda for a collection property. *

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

property type. * @return the typed property reference. */ + @SuppressWarnings({ "unchecked", "rawtypes" }) static PropertyReference ofMany(PropertyReference> property) { return (PropertyReference) PropertyReferences.of(property); } @@ -106,7 +109,7 @@ static PropertyReference ofMany(PropertyReference getOwningType() { - return PropertyReferences.getMetadata(this).owner(); + return PropertyReferences.of(this).getOwningType(); } /** @@ -115,7 +118,7 @@ default TypeInformation getOwningType() { * @return the current property name. */ default String getName() { - return PropertyReferences.getMetadata(this).property(); + return PropertyReferences.of(this).getName(); } /** @@ -136,7 +139,7 @@ default Class getType() { * @return the type information for the property at this segment. */ default TypeInformation getTypeInformation() { - return PropertyReferences.getMetadata(this).propertyType(); + return PropertyReferences.of(this).getTypeInformation(); } /** @@ -166,12 +169,16 @@ default boolean isCollection() { /** * Extend the property to a property path by appending the {@code next} path segment and return a new property path * instance. + *

+ * Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from + * {@code Iterable <P>} and a single {@code P} cannot represent a collection of items. * * @param next the next property path segment as method reference or lambda accepting the owner object {@code P} type * and returning {@code N} as result of accessing a property. * @param the new property value type. * @return a new composed {@code TypedPropertyPath}. */ + @SuppressWarnings({ "unchecked", "rawtypes" }) default TypedPropertyPath thenMany( PropertyReference> next) { return (TypedPropertyPath) TypedPropertyPaths.compose(this, next); diff --git a/src/main/java/org/springframework/data/core/PropertyReferences.java b/src/main/java/org/springframework/data/core/PropertyReferences.java index 46a8f50ab0..7c02812a0a 100644 --- a/src/main/java/org/springframework/data/core/PropertyReferences.java +++ b/src/main/java/org/springframework/data/core/PropertyReferences.java @@ -40,7 +40,6 @@ */ class PropertyReferences { - private static final Map> lambdas = new WeakHashMap<>(); private static final Map, ResolvedPropertyReference>> resolved = new WeakHashMap<>(); private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyReference.class, @@ -62,7 +61,7 @@ public static PropertyReference of(PropertyReference lambda) } return (PropertyReference) cache.computeIfAbsent(lambda, - o -> new ResolvedPropertyReference(o, getMetadata(lambda))); + o -> new ResolvedPropertyReference(o, read(lambda))); } /** @@ -79,19 +78,6 @@ public static PropertyReference of(PropertyReference delegate return new ResolvedPropertyReference<>(delegate, metadata); } - /** - * Retrieve {@link PropertyReferenceMetadata} for a given {@link PropertyReference}. - */ - public static PropertyReferenceMetadata getMetadata(PropertyReference lambda) { - - Map cache; - synchronized (lambdas) { - cache = lambdas.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); - } - - return cache.computeIfAbsent(lambda, o -> read(lambda)); - } - private static PropertyReferenceMetadata read(PropertyReference lambda) { MemberDescriptor reference = reader.read(lambda); diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index 1ab23c7e4c..3d3c08b933 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -97,13 +97,16 @@ public interface TypedPropertyPath extends Proper * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda for a collection property. *

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

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

property type. * @return the typed property path. - * @since 4.1 */ + @SuppressWarnings({ "unchecked", "rawtypes" }) static TypedPropertyPath ofMany(TypedPropertyPath> propertyPath) { return (TypedPropertyPath) TypedPropertyPaths.of(propertyPath); } @@ -119,17 +122,17 @@ static TypedPropertyPath ofMany(TypedPropertyPath getOwningType() { - return TypedPropertyPaths.getMetadata(this).owner(); + return TypedPropertyPaths.of(this).getOwningType(); } @Override default String getSegment() { - return TypedPropertyPaths.getMetadata(this).property(); + return TypedPropertyPaths.of(this).getSegment(); } @Override default TypeInformation getTypeInformation() { - return TypedPropertyPaths.getMetadata(this).propertyType(); + return TypedPropertyPaths.of(this).getTypeInformation(); } @Override @@ -162,12 +165,16 @@ default Iterator iterator() { /** * Extend the property path by appending the {@code next} path segment and returning a new property path instance. + *

+ * Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from + * {@code Iterable <P>} and a single {@code P} cannot represent a collection of items. * * @param next the next property path segment as method reference or lambda accepting the owner object {@code P} type * and returning {@code N} as result of accessing a property. * @param the new property value type. * @return a new composed {@code TypedPropertyPath}. */ + @SuppressWarnings({ "unchecked", "rawtypes" }) default TypedPropertyPath thenMany( PropertyReference> next) { return (TypedPropertyPath) TypedPropertyPaths.compose(this, PropertyReference.of(next)); diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index 7afe4f6e10..08879fa212 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -52,7 +52,6 @@ */ class TypedPropertyPaths { - private static final Map> lambdas = new WeakHashMap<>(); private static final Map>> resolved = new WeakHashMap<>(); private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyPath.class, @@ -125,7 +124,7 @@ public static TypedPropertyPath of(PropertyReference lambda) return new PropertyReferenceWrapper<>(resolved); } - PropertyPathMetadata metadata = getMetadata(lambda); + PropertyPathMetadata metadata = read(lambda); if (KotlinDetector.isKotlinReflectPresent()) { if (metadata instanceof KPropertyPathMetadata kMetadata @@ -137,45 +136,6 @@ public static TypedPropertyPath of(PropertyReference lambda) return new ResolvedPropertyReference<>(lambda, metadata); } - /** - * Delegate to handle property path composition of single-property and property-path KProperty1 references. - */ - static class KotlinDelegate { - - @SuppressWarnings({ "rawtypes", "unchecked" }) - public static TypedPropertyPath of(Object property) { - - if (property instanceof KPropertyPath paths) { - - TypedPropertyPath parent = of(paths.getProperty()); - TypedPropertyPath child = of(paths.getLeaf()); - - return TypedPropertyPaths.compose(parent, child); - } - - if (property instanceof KPropertyImpl impl) { - - Class owner = impl.getJavaField() != null ? impl.getJavaField().getDeclaringClass() - : impl.getGetter().getCaller().getMember().getDeclaringClass(); - KPropertyPathMetadata metadata = TypedPropertyPaths.KPropertyPathMetadata - .of(MemberDescriptor.KPropertyReferenceDescriptor.create(owner, (KProperty1) impl)); - return new TypedPropertyPaths.ResolvedKPropertyPath(metadata); - } - - if (property instanceof KProperty1 kProperty) { - - if (kProperty.getGetter().getProperty() instanceof KProperty1Impl impl) { - return of(impl); - } - - throw new IllegalArgumentException("Property " + kProperty.getName() + " is not a KProperty"); - } - - throw new IllegalArgumentException("Property " + property + " is not a KProperty"); - } - - } - /** * Introspect {@link TypedPropertyPath} and return an introspected {@link ResolvedTypedPropertyPath} variant. */ @@ -193,14 +153,14 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) } return (TypedPropertyPath) cache.computeIfAbsent(lambda, - o -> new ResolvedTypedPropertyPath(o, doGetMetadata(lambda))); + o -> new ResolvedTypedPropertyPath(o, read(lambda))); } /** * Retrieve {@link PropertyPathMetadata} for a given {@link TypedPropertyPath}. */ @SuppressWarnings({ "unchecked", "rawtypes" }) - public static TypedPropertyPath of(TypedPropertyPath delegate, PropertyPathMetadata metadata) { + private static TypedPropertyPath aaa(TypedPropertyPath delegate, PropertyPathMetadata metadata) { if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyPathMetadata) { return new ResolvedKPropertyPath(((KPropertyPathMetadata) metadata).getProperty(), metadata); @@ -209,31 +169,6 @@ public static TypedPropertyPath of(TypedPropertyPath delegate return new ResolvedTypedPropertyPath<>(delegate, metadata); } - /** - * Retrieve {@link PropertyPathMetadata} for a given {@link PropertyReference}. - */ - public static PropertyPathMetadata getMetadata(PropertyReference lambda) { - return doGetMetadata(lambda); - } - - /** - * Retrieve {@link PropertyPathMetadata} for a given {@link TypedPropertyPath}. - */ - public static PropertyPathMetadata getMetadata(TypedPropertyPath lambda) { - return doGetMetadata(lambda); - } - - private static PropertyPathMetadata doGetMetadata(Object lambda) { - - Map cache; - - synchronized (lambdas) { - cache = lambdas.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); - } - - return cache.computeIfAbsent(lambda, o -> read(lambda)); - } - private static PropertyPathMetadata read(Object lambda) { MemberDescriptor reference = reader.read(lambda); @@ -363,6 +298,48 @@ public KProperty getProperty() { } } + /** + * Delegate to handle property path composition of single-property and property-path KProperty1 references. + */ + static class KotlinDelegate { + + @SuppressWarnings({ "rawtypes", "unchecked" }) + public static TypedPropertyPath of(Object property) { + + if (property instanceof KPropertyPath paths) { + + TypedPropertyPath parent = of(paths.getProperty()); + TypedPropertyPath child = of(paths.getLeaf()); + + return TypedPropertyPaths.compose(parent, child); + } + + if (property instanceof KPropertyImpl impl) { + + Class owner = impl.getJavaField() != null ? impl.getJavaField().getDeclaringClass() + : impl.getGetter().getCaller().getMember().getDeclaringClass(); + KPropertyPathMetadata metadata = TypedPropertyPaths.KPropertyPathMetadata + .of(MemberDescriptor.KPropertyReferenceDescriptor.create(owner, (KProperty1) impl)); + return new TypedPropertyPaths.ResolvedKPropertyPath(metadata); + } + + if (property instanceof KProperty1 kProperty) { + + if (kProperty.getGetter().getProperty() instanceof KProperty1Impl impl) { + return of(impl); + } + + throw new IllegalArgumentException("Property " + kProperty.getName() + " is not a KProperty"); + } + + throw new IllegalArgumentException("Property " + property + " is not a KProperty"); + } + + } + + /** + * Marker interface to indicate a resolved and processed property path. + */ interface Resolved { } From 68efb786468997b1d196b99afb6d3766a26b0d1e Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 18 Nov 2025 16:00:35 +0100 Subject: [PATCH 22/25] Refine Kotlin support. Inline KProperty references in TypedPropertyPaths. --- .../core/TypedPropertyPathBenchmarks.java | 12 ++--- .../modules/ROOT/pages/property-paths.adoc | 10 ++-- .../data/core/TypedPropertyPath.java | 4 +- .../data/core/TypedPropertyPaths.java | 23 ++++---- .../data/core/KPropertyExtensions.kt | 4 +- .../data/core/KPropertyPath.kt | 14 ++++- .../data/core/TypedPropertyPathExtensions.kt | 22 +------- .../data/domain/SortUnitTests.java | 2 +- .../data/core/KPropertyExtensionsTests.kt | 6 +-- .../data/core/KTypedPropertyPathUnitTests.kt | 53 ++----------------- .../data/core/PropertyReferenceKtUnitTests.kt | 38 +++++++++++++ .../data/core/TypedPropertyPathKtUnitTests.kt | 33 ++++++++---- 12 files changed, 111 insertions(+), 110 deletions(-) create mode 100644 src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt diff --git a/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java index 8dc57aaccd..759af41a44 100644 --- a/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java +++ b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java @@ -29,32 +29,32 @@ public class TypedPropertyPathBenchmarks extends BenchmarkSettings { @Benchmark public Object benchmarkMethodReference() { - return TypedPropertyPath.ofReference(Person::firstName); + return TypedPropertyPath.ofProperty(Person::firstName); } @Benchmark public Object benchmarkComposedMethodReference() { - return TypedPropertyPath.ofReference(Person::address).then(Address::city); + return TypedPropertyPath.ofProperty(Person::address).then(Address::city); } @Benchmark public TypedPropertyPath benchmarkLambda() { - return TypedPropertyPath.ofReference(person -> person.firstName()); + return TypedPropertyPath.ofProperty(person -> person.firstName()); } @Benchmark public TypedPropertyPath benchmarkComposedLambda() { - return TypedPropertyPath.ofReference((Person person) -> person.address()).then(address -> address.city()); + return TypedPropertyPath.ofProperty((Person person) -> person.address()).then(address -> address.city()); } @Benchmark public Object dotPath() { - return TypedPropertyPath.ofReference(Person::firstName).toDotPath(); + return TypedPropertyPath.ofProperty(Person::firstName).toDotPath(); } @Benchmark public Object composedDotPath() { - return TypedPropertyPath.ofReference(Person::address).then(Address::city).toDotPath(); + return TypedPropertyPath.ofProperty(Person::address).then(Address::city).toDotPath(); } record Person(String firstName, String lastName, Address address) { diff --git a/src/main/antora/modules/ROOT/pages/property-paths.adoc b/src/main/antora/modules/ROOT/pages/property-paths.adoc index 4cd407608e..381d5b432c 100644 --- a/src/main/antora/modules/ROOT/pages/property-paths.adoc +++ b/src/main/antora/modules/ROOT/pages/property-paths.adoc @@ -33,7 +33,7 @@ class Person { Address address; List

previousAddresses; - String getFirstname() { … }; // other property accessors omitted for brevity + String getFirstname() { … } // other property accessors omitted for brevity } @@ -140,11 +140,9 @@ Kotlin:: [source,kotlin,role="secondary"] ---- // Kotlin API -KTypedPropertyPath.of(Person::address).then(Address::city) - -// Extension for KProperty1 -KTypedPropertyPath.of(Person::address / Address::city) +TypedPropertyPath.of(Person::address / Address::city) +// as extension function (Person::address / Address::city).toPath() ---- ====== @@ -177,7 +175,7 @@ Kotlin:: Sort.by(Person::firstName, Person::lastName) // Composed navigation -Sort.by((Person::address / Address::city).toPath(), Person::lastName) +Sort.by(Person::address / Address::city, Person::lastName) ---- ====== diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index 3d3c08b933..edad7c42c9 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -66,7 +66,7 @@ public interface TypedPropertyPath extends PropertyPath, Serializable { /** - * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda. + * Syntax sugar to create a {@link TypedPropertyPath} from a property described as method reference or lambda. *

* This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference or lambda. * @@ -75,7 +75,7 @@ public interface TypedPropertyPath extends Proper * @param

property type. * @return the typed property path. */ - static TypedPropertyPath ofReference(PropertyReference propertyPath) { + static TypedPropertyPath ofProperty(PropertyReference propertyPath) { return TypedPropertyPaths.of(propertyPath); } diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index 08879fa212..83c7a7fb35 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -114,7 +114,7 @@ public static TypedPropertyPath of(PropertyReference lambda) k -> new ConcurrentReferenceHashMap<>()); } - return (TypedPropertyPath) cache.computeIfAbsent(lambda, o -> doResolvePropertyReference(lambda)); + return (TypedPropertyPath) cache.computeIfAbsent(lambda, TypedPropertyPaths::doResolvePropertyReference); } @SuppressWarnings({ "rawtypes", "unchecked" }) @@ -146,27 +146,28 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) return lambda; } - Map, ResolvedTypedPropertyPath> cache; + Map, TypedPropertyPath> cache; synchronized (resolved) { cache = (Map) resolved.computeIfAbsent(lambda.getClass().getClassLoader(), k -> new ConcurrentReferenceHashMap<>()); } return (TypedPropertyPath) cache.computeIfAbsent(lambda, - o -> new ResolvedTypedPropertyPath(o, read(lambda))); + TypedPropertyPaths::doResolvePropertyPathReference); } - /** - * Retrieve {@link PropertyPathMetadata} for a given {@link TypedPropertyPath}. - */ - @SuppressWarnings({ "unchecked", "rawtypes" }) - private static TypedPropertyPath aaa(TypedPropertyPath delegate, PropertyPathMetadata metadata) { + private static TypedPropertyPath doResolvePropertyPathReference(TypedPropertyPath lambda) { + + PropertyPathMetadata metadata = read(lambda); - if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyPathMetadata) { - return new ResolvedKPropertyPath(((KPropertyPathMetadata) metadata).getProperty(), metadata); + if (KotlinDetector.isKotlinReflectPresent()) { + if (metadata instanceof KPropertyPathMetadata kMetadata + && kMetadata.getProperty() instanceof KPropertyPath ref) { + return KotlinDelegate.of(ref); + } } - return new ResolvedTypedPropertyPath<>(delegate, metadata); + return new ResolvedTypedPropertyPath<>(lambda, metadata); } private static PropertyPathMetadata read(Object lambda) { diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt index 9602689c50..b3c306feaa 100644 --- a/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/KPropertyExtensions.kt @@ -29,13 +29,13 @@ import kotlin.reflect.KProperty1 fun KProperty<*>.toDotPath(): String = asString(this) /** - * Extension for [KProperty1] providing an `toPath` function to create a [TypedPropertyPath]. + * Extension for [KProperty1] providing an `toPropertyPath` function to create a [TypedPropertyPath]. * * @author Mark Paluch * @since 4.1 * @see org.springframework.data.core.PropertyPath.toDotPath */ -fun KProperty1.toPath(): TypedPropertyPath = +fun KProperty1.toPropertyPath(): TypedPropertyPath = KTypedPropertyPath.of(this) /** diff --git a/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt index 51377944dd..796d60c29d 100644 --- a/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt +++ b/src/main/kotlin/org/springframework/data/core/KPropertyPath.kt @@ -31,8 +31,15 @@ import kotlin.reflect.KProperty1 internal interface KPropertyPath : KProperty1 { val property: KProperty1 val leaf: KProperty1<*, P> + } +/** + * Abstraction of a single property reference wrapping [KProperty1]. + * + * @author Mark Paluch + * @since 4.1 + */ internal class KSinglePropertyReference( val parent: KProperty1, val child: KProperty1 @@ -57,6 +64,7 @@ internal class KSinglePropertyReference( get() = parent override val leaf: KProperty1<*, P> get() = child + } /** @@ -67,7 +75,6 @@ internal class KSinglePropertyReference( * @author Mikhail Polivakha * @since 4.1 */ - internal class KIterablePropertyReference( val parent: KProperty1?>, val child: KProperty1 @@ -85,20 +92,25 @@ internal class KIterablePropertyReference( get() = parent override val leaf: KProperty1<*, P> get() = child + } /** * Recursively construct field name for a nested property. + * * @author Tjeu Kayim * @author Mikhail Polivakha + * @since 4.1 */ internal fun asString(property: KProperty<*>): String { + return when (property) { is KPropertyPath<*, *> -> "${asString(property.property)}.${property.leaf.name}" else -> property.name } + } diff --git a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt index d306b913a5..5fc6b3e2aa 100644 --- a/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/TypedPropertyPathExtensions.kt @@ -25,32 +25,12 @@ 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 TypedPropertyPath.then(next: KProperty1): TypedPropertyPath { - val nextPath = KTypedPropertyPath.of(next) as TypedPropertyPath - return TypedPropertyPaths.compose(this, nextPath) -} - -/** - * Extension function to compose a [TypedPropertyPath] with a [KProperty]. - * - * @since 4.1 - */ -fun TypedPropertyPath.then(next: KProperty): TypedPropertyPath { - val nextPath = KTypedPropertyPath.of(next) as TypedPropertyPath - return TypedPropertyPaths.compose(this, nextPath) -} - /** * Helper to create [TypedPropertyPath] from [KProperty]. * * @since 4.1 */ -class KTypedPropertyPath { +internal class KTypedPropertyPath { /** * Companion object for static factory methods. diff --git a/src/test/java/org/springframework/data/domain/SortUnitTests.java b/src/test/java/org/springframework/data/domain/SortUnitTests.java index 06d8f78508..d59fcbd93d 100755 --- a/src/test/java/org/springframework/data/domain/SortUnitTests.java +++ b/src/test/java/org/springframework/data/domain/SortUnitTests.java @@ -59,7 +59,7 @@ record PersonHolder(Person person) { assertThat(Sort.by(Person::getFirstName).iterator().next().getProperty()).isEqualTo("firstName"); assertThat( - Sort.by(TypedPropertyPath.ofReference(PersonHolder::person).then(Person::getFirstName)).iterator().next() + Sort.by(TypedPropertyPath.ofProperty(PersonHolder::person).then(Person::getFirstName)).iterator().next() .getProperty()) .isEqualTo("person.firstName"); } diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt index f74135f25c..e358418c2a 100644 --- a/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KPropertyExtensionsTests.kt @@ -47,17 +47,17 @@ class KPropertyExtensionsTests { return Stream.of( Arguments.argumentSet( "Person.name (toPath)", - 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).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).toPath(), + (Person::addresses / Address::country / Country::name).toPropertyPath(), PropertyPath.from("addresses.country.name", Person::class.java) ) ) diff --git a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt index 81af3764a9..4dcbaefbbb 100644 --- a/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KTypedPropertyPathUnitTests.kt @@ -1,7 +1,5 @@ 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 @@ -27,34 +25,29 @@ class KTypedPropertyPathUnitTests { fun propertyPaths(): Stream { return Stream.of( - Arguments.argumentSet( - "Person.name", - KTypedPropertyPath.of(Person::name), - PropertyPath.from("name", Person::class.java) - ), Arguments.argumentSet( "Person.name (toPath)", - Person::name.toPath(), + Person::name.toPropertyPath(), PropertyPath.from("name", Person::class.java) ), Arguments.argumentSet( "Person.address.country", - KTypedPropertyPath.of(Person::address / Address::country), + (Person::address / Address::country).toPropertyPath(), PropertyPath.from("address.country", Person::class.java) ), Arguments.argumentSet( "Person.address.country.name", - KTypedPropertyPath.of(Person::address / Address::country / 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).toPath(), + (Person::address / Address::country / Country::name).toPropertyPath(), PropertyPath.from("address.country.name", Person::class.java) ), Arguments.argumentSet( "Person.emergencyContact.address.country.name", - KTypedPropertyPath.of(Person::emergencyContact / Person::address / Address::country / Country::name), + (Person::emergencyContact / Person::address / Address::country / Country::name).toPropertyPath(), PropertyPath.from( "emergencyContact.address.country.name", Person::class.java @@ -64,42 +57,6 @@ class KTypedPropertyPathUnitTests { } } - @Test // GH-3400 - fun shouldCreatePropertyPath() { - - val path = KTypedPropertyPath.of(Person::name) - - assertThat(path.toDotPath()).isEqualTo("name") - } - - @Test // GH-3400 - fun shouldComposePropertyPath() { - - val path = KTypedPropertyPath.of(Person::address).then(Address::city) - - assertThat(path.toDotPath()).isEqualTo("address.city") - } - - @Test // GH-3400 - fun shouldComposeManyPropertyPath() { - - val path = KTypedPropertyPath.of(Person::addresses).then(Address::city) - - assertThat(path.toDotPath()).isEqualTo("addresses.city") - } - - @Test // GH-3400 - fun shouldCreateComposed() { - - assertThat( - PropertyPath.of(Person::address / Address::city).toDotPath() - ).isEqualTo("address.city") - - val path = KTypedPropertyPath.of(Person::address / Address::city) - - assertThat(path.toDotPath()).isEqualTo("address.city") - } - class Person { var name: String? = null var age: Int = 0 diff --git a/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt new file mode 100644 index 0000000000..60834b0957 --- /dev/null +++ b/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt @@ -0,0 +1,38 @@ +package org.springframework.data.core + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalArgumentException +import org.junit.jupiter.api.Test + +/** + * Kotlin unit tests for [PropertyReference] and related functionality. + * + * @author Mark Paluch + */ +class PropertyReferenceKtUnitTests { + + @Test // GH-3400 + fun shouldSupportPropertyReference() { + assertThat(PropertyReference.of(Person::address).name).isEqualTo("address") + } + + @Test // GH-3400 + fun resolutionShouldFailForComposedPropertyPath() { + assertThatIllegalArgumentException() + .isThrownBy { PropertyReference.of(Person::address / Address::city) } + } + + class Person { + var name: String? = null + var age: Int = 0 + var address: Address? = null + } + + class Address { + var city: String? = null + var street: String? = null + } + + data class Country(val name: String) + +} diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt index ece047f499..a814fbe3cf 100644 --- a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt @@ -29,24 +29,24 @@ class TypedPropertyPathKtUnitTests { return Stream.of( Arguments.argumentSet( "Person.name", - TypedPropertyPath.ofReference(Person::name), + TypedPropertyPath.ofProperty(Person::name), PropertyPath.from("name", Person::class.java) ), Arguments.argumentSet( "Person.address.country", - TypedPropertyPath.ofReference(Person::address) + TypedPropertyPath.ofProperty(Person::address) .then(Address::country), PropertyPath.from("address.country", Person::class.java) ), Arguments.argumentSet( "Person.address.country.name", - TypedPropertyPath.ofReference(Person::address) + TypedPropertyPath.ofProperty(Person::address) .then(Address::country).then(Country::name), PropertyPath.from("address.country.name", Person::class.java) ), Arguments.argumentSet( "Person.emergencyContact.address.country.name", - TypedPropertyPath.ofReference(Person::emergencyContact) + TypedPropertyPath.ofProperty(Person::emergencyContact) .then

(Person::address).then(Address::country) .then(Country::name), PropertyPath.from( @@ -60,30 +60,45 @@ class TypedPropertyPathKtUnitTests { @Test // GH-3400 fun shouldSupportPropertyReference() { - assertThat(TypedPropertyPath.ofReference(Person::address).toDotPath()).isEqualTo("address") + + assertThat( + TypedPropertyPath.ofProperty(Person::address).toDotPath() + ).isEqualTo("address") } @Test // GH-3400 fun shouldSupportComposedPropertyReference() { - val path = TypedPropertyPath.ofReference(Person::address).then(Address::city); + val path = TypedPropertyPath.ofProperty(Person::address) + .then(Address::city); assertThat(path.toDotPath()).isEqualTo("address.city") } @Test // GH-3400 fun shouldSupportPropertyLambda() { - assertThat(TypedPropertyPath.ofReference { it.address }.toDotPath()).isEqualTo("address") - assertThat(TypedPropertyPath.ofReference { foo -> foo.address } + assertThat(TypedPropertyPath.ofProperty { it.address } + .toDotPath()).isEqualTo("address") + assertThat(TypedPropertyPath.ofProperty { foo -> foo.address } .toDotPath()).isEqualTo("address") } @Test // GH-3400 fun shouldSupportComposedPropertyLambda() { - val path = TypedPropertyPath.ofReference { it.address }; + val path = TypedPropertyPath.ofProperty { it.address }; assertThat(path.then { it.city }.toDotPath()).isEqualTo("address.city") } + @Test // GH-3400 + fun shouldSupportComposedKProperty() { + + val path = TypedPropertyPath.ofProperty(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 From 62427f4964f0bc0e708a36b6ef0e9f23d327f290 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 20 Nov 2025 10:00:25 +0100 Subject: [PATCH 23/25] Incorporate design review feedback. --- .../SerializableLambdaReaderBenchmarks.java | 6 +- .../core/TypedPropertyPathBenchmarks.java | 12 +- .../PropertyValueConverterRegistrar.java | 17 +- .../data/core/MemberReference.java | 44 ----- .../data/core/PropertyPath.java | 31 +++- .../data/core/PropertyReference.java | 41 +++-- .../data/core/PropertyReferences.java | 113 ++++++------ ...ropertyPathUtil.java => PropertyUtil.java} | 37 +++- .../data/core/SerializableLambdaReader.java | 10 +- .../data/core/SimplePropertyPath.java | 4 +- .../data/core/TypedPropertyPath.java | 107 +++++++++--- .../data/core/TypedPropertyPaths.java | 165 ++++-------------- .../data/util/MethodInvocationRecorder.java | 4 + .../data/core/PropertyReferenceExtensions.kt | 2 +- ...pertyValueConverterRegistrarUnitTests.java | 15 ++ .../data/core/MemberReferenceTests.java | 54 ------ .../data/core/TypedPropertyPathUnitTests.java | 8 + .../data/domain/SortUnitTests.java | 5 +- .../data/core/KPropertyReferenceUnitTests.kt | 6 +- .../data/core/PropertyReferenceKtUnitTests.kt | 4 +- .../data/core/TypedPropertyPathKtUnitTests.kt | 20 +-- 21 files changed, 339 insertions(+), 366 deletions(-) delete mode 100644 src/main/java/org/springframework/data/core/MemberReference.java rename src/main/java/org/springframework/data/core/{PropertyPathUtil.java => PropertyUtil.java} (58%) delete mode 100644 src/test/java/org/springframework/data/core/MemberReferenceTests.java diff --git a/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java b/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java index 6ef8254dca..04d6e0660f 100644 --- a/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java +++ b/src/jmh/java/org/springframework/data/core/SerializableLambdaReaderBenchmarks.java @@ -28,19 +28,19 @@ @Testable public class SerializableLambdaReaderBenchmarks extends BenchmarkSettings { - private static final SerializableLambdaReader reader = new SerializableLambdaReader(MemberReference.class); + private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyReference.class); @Benchmark public Object benchmarkMethodReference() { - MemberReference methodReference = Person::firstName; + PropertyReference methodReference = Person::firstName; return reader.read(methodReference); } @Benchmark public Object benchmarkLambda() { - MemberReference methodReference = person -> person.firstName(); + PropertyReference methodReference = person -> person.firstName(); return reader.read(methodReference); } diff --git a/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java index 759af41a44..245641dc03 100644 --- a/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java +++ b/src/jmh/java/org/springframework/data/core/TypedPropertyPathBenchmarks.java @@ -29,32 +29,32 @@ public class TypedPropertyPathBenchmarks extends BenchmarkSettings { @Benchmark public Object benchmarkMethodReference() { - return TypedPropertyPath.ofProperty(Person::firstName); + return TypedPropertyPath.path(Person::firstName); } @Benchmark public Object benchmarkComposedMethodReference() { - return TypedPropertyPath.ofProperty(Person::address).then(Address::city); + return TypedPropertyPath.path(Person::address).then(Address::city); } @Benchmark public TypedPropertyPath benchmarkLambda() { - return TypedPropertyPath.ofProperty(person -> person.firstName()); + return TypedPropertyPath.path(person -> person.firstName()); } @Benchmark public TypedPropertyPath benchmarkComposedLambda() { - return TypedPropertyPath.ofProperty((Person person) -> person.address()).then(address -> address.city()); + return TypedPropertyPath.path((Person person) -> person.address()).then(address -> address.city()); } @Benchmark public Object dotPath() { - return TypedPropertyPath.ofProperty(Person::firstName).toDotPath(); + return TypedPropertyPath.path(Person::firstName).toDotPath(); } @Benchmark public Object composedDotPath() { - return TypedPropertyPath.ofProperty(Person::address).then(Address::city).toDotPath(); + return TypedPropertyPath.path(Person::address).then(Address::city).toDotPath(); } record Person(String firstName, String lastName, Address address) { diff --git a/src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java b/src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java index 869b60269e..1606dc7fed 100644 --- a/src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java +++ b/src/main/java/org/springframework/data/convert/PropertyValueConverterRegistrar.java @@ -20,6 +20,7 @@ import java.util.function.Function; import org.springframework.data.convert.PropertyValueConverter.FunctionPropertyValueConverter; +import org.springframework.data.core.PropertyReference; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.util.MethodInvocationRecorder; import org.springframework.lang.Contract; @@ -50,11 +51,25 @@ public class PropertyValueConverterRegistrar

> { * * @param the domain type * @param the property type - * @param type the domain type to obtain the property from * @param property a {@link Function} to describe the property to be referenced. * Usually a method handle to a getter. * @return will never be {@literal null}. */ + public WritingConverterRegistrationBuilder registerConverter(PropertyReference property) { + return new WritingConverterRegistrationBuilder<>(property.getOwningType().getType(), property.getName(), this); + } + + /** + * Starts a converter registration by pointing to a property of a domain type. + * + * @param the domain type + * @param the property type + * @param type the domain type to obtain the property from + * @param property a {@link Function} to describe the property to be referenced. Usually a method handle to a getter. + * @return will never be {@literal null}. + * @deprecated since 4.1, use {@link #registerConverter(PropertyReference)} instead. + */ + @Deprecated(since = "4.1") public WritingConverterRegistrationBuilder registerConverter(Class type, Function property) { String propertyName = MethodInvocationRecorder.forProxyOf(type).record(property).getPropertyPath() diff --git a/src/main/java/org/springframework/data/core/MemberReference.java b/src/main/java/org/springframework/data/core/MemberReference.java deleted file mode 100644 index faa0827adb..0000000000 --- a/src/main/java/org/springframework/data/core/MemberReference.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.core; - -import java.io.Serializable; -import java.lang.reflect.Member; -import java.util.function.Function; - -import org.springframework.util.Assert; - -/** - * PoC for REST EntityLookupConfiguration - * - * @author Mark Paluch - */ -public interface MemberReference extends Function, Serializable { - - default Member getMember() { - return resolve(this); - } - - public static Member resolve(Object object) { - - Assert.notNull(object, "Object must not be null"); - Assert.isInstanceOf(Serializable.class, object, "Object must be Serializable"); - - return new SerializableLambdaReader(MemberReference.class).read(object) - .getMember(); - } - -} diff --git a/src/main/java/org/springframework/data/core/PropertyPath.java b/src/main/java/org/springframework/data/core/PropertyPath.java index eff9d76455..f08a1a03b7 100644 --- a/src/main/java/org/springframework/data/core/PropertyPath.java +++ b/src/main/java/org/springframework/data/core/PropertyPath.java @@ -24,6 +24,19 @@ /** * Abstraction of a {@link PropertyPath} within a domain class. + *

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

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

+ * A typed variant of {@link PropertyPath} is available as {@link TypedPropertyPath} through + * {@link #of(PropertyReference)} to leverage method references for a type-safe usage across application code. * * @author Oliver Gierke * @author Christoph Strobl @@ -79,10 +92,10 @@ static TypedPropertyPath ofMany(PropertyReference - * PropertyPath.from("a.b.c", Some.class).getSegment(); + * PropertyPath.from("address.city.name", Person.class).getSegment(); *

* - * results in {@code a}. + * results in {@code address}. * * @return the current property path segment. */ @@ -149,10 +162,10 @@ default boolean isCollection() { * Returns the next {@code PropertyPath} segment in the property path chain. * *
-	 * PropertyPath.from("a.b.c", Some.class).next().toDotPath();
+	 * PropertyPath.from("address.city.name", Person.class).next().toDotPath();
 	 * 
* - * results in the output: {@code b.c} + * results in the output: {@code city.name}. * * @return the next {@code PropertyPath} or {@literal null} if the path does not contain further segments. * @see #hasNext() @@ -201,16 +214,16 @@ default PropertyPath nested(String path) { * Returns an {@link Iterator Iterator of PropertyPath} that iterates over all property path segments. For example: * *
-	 * PropertyPath propertyPath = PropertyPath.from("a.b.c", Some.class);
-	 * propertyPath.forEach(p -> p.toDotPath());
+	 * PropertyPath path = PropertyPath.from("address.city.name", Person.class);
+	 * path.forEach(p -> p.toDotPath());
 	 * 
* * results in the dot paths: * *
-	 * a.b.c             (this object)
-	 * b.c               (next() object)
-	 * c                 (next().next() object)
+	 * address.city.name     (this object)
+	 * city.name             (next() object)
+	 * city.name             (next().next() object)
 	 * 
*/ @Override diff --git a/src/main/java/org/springframework/data/core/PropertyReference.java b/src/main/java/org/springframework/data/core/PropertyReference.java index be1758de88..2e4b187033 100644 --- a/src/main/java/org/springframework/data/core/PropertyReference.java +++ b/src/main/java/org/springframework/data/core/PropertyReference.java @@ -22,13 +22,12 @@ /** * Interface providing type-safe property references. *

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

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

  * PropertyReference<Person, String> name = PropertyReference.of(Person::getName);
@@ -64,11 +63,25 @@
 public interface PropertyReference extends Serializable {
 
 	/**
-	 * Syntax sugar to create a {@link PropertyReference} from a method reference or lambda.
+	 * Syntax sugar to create a {@link PropertyReference} from a method reference. Suitable for static imports.
 	 * 

- * This method returns a resolved {@link PropertyReference} by introspecting the given method reference or lambda. + * This method returns a resolved {@link PropertyReference} by introspecting the given method reference. * - * @param property the method reference or lambda. + * @param property the method reference. + * @param owning type. + * @param

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

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

property type. * @return the typed property reference. @@ -80,11 +93,11 @@ public interface PropertyReference extends Serial /** * Syntax sugar to create a {@link PropertyReference} from a method reference or lambda for a collection property. *

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

property type. * @return the typed property reference. @@ -104,11 +117,11 @@ static PropertyReference ofMany(PropertyReference getOwningType() { + default TypeInformation getOwningType() { return PropertyReferences.of(this).getOwningType(); } diff --git a/src/main/java/org/springframework/data/core/PropertyReferences.java b/src/main/java/org/springframework/data/core/PropertyReferences.java index 7c02812a0a..d81dbaf7ee 100644 --- a/src/main/java/org/springframework/data/core/PropertyReferences.java +++ b/src/main/java/org/springframework/data/core/PropertyReferences.java @@ -20,8 +20,8 @@ import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.util.Map; -import java.util.Objects; import java.util.WeakHashMap; +import java.util.function.Supplier; import org.jspecify.annotations.Nullable; @@ -65,20 +65,20 @@ public static PropertyReference of(PropertyReference lambda) } /** - * Retrieve {@link PropertyReferenceMetadata} for a given {@link PropertyReference}. + * Retrieve {@link PropertyMetadata} for a given {@link PropertyReference}. */ @SuppressWarnings({ "unchecked", "rawtypes" }) public static PropertyReference of(PropertyReference delegate, - PropertyReferenceMetadata metadata) { + PropertyMetadata metadata) { - if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyReferenceMetadata kmp) { + if (KotlinDetector.isKotlinReflectPresent() && metadata instanceof KPropertyMetadata kmp) { return new ResolvedKPropertyReference(kmp.getProperty(), metadata); } return new ResolvedPropertyReference<>(delegate, metadata); } - private static PropertyReferenceMetadata read(PropertyReference lambda) { + private static PropertyMetadata read(PropertyReference lambda) { MemberDescriptor reference = reader.read(lambda); @@ -90,42 +90,66 @@ private static PropertyReferenceMetadata read(PropertyReference lambda) { + " is a property path. Use a single property reference."); } - return KPropertyReferenceMetadata.of(kProperty); + return KPropertyMetadata.of(kProperty); } if (reference instanceof MethodDescriptor method) { - return PropertyReferenceMetadata.ofMethod(method); + return PropertyMetadata.ofMethod(method); } - return PropertyReferenceMetadata.ofField((FieldDescriptor) reference); + return PropertyMetadata.ofField((FieldDescriptor) reference); } /** * Metadata describing a property reference including its owner type, property type, and name. */ - static class PropertyReferenceMetadata { + static class PropertyMetadata { private final TypeInformation owner; private final String property; private final TypeInformation propertyType; - PropertyReferenceMetadata(Class owner, String property, ResolvableType propertyType) { + PropertyMetadata(Class owner, String property, ResolvableType propertyType) { this(TypeInformation.of(owner), property, TypeInformation.of(propertyType)); } - PropertyReferenceMetadata(TypeInformation owner, String property, TypeInformation propertyType) { + PropertyMetadata(TypeInformation owner, String property, TypeInformation propertyType) { this.owner = owner; this.property = property; this.propertyType = propertyType; } /** - * Create a new {@code PropertyReferenceMetadata} from a method. + * Create a new {@code PropertyMetadata} from a method. */ - public static PropertyReferenceMetadata ofMethod(MethodDescriptor method) { + public static PropertyMetadata ofMethod(MethodDescriptor descriptor) { + + return resolveProperty(descriptor, + () -> new IllegalArgumentException("Cannot find PropertyDescriptor from method '%s.%s()'" + .formatted(descriptor.owner().getName(), descriptor.getMember().getName()))); + } + + /** + * Create a new {@code PropertyMetadata} from a field. + */ + public static PropertyMetadata ofField(FieldDescriptor field) { + return new PropertyMetadata(field.owner(), field.getMember().getName(), field.getType()); + } + + /** + * Resolve {@code PropertyMetadata} from a method descriptor by introspecting bean metadata and return metadata if + * available otherwise throw an exception supplied by {@code exceptionSupplier}. + * + * @param method the method descriptor. + * @param exceptionSupplier supplier for exception to be thrown when property cannot be resolved. + * @return metadata for the resolved property. + * @see BeanUtils + */ + public static PropertyMetadata resolveProperty(MethodDescriptor method, + Supplier exceptionSupplier) { PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method.method()); - String methodName = method.getMember().getName(); + String methodName = method.method().getName(); if (descriptor == null) { @@ -134,14 +158,13 @@ public static PropertyReferenceMetadata ofMethod(MethodDescriptor method) { TypeInformation fallback = owner.getProperty(propertyName); if (fallback != null) { - return new PropertyReferenceMetadata(owner, propertyName, fallback); + return new PropertyMetadata(owner, propertyName, fallback); } - throw new IllegalArgumentException( - "Cannot find PropertyDescriptor from method '%s.%s()'".formatted(method.owner().getName(), methodName)); + throw exceptionSupplier.get(); } - return new PropertyReferenceMetadata(method.getOwner(), descriptor.getName(), method.getType()); + return new PropertyMetadata(method.owner(), descriptor.getName(), method.getType()); } private static String getPropertyName(String methodName) { @@ -155,13 +178,6 @@ private static String getPropertyName(String methodName) { return methodName; } - /** - * Create a new {@code PropertyReferenceMetadata} from a field. - */ - public static PropertyReferenceMetadata ofField(FieldDescriptor field) { - return new PropertyReferenceMetadata(field.owner(), field.getMember().getName(), field.getType()); - } - public TypeInformation owner() { return owner; } @@ -177,22 +193,22 @@ public TypeInformation propertyType() { } /** - * Kotlin-specific {@link PropertyReferenceMetadata} implementation. + * Kotlin-specific {@link PropertyMetadata} implementation. */ - static class KPropertyReferenceMetadata extends PropertyReferenceMetadata { + static class KPropertyMetadata extends PropertyMetadata { private final KProperty property; - KPropertyReferenceMetadata(Class owner, KProperty property, ResolvableType propertyType) { + KPropertyMetadata(Class owner, KProperty property, ResolvableType propertyType) { super(owner, property.getName(), propertyType); this.property = property; } /** - * Create a new {@code KPropertyReferenceMetadata}. + * Create a new {@code KPropertyMetadata}. */ - public static KPropertyReferenceMetadata of(MemberDescriptor.KotlinMemberDescriptor descriptor) { - return new KPropertyReferenceMetadata(descriptor.getOwner(), descriptor.getKotlinProperty(), + public static KPropertyMetadata of(MemberDescriptor.KotlinMemberDescriptor descriptor) { + return new KPropertyMetadata(descriptor.getOwner(), descriptor.getKotlinProperty(), descriptor.getType()); } @@ -209,17 +225,18 @@ public KProperty getProperty() { */ static abstract class ResolvedPropertyReferenceSupport implements PropertyReference { - private final PropertyReferenceMetadata metadata; + private final PropertyMetadata metadata; private final String toString; - ResolvedPropertyReferenceSupport(PropertyReferenceMetadata metadata) { + ResolvedPropertyReferenceSupport(PropertyMetadata metadata) { this.metadata = metadata; this.toString = metadata.owner().getType().getSimpleName() + "." + getName(); } @Override - public TypeInformation getOwningType() { - return metadata.owner(); + @SuppressWarnings("unchecked") + public TypeInformation getOwningType() { + return (TypeInformation) metadata.owner(); } @Override @@ -228,28 +245,19 @@ public String getName() { } @Override - public TypeInformation getTypeInformation() { - return metadata.propertyType(); + @SuppressWarnings("unchecked") + public TypeInformation

getTypeInformation() { + return (TypeInformation

) metadata.propertyType(); } @Override public boolean equals(@Nullable Object obj) { - - if (obj == this) { - return true; - } - - if (!(obj instanceof PropertyReference that)) { - return false; - } - - return Objects.equals(this.getOwningType(), that.getOwningType()) - && Objects.equals(this.getName(), that.getName()); + return PropertyUtil.equals(this, obj); } @Override public int hashCode() { - return toString().hashCode(); + return PropertyUtil.hashCode(this); } @Override @@ -269,7 +277,7 @@ static class ResolvedPropertyReference extends ResolvedPropertyReferenceSu private final PropertyReference function; - ResolvedPropertyReference(PropertyReference function, PropertyReferenceMetadata metadata) { + ResolvedPropertyReference(PropertyReference function, PropertyMetadata metadata) { super(metadata); this.function = function; } @@ -292,11 +300,12 @@ static class ResolvedKPropertyReference extends ResolvedPropertyReferenceS private final KProperty

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

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

property, PropertyReferenceMetadata metadata) { + ResolvedKPropertyReference(KProperty

property, PropertyMetadata metadata) { super(metadata); this.property = property; } diff --git a/src/main/java/org/springframework/data/core/PropertyPathUtil.java b/src/main/java/org/springframework/data/core/PropertyUtil.java similarity index 58% rename from src/main/java/org/springframework/data/core/PropertyPathUtil.java rename to src/main/java/org/springframework/data/core/PropertyUtil.java index 92dfa6e1b4..443dbfb8e9 100644 --- a/src/main/java/org/springframework/data/core/PropertyPathUtil.java +++ b/src/main/java/org/springframework/data/core/PropertyUtil.java @@ -20,12 +20,12 @@ import org.jspecify.annotations.Nullable; /** - * Utility class for {@link PropertyPath} implementations. + * Utility class for {@link PropertyPath} and {@link PropertyReference} implementations. * * @author Mark Paluch * @since 4.1 */ -class PropertyPathUtil { +class PropertyUtil { /** * Compute the hash code for the given {@link PropertyPath} based on its {@link Object#toString() string} @@ -38,6 +38,17 @@ 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. * @@ -45,7 +56,7 @@ static int hashCode(PropertyPath path) { * @param o the other object. * @return {@literal true} if both are equal; {@literal false} otherwise. */ - public static boolean equals(PropertyPath self, @Nullable Object o) { + static boolean equals(PropertyPath self, @Nullable Object o) { if (self == o) { return true; @@ -59,4 +70,24 @@ public static boolean equals(PropertyPath self, @Nullable Object o) { && Objects.equals(self.toString(), that.toString()); } + /** + * Equality check for {@link PropertyReference} implementations based on their owning type and name. + * + * @param self the property path. + * @param o the other object. + * @return {@literal true} if both are equal; {@literal false} otherwise. + */ + static boolean equals(PropertyReference self, @Nullable Object o) { + + if (self == o) { + return true; + } + + if (!(o instanceof PropertyReference that)) { + return false; + } + + return Objects.equals(self.getOwningType(), that.getOwningType()) && Objects.equals(self.getName(), that.getName()); + } + } diff --git a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java index 4505f1d5f3..71f8fa58bb 100644 --- a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java +++ b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java @@ -111,8 +111,8 @@ class SerializableLambdaReader { 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, false); - private static final boolean includeSuppressedExceptions = isEnabled(INCLUDE_SUPPRESSED_EXCEPTIONS, true); + private static final boolean filterStackTrace = isEnabled(FILTER_STACK_TRACE, true); + private static final boolean includeSuppressedExceptions = isEnabled(INCLUDE_SUPPRESSED_EXCEPTIONS, false); private final List> entryPoints; @@ -143,15 +143,13 @@ public MemberDescriptor read(Object lambdaObject) { if (isKotlinPropertyReference(lambda)) { Object captured = lambda.getCapturedArg(0); - if (captured != null // - && captured instanceof PropertyReference propRef // + if (captured instanceof PropertyReference propRef // && propRef.getOwner() instanceof KClass owner // && captured instanceof KProperty1 kProperty) { return new KPropertyReferenceDescriptor(JvmClassMappingKt.getJavaClass(owner), kProperty); } - if (captured != null // - && captured instanceof KPropertyPath propRef) { + if (captured instanceof KPropertyPath propRef) { return KPropertyPathDescriptor.create(propRef); } } diff --git a/src/main/java/org/springframework/data/core/SimplePropertyPath.java b/src/main/java/org/springframework/data/core/SimplePropertyPath.java index a8dfb2211a..aaecacf172 100644 --- a/src/main/java/org/springframework/data/core/SimplePropertyPath.java +++ b/src/main/java/org/springframework/data/core/SimplePropertyPath.java @@ -177,12 +177,12 @@ public boolean hasNext() { @Override public boolean equals(@Nullable Object o) { - return PropertyPathUtil.equals(this, o); + return PropertyUtil.equals(this, o); } @Override public int hashCode() { - return PropertyPathUtil.hashCode(this); + return PropertyUtil.hashCode(this); } /** diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index edad7c42c9..bb1e1fdf71 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -22,15 +22,15 @@ import org.jspecify.annotations.Nullable; /** - * Interface providing type-safe property path navigation through method references and lambda expressions. + * Interface providing type-safe property path navigation through method references expressions. *

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

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

  * TypedPropertyPath<Person, String> name = TypedPropertyPath.of(Person::getName);
@@ -49,11 +49,11 @@
  * at this segment. Composition automatically flows type information forward, ensuring that {@code then()} preserves the
  * full chain's type safety.
  * 

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

the property value type at this path segment. @@ -66,25 +66,80 @@ public interface TypedPropertyPath extends PropertyPath, Serializable { /** - * Syntax sugar to create a {@link TypedPropertyPath} from a property described as method reference or lambda. + * Syntax sugar to create a {@link TypedPropertyPath} from a property described as method reference. Suitable for + * static imports. *

- * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference or lambda. + * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference. * - * @param propertyPath the method reference or lambda. + * @param property the property reference. * @param owning type. * @param

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

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

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

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

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

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

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

- * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference or lambda. + * This method returns a resolved {@link TypedPropertyPath} by introspecting the given method reference. * - * @param propertyPath the method reference or lambda. + * @param propertyPath the method reference. * @param owning type. * @param

property type. * @return the typed property path. @@ -94,14 +149,14 @@ public interface TypedPropertyPath extends Proper } /** - * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda for a collection property. + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference for a collection property. *

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

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

property type. * @return the typed property path. @@ -121,7 +176,7 @@ static TypedPropertyPath ofMany(TypedPropertyPath getOwningType() { + default TypeInformation getOwningType() { return TypedPropertyPaths.of(this).getOwningType(); } @@ -131,7 +186,7 @@ default String getSegment() { } @Override - default TypeInformation getTypeInformation() { + default TypeInformation

getTypeInformation() { return TypedPropertyPaths.of(this).getTypeInformation(); } @@ -154,8 +209,8 @@ default Iterator iterator() { /** * Extend the property path by appending the {@code next} path segment and returning a new property path instance. * - * @param next the next property path segment as method reference or lambda accepting the owner object {@code P} type - * and returning {@code N} as result of accessing a property. + * @param next the next property path segment as method reference accepting the owner object {@code P} type and + * returning {@code N} as result of accessing a property. * @param the new property value type. * @return a new composed {@code TypedPropertyPath}. */ @@ -169,8 +224,8 @@ default Iterator iterator() { * Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from * {@code Iterable <P>} and a single {@code P} cannot represent a collection of items. * - * @param next the next property path segment as method reference or lambda accepting the owner object {@code P} type - * and returning {@code N} as result of accessing a property. + * @param next the next property path segment as method reference accepting the owner object {@code P} type and + * returning {@code N} as result of accessing a property. * @param the new property value type. * @return a new composed {@code TypedPropertyPath}. */ diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index 83c7a7fb35..bc801aa3ba 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -20,27 +20,23 @@ import kotlin.reflect.jvm.internal.KProperty1Impl; import kotlin.reflect.jvm.internal.KPropertyImpl; -import java.beans.Introspector; -import java.beans.PropertyDescriptor; import java.io.Serializable; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.WeakHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; 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.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; @@ -55,7 +51,7 @@ class TypedPropertyPaths { private static final Map>> resolved = new WeakHashMap<>(); private static final SerializableLambdaReader reader = new SerializableLambdaReader(PropertyPath.class, - TypedPropertyPath.class, TypedPropertyPaths.class); + PropertyReference.class, PropertyReferences.class, TypedPropertyPath.class, TypedPropertyPaths.class); /** * Compose a {@link TypedPropertyPath} by appending {@code next}. @@ -124,7 +120,7 @@ public static TypedPropertyPath of(PropertyReference lambda) return new PropertyReferenceWrapper<>(resolved); } - PropertyPathMetadata metadata = read(lambda); + PropertyMetadata metadata = read(lambda); if (KotlinDetector.isKotlinReflectPresent()) { if (metadata instanceof KPropertyPathMetadata kMetadata @@ -158,7 +154,7 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) private static TypedPropertyPath doResolvePropertyPathReference(TypedPropertyPath lambda) { - PropertyPathMetadata metadata = read(lambda); + PropertyMetadata metadata = read(lambda); if (KotlinDetector.isKotlinReflectPresent()) { if (metadata instanceof KPropertyPathMetadata kMetadata @@ -170,7 +166,7 @@ public static TypedPropertyPath of(TypedPropertyPath lambda) return new ResolvedTypedPropertyPath<>(lambda, metadata); } - private static PropertyPathMetadata read(Object lambda) { + private static PropertyMetadata read(Object lambda) { MemberDescriptor reference = reader.read(lambda); @@ -186,92 +182,16 @@ private static PropertyPathMetadata read(Object lambda) { } if (reference instanceof MethodDescriptor method) { - return PropertyPathMetadata.ofMethod(method); + return PropertyMetadata.ofMethod(method); } - return PropertyPathMetadata.ofField((MemberDescriptor.MethodDescriptor.FieldDescriptor) reference); + return PropertyMetadata.ofField((MemberDescriptor.MethodDescriptor.FieldDescriptor) reference); } /** - * Metadata describing a single property path segment including its owner type, property type, and name. + * Kotlin-specific {@link PropertyMetadata} implementation supporting composed {@link KProperty property paths}. */ - static class PropertyPathMetadata { - - private final TypeInformation owner; - private final String property; - private final TypeInformation propertyType; - - PropertyPathMetadata(Class owner, String property, ResolvableType propertyType) { - this(TypeInformation.of(owner), property, TypeInformation.of(propertyType)); - } - - PropertyPathMetadata(TypeInformation owner, String property, TypeInformation propertyType) { - this.owner = owner; - this.property = property; - this.propertyType = propertyType; - } - - /** - * Create a new {@code PropertyPathMetadata} from a method. - */ - public static PropertyPathMetadata ofMethod(MethodDescriptor method) { - - PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method.method()); - String methodName = method.getMember().getName(); - - if (descriptor == null) { - - String propertyName = getPropertyName(methodName); - TypeInformation owner = TypeInformation.of(method.owner()); - TypeInformation fallback = owner.getProperty(propertyName); - - if (fallback != null) { - return new PropertyPathMetadata(owner, propertyName, fallback); - } - - throw new IllegalArgumentException( - "Cannot find PropertyDescriptor from method '%s.%s()'".formatted(method.owner().getName(), methodName)); - } - - return new PropertyPathMetadata(method.getOwner(), 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; - } - - /** - * Create a new {@code PropertyPathMetadata} from a field. - */ - public static PropertyPathMetadata ofField(FieldDescriptor field) { - return new PropertyPathMetadata(field.owner(), field.getMember().getName(), field.getType()); - } - - public TypeInformation owner() { - return owner; - } - - public String property() { - return property; - } - - public TypeInformation propertyType() { - return propertyType; - } - - } - - /** - * Kotlin-specific {@link PropertyPathMetadata} implementation. - */ - static class KPropertyPathMetadata extends PropertyPathMetadata { + static class KPropertyPathMetadata extends PropertyMetadata { private final KProperty property; @@ -353,19 +273,20 @@ interface Resolved { */ static abstract class ResolvedTypedPropertyPathSupport implements TypedPropertyPath, Resolved { - private final PropertyPathMetadata metadata; + private final PropertyMetadata metadata; private final List list; private final String toString; - ResolvedTypedPropertyPathSupport(PropertyPathMetadata metadata) { + ResolvedTypedPropertyPathSupport(PropertyMetadata metadata) { this.metadata = metadata; this.list = List.of(this); this.toString = metadata.owner().getType().getSimpleName() + "." + toDotPath(); } @Override - public TypeInformation getOwningType() { - return metadata.owner(); + @SuppressWarnings("unchecked") + public TypeInformation getOwningType() { + return (TypeInformation) metadata.owner(); } @Override @@ -374,8 +295,9 @@ public String getSegment() { } @Override - public TypeInformation getTypeInformation() { - return metadata.propertyType(); + @SuppressWarnings("unchecked") + public TypeInformation

getTypeInformation() { + return (TypeInformation

) metadata.propertyType(); } @Override @@ -395,22 +317,12 @@ public List toList() { @Override public boolean equals(@Nullable Object obj) { - - if (obj == this) { - return true; - } - - if (!(obj instanceof PropertyPath that)) { - return false; - } - - return Objects.equals(this.getOwningType(), that.getOwningType()) - && Objects.equals(this.toDotPath(), that.toDotPath()); + return PropertyUtil.equals(this, obj); } @Override public int hashCode() { - return toString().hashCode(); + return PropertyUtil.hashCode(this); } @Override @@ -442,7 +354,7 @@ public PropertyReferenceWrapper(PropertyReference property) { } @Override - public TypeInformation getOwningType() { + public TypeInformation getOwningType() { return property.getOwningType(); } @@ -452,8 +364,9 @@ public String getSegment() { } @Override - public TypeInformation getTypeInformation() { - return property.getTypeInformation(); + @SuppressWarnings("unchecked") + public TypeInformation

getTypeInformation() { + return (TypeInformation

) property.getTypeInformation(); } @Override @@ -473,22 +386,12 @@ public List toList() { @Override public boolean equals(@Nullable Object obj) { - - if (obj == this) { - return true; - } - - if (!(obj instanceof PropertyPath that)) { - return false; - } - - return Objects.equals(this.getOwningType(), that.getOwningType()) - && Objects.equals(this.toDotPath(), that.toDotPath()); + return PropertyUtil.equals(this, obj); } @Override public int hashCode() { - return toString().hashCode(); + return PropertyUtil.hashCode(this); } @Override @@ -508,7 +411,7 @@ static class ResolvedPropertyReference extends ResolvedTypedPropertyPathSu private final PropertyReference function; - ResolvedPropertyReference(PropertyReference function, PropertyPathMetadata metadata) { + ResolvedPropertyReference(PropertyReference function, PropertyMetadata metadata) { super(metadata); this.function = function; } @@ -530,7 +433,7 @@ static class ResolvedTypedPropertyPath extends ResolvedTypedPropertyPathSu private final TypedPropertyPath function; - ResolvedTypedPropertyPath(TypedPropertyPath function, PropertyPathMetadata metadata) { + ResolvedTypedPropertyPath(TypedPropertyPath function, PropertyMetadata metadata) { super(metadata); this.function = function; } @@ -553,11 +456,12 @@ static class ResolvedKPropertyPath extends ResolvedTypedPropertyPathSuppor private final KProperty

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

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

property, PropertyPathMetadata metadata) { + ResolvedKPropertyPath(KProperty

property, PropertyMetadata metadata) { super(metadata); this.property = property; } @@ -604,7 +508,7 @@ public static PropertyPath getSelf(PropertyPath path) { } @Override - public TypeInformation getOwningType() { + public TypeInformation getOwningType() { return self.getOwningType(); } @@ -624,8 +528,9 @@ public String toDotPath() { } @Override - public TypeInformation getTypeInformation() { - return self.getTypeInformation(); + @SuppressWarnings("unchecked") + public TypeInformation

getTypeInformation() { + return (TypeInformation

) self.getTypeInformation(); } @Override @@ -649,12 +554,12 @@ public Iterator iterator() { @Override public boolean equals(@Nullable Object o) { - return PropertyPathUtil.equals(this, o); + return PropertyUtil.equals(this, o); } @Override public int hashCode() { - return PropertyPathUtil.hashCode(this); + return PropertyUtil.hashCode(this); } @Override diff --git a/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java b/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java index 37a4404d03..798228e554 100644 --- a/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java +++ b/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java @@ -44,7 +44,11 @@ * @author Johannes Englmeier * @since 2.2 * @soundtrack The Intersphere - Don't Think Twice (The Grand Delusion) + * @deprecated since 4.1 in favor of {@link org.springframework.data.core.PropertyReference} and + * {@link org.springframework.data.core.TypedPropertyPath} infrastructure and limitations imposed by + * subclass proxy limitations. */ +@Deprecated(since = "4.1") public class MethodInvocationRecorder { public static PropertyNameDetectionStrategy DEFAULT = DefaultPropertyNameDetectionStrategy.INSTANCE; diff --git a/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt index b8ed3a8888..123477b1ca 100644 --- a/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt +++ b/src/main/kotlin/org/springframework/data/core/PropertyReferenceExtensions.kt @@ -88,7 +88,7 @@ class KPropertyReference { val property1 = property as KProperty1<*, *> val owner = property1.javaField?.declaringClass ?: property1.javaGetter?.declaringClass - val metadata = PropertyReferences.KPropertyReferenceMetadata.of( + val metadata = PropertyReferences.KPropertyMetadata.of( MemberDescriptor.KPropertyReferenceDescriptor.create( owner, property1 diff --git a/src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java b/src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java index 3f154f0117..4bbfb0a4c2 100644 --- a/src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java +++ b/src/test/java/org/springframework/data/convert/PropertyValueConverterRegistrarUnitTests.java @@ -26,6 +26,7 @@ * Unit tests for {@link ValueConverterRegistry}. * * @author Christoph Strobl + * @author Mark Paluch */ @SuppressWarnings({ "rawtypes", "unchecked" }) class PropertyValueConverterRegistrarUnitTests { @@ -68,6 +69,20 @@ void allowsTypeSafeConverterRegistration() { assertThat(name.read("off", null)).isEqualTo("off"); } + @Test // GH-3400 + void allowsTypeSafeConverterRegistrationViaPropertyReference() { + + PropertyValueConverterRegistrar registrar = new PropertyValueConverterRegistrar<>(); + registrar.registerConverter(Person::getName) // + .writing(PropertyValueConverterRegistrarUnitTests::reverse) // + .readingAsIs(); + + PropertyValueConverter> name = registrar + .buildRegistry().getConverter(Person.class, "name"); + assertThat(name.write("foo", null)).isEqualTo("oof"); + assertThat(name.read("мир", null)).isEqualTo("мир"); + } + @Test // GH-1484 void allowsTypeSafeConverterRegistrationViaRecordedProperty() { diff --git a/src/test/java/org/springframework/data/core/MemberReferenceTests.java b/src/test/java/org/springframework/data/core/MemberReferenceTests.java deleted file mode 100644 index d4aa7e3258..0000000000 --- a/src/test/java/org/springframework/data/core/MemberReferenceTests.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.core; - -import static org.assertj.core.api.Assertions.*; - -import java.io.Serializable; - -import org.junit.jupiter.api.Test; - -import org.springframework.core.convert.converter.Converter; - -/** - * @author Mark Paluch - */ -class MemberReferenceTests { - - @Test - void shouldResolveMember() throws NoSuchMethodException { - - MemberReference reference = Person::name; - - assertThat(reference.getMember()).isEqualTo(Person.class.getMethod("name")); - } - - @Test - void retrofitConverter() throws NoSuchMethodException { - - Converter reference = convert(Person::name); - - assertThat(MemberReference.resolve(reference)).isEqualTo(Person.class.getMethod("name")); - } - - static & Serializable> T convert(T converter) { - return converter; - } - - record Person(String name) { - - } -} diff --git a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java index 0232554848..79ff65ea3e 100644 --- a/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java +++ b/src/test/java/org/springframework/data/core/TypedPropertyPathUnitTests.java @@ -69,6 +69,13 @@ void resolvesMHComposedPath() { .isEqualTo("address.country"); } + @Test // GH-3400 + void resolvesMHComposedPathThroughFactory() { + assertThat(TypedPropertyPath + .path(PersonQuery::getEmergencyContact, PersonQuery::getAddress, Address::getCountry, Country::name) + .toDotPath()).isEqualTo("emergencyContact.address.country.name"); + } + @Test // GH-3400 void resolvesCollectionPath() { assertThat(PropertyPath.ofMany(PersonQuery::getAddresses).then(Address::getCity).toDotPath()) @@ -200,6 +207,7 @@ void resolvesMRRecordPath() { @Test // GH-3400 void arithmeticOpsFail() { + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> { PropertyPath.of((PersonQuery person) -> { int a = 1 + 2; diff --git a/src/test/java/org/springframework/data/domain/SortUnitTests.java b/src/test/java/org/springframework/data/domain/SortUnitTests.java index d59fcbd93d..cf0bae3442 100755 --- a/src/test/java/org/springframework/data/domain/SortUnitTests.java +++ b/src/test/java/org/springframework/data/domain/SortUnitTests.java @@ -16,12 +16,13 @@ package org.springframework.data.domain; import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.core.TypedPropertyPath.*; import static org.springframework.data.domain.Sort.NullHandling.*; import java.util.Collection; import org.junit.jupiter.api.Test; -import org.springframework.data.core.TypedPropertyPath; + import org.springframework.data.domain.Sort.Direction; import org.springframework.data.domain.Sort.Order; import org.springframework.data.geo.Circle; @@ -59,7 +60,7 @@ record PersonHolder(Person person) { assertThat(Sort.by(Person::getFirstName).iterator().next().getProperty()).isEqualTo("firstName"); assertThat( - Sort.by(TypedPropertyPath.ofProperty(PersonHolder::person).then(Person::getFirstName)).iterator().next() + Sort.by(path(PersonHolder::person, Person::getFirstName)).iterator().next() .getProperty()) .isEqualTo("person.firstName"); } diff --git a/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt index b9fa84a731..22ec18fc7c 100644 --- a/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/KPropertyReferenceUnitTests.kt @@ -37,7 +37,11 @@ class KPropertyReferenceUnitTests { @Test // GH-3400 fun composedReferenceCreationShouldFail() { - assertThatIllegalArgumentException().isThrownBy { PropertyReference.of(Person::address / Address::city) } + assertThatIllegalArgumentException().isThrownBy { + PropertyReference.property( + Person::address / Address::city + ) + } assertThatIllegalArgumentException().isThrownBy { KPropertyReference.of(Person::address / Address::city) } } diff --git a/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt index 60834b0957..e30b92fc17 100644 --- a/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/PropertyReferenceKtUnitTests.kt @@ -13,13 +13,13 @@ class PropertyReferenceKtUnitTests { @Test // GH-3400 fun shouldSupportPropertyReference() { - assertThat(PropertyReference.of(Person::address).name).isEqualTo("address") + assertThat(PropertyReference.property(Person::address).name).isEqualTo("address") } @Test // GH-3400 fun resolutionShouldFailForComposedPropertyPath() { assertThatIllegalArgumentException() - .isThrownBy { PropertyReference.of(Person::address / Address::city) } + .isThrownBy { PropertyReference.property(Person::address / Address::city) } } class Person { diff --git a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt index a814fbe3cf..3dd5043ede 100644 --- a/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt +++ b/src/test/kotlin/org/springframework/data/core/TypedPropertyPathKtUnitTests.kt @@ -29,24 +29,24 @@ class TypedPropertyPathKtUnitTests { return Stream.of( Arguments.argumentSet( "Person.name", - TypedPropertyPath.ofProperty(Person::name), + TypedPropertyPath.path(Person::name), PropertyPath.from("name", Person::class.java) ), Arguments.argumentSet( "Person.address.country", - TypedPropertyPath.ofProperty(Person::address) + TypedPropertyPath.path(Person::address) .then(Address::country), PropertyPath.from("address.country", Person::class.java) ), Arguments.argumentSet( "Person.address.country.name", - TypedPropertyPath.ofProperty(Person::address) + TypedPropertyPath.path(Person::address) .then(Address::country).then(Country::name), PropertyPath.from("address.country.name", Person::class.java) ), Arguments.argumentSet( "Person.emergencyContact.address.country.name", - TypedPropertyPath.ofProperty(Person::emergencyContact) + TypedPropertyPath.path(Person::emergencyContact) .then

(Person::address).then(Address::country) .then(Country::name), PropertyPath.from( @@ -62,37 +62,37 @@ class TypedPropertyPathKtUnitTests { fun shouldSupportPropertyReference() { assertThat( - TypedPropertyPath.ofProperty(Person::address).toDotPath() + TypedPropertyPath.path(Person::address).toDotPath() ).isEqualTo("address") } @Test // GH-3400 fun shouldSupportComposedPropertyReference() { - val path = TypedPropertyPath.ofProperty(Person::address) + val path = TypedPropertyPath.path(Person::address) .then(Address::city); assertThat(path.toDotPath()).isEqualTo("address.city") } @Test // GH-3400 fun shouldSupportPropertyLambda() { - assertThat(TypedPropertyPath.ofProperty { it.address } + assertThat(TypedPropertyPath.path { it.address } .toDotPath()).isEqualTo("address") - assertThat(TypedPropertyPath.ofProperty { foo -> foo.address } + assertThat(TypedPropertyPath.path { foo -> foo.address } .toDotPath()).isEqualTo("address") } @Test // GH-3400 fun shouldSupportComposedPropertyLambda() { - val path = TypedPropertyPath.ofProperty { it.address }; + val path = TypedPropertyPath.path { it.address }; assertThat(path.then { it.city }.toDotPath()).isEqualTo("address.city") } @Test // GH-3400 fun shouldSupportComposedKProperty() { - val path = TypedPropertyPath.ofProperty(Person::address / Address::city); + val path = TypedPropertyPath.path(Person::address / Address::city); assertThat(path.toDotPath()).isEqualTo("address.city") val otherPath = TypedPropertyPath.of(Person::address / Address::city); From f383e78c4022ec29e25f79d97fcf9278a9a7cccf Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 20 Nov 2025 11:53:12 +0100 Subject: [PATCH 24/25] Expose PropertyPathUtil. Allow generic parsing of property paths from serialized lambdas. --- ...ropertyUtil.java => PropertyPathUtil.java} | 47 +++++++++++++- .../data/core/PropertyPathUtilUnitTests.java | 63 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) rename src/main/java/org/springframework/data/core/{PropertyUtil.java => PropertyPathUtil.java} (62%) create mode 100644 src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java diff --git a/src/main/java/org/springframework/data/core/PropertyUtil.java b/src/main/java/org/springframework/data/core/PropertyPathUtil.java similarity index 62% rename from src/main/java/org/springframework/data/core/PropertyUtil.java rename to src/main/java/org/springframework/data/core/PropertyPathUtil.java index 443dbfb8e9..b46c47d84e 100644 --- a/src/main/java/org/springframework/data/core/PropertyUtil.java +++ b/src/main/java/org/springframework/data/core/PropertyPathUtil.java @@ -15,17 +15,62 @@ */ 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 */ -class PropertyUtil { +public class PropertyPathUtil { + + /** + * Resolve a {@link PropertyPath} from a {@link Serializable} lambda implementing a functional interface accepting a + * single method argument and returning a value. The form of the interface must follow a design aligned with + * {@link org.springframework.core.convert.converter.Converter} or {@link java.util.function.Function}. + * + * @param obj the serializable lambda object. + * @return the resolved property path. + */ + public static PropertyPath resolve(Object obj) { + + Assert.isInstanceOf(Serializable.class, obj, "Object must be Serializable"); + + return TypedPropertyPaths.of(new SerializableWrapper((Serializable) obj)); + } + + private record SerializableWrapper(Serializable serializable) implements PropertyReference { + + @Override + public @Nullable Object get(Object obj) { + return null; + } + + // serializable bridge + public SerializedLambda writeReplace() { + + Method method = ReflectionUtils.findMethod(serializable.getClass(), "writeReplace"); + + if (method == null) { + throw new InvalidDataAccessApiUsageException( + "Cannot find writeReplace method on " + serializable.getClass().getName()); + } + + ReflectionUtils.makeAccessible(method); + return (SerializedLambda) ReflectionUtils.invokeMethod(method, serializable); + } + + } /** * Compute the hash code for the given {@link PropertyPath} based on its {@link Object#toString() string} diff --git a/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java b/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java new file mode 100644 index 0000000000..256d5a99f8 --- /dev/null +++ b/src/test/java/org/springframework/data/core/PropertyPathUtilUnitTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.core; + +import java.io.Serializable; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.core.convert.converter.Converter; + +/** + * Unit test {@link PropertyPathUtil}. + * + * @author Mark Paluch + */ +class PropertyPathUtilUnitTests { + + @Test + void shouldResolvePropertyPath() { + + Converter c = convert(Person::getName); + + System.out.println(PropertyPathUtil.resolve(c)); + } + + static & Serializable> Serializable of(C mapping) { + return mapping; + } + + static & Serializable> T convert(T converter) { + return converter; + } + + static class Person { + + private String name; + private @Nullable Integer age; + + // Getters + public String getName() { + return name; + } + + public @Nullable Integer getAge() { + return age; + } + + } +} From b1d6892ffde3e19c9a2c6fc09bd54eb2368ee5a4 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 20 Nov 2025 11:53:38 +0100 Subject: [PATCH 25/25] Documentation, polishing. --- .../modules/ROOT/pages/property-paths.adoc | 8 +++ .../data/core/PropertyPath.java | 21 ++++--- .../data/core/PropertyReference.java | 27 ++++---- .../data/core/PropertyReferences.java | 4 +- .../data/core/SerializableLambdaReader.java | 63 ++++++++++++------- .../data/core/SimplePropertyPath.java | 4 +- .../data/core/TypedPropertyPath.java | 55 +++++++++------- .../data/core/TypedPropertyPaths.java | 12 ++-- 8 files changed, 118 insertions(+), 76 deletions(-) diff --git a/src/main/antora/modules/ROOT/pages/property-paths.adoc b/src/main/antora/modules/ROOT/pages/property-paths.adoc index 381d5b432c..d1dc0982ed 100644 --- a/src/main/antora/modules/ROOT/pages/property-paths.adoc +++ b/src/main/antora/modules/ROOT/pages/property-paths.adoc @@ -131,6 +131,14 @@ Java:: + [source,java,role="primary"] ---- +// Static import variant +path(Person::getAddress) + .then(Address::getCity); + +// Composition factory method +path(Person::getAddress, Address::getCity); + +// Fluent composition TypedPropertyPath.of(Person::getAddress) .then(Address::getCity); ---- diff --git a/src/main/java/org/springframework/data/core/PropertyPath.java b/src/main/java/org/springframework/data/core/PropertyPath.java index f08a1a03b7..68b7235e01 100644 --- a/src/main/java/org/springframework/data/core/PropertyPath.java +++ b/src/main/java/org/springframework/data/core/PropertyPath.java @@ -45,38 +45,39 @@ * @author Johannes Englmeier * @see PropertyReference * @see TypedPropertyPath + * @see java.beans.PropertyDescriptor */ public interface PropertyPath extends Streamable { /** - * Syntax sugar to create a {@link TypedPropertyPath} from a method reference or lambda. + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans property. *

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

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

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

property type. * @return the typed property path. * @since 4.1 */ @SuppressWarnings({ "unchecked", "rawtypes" }) - static TypedPropertyPath ofMany(PropertyReference> propertyPath) { - return (TypedPropertyPath) TypedPropertyPaths.of(propertyPath); + static TypedPropertyPath ofMany(PropertyReference> property) { + return (TypedPropertyPath) TypedPropertyPaths.of(property); } /** diff --git a/src/main/java/org/springframework/data/core/PropertyReference.java b/src/main/java/org/springframework/data/core/PropertyReference.java index 2e4b187033..97e374bf7a 100644 --- a/src/main/java/org/springframework/data/core/PropertyReference.java +++ b/src/main/java/org/springframework/data/core/PropertyReference.java @@ -30,7 +30,7 @@ * reference, for example: * *

- * PropertyReference<Person, String> name = PropertyReference.of(Person::getName);
+ * PropertyReference.property(Person::getName);
  * 
* * The resulting object can be used to obtain the {@link #getName() property name} and to interact with the target @@ -56,18 +56,23 @@ * @param

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

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

property type. * @return the typed property reference. @@ -77,11 +82,11 @@ public interface PropertyReference extends Serial } /** - * Syntax sugar to create a {@link PropertyReference} from a method reference. + * Syntax sugar to create a {@link PropertyReference} from a method reference to a Java beans property. *

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

property type. * @return the typed property reference. @@ -91,13 +96,13 @@ public interface PropertyReference extends Serial } /** - * Syntax sugar to create a {@link PropertyReference} from a method reference or lambda for a collection property. + * Syntax sugar to create a {@link PropertyReference} from a method reference to a Java beans property. *

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

property type. * @return the typed property reference. @@ -170,8 +175,8 @@ default boolean isCollection() { * 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 or lambda accepting the owner object {@code P} type - * and returning {@code N} as result of accessing a property. + * @param next the next property path segment as method reference accepting the owner object {@code P} type and + * returning {@code N} as result of accessing the property. * @param the new property value type. * @return a new composed {@code TypedPropertyPath}. */ @@ -186,8 +191,8 @@ default boolean isCollection() { * Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from * {@code Iterable <P>} and a single {@code P} cannot represent a collection of items. * - * @param next the next property path segment as method reference or lambda accepting the owner object {@code P} type - * and returning {@code N} as result of accessing a property. + * @param next the next property path segment as method reference accepting the owner object {@code P} type and + * returning {@code N} as result of accessing the property. * @param the new property value type. * @return a new composed {@code TypedPropertyPath}. */ diff --git a/src/main/java/org/springframework/data/core/PropertyReferences.java b/src/main/java/org/springframework/data/core/PropertyReferences.java index d81dbaf7ee..98540ecb00 100644 --- a/src/main/java/org/springframework/data/core/PropertyReferences.java +++ b/src/main/java/org/springframework/data/core/PropertyReferences.java @@ -252,12 +252,12 @@ public TypeInformation

getTypeInformation() { @Override public boolean equals(@Nullable Object obj) { - return PropertyUtil.equals(this, obj); + return PropertyPathUtil.equals(this, obj); } @Override public int hashCode() { - return PropertyUtil.hashCode(this); + return PropertyPathUtil.hashCode(this); } @Override diff --git a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java index 71f8fa58bb..4ddeef66fe 100644 --- a/src/main/java/org/springframework/data/core/SerializableLambdaReader.java +++ b/src/main/java/org/springframework/data/core/SerializableLambdaReader.java @@ -141,17 +141,7 @@ public MemberDescriptor read(Object lambdaObject) { SerializedLambda lambda = serialize(lambdaObject); if (isKotlinPropertyReference(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); - } + return KotlinDelegate.read(lambda); } assertNotConstructor(lambda); @@ -178,11 +168,6 @@ public MemberDescriptor read(Object lambdaObject) { + ". The given value is not a lambda or method reference."); } - public static boolean isKotlinPropertyReference(SerializedLambda lambda) { - return KotlinDetector.isKotlinReflectPresent() && lambda.getCapturedArgCount() == 1 - && lambda.getCapturedArg(0) != null && KotlinDetector.isKotlinType(lambda.getCapturedArg(0).getClass()); - } - private void assertNotConstructor(SerializedLambda lambda) { if (lambda.getImplMethodKind() == MethodHandleInfo.REF_newInvokeSpecial @@ -219,7 +204,7 @@ private MemberDescriptor getMemberDescriptor(Object lambdaObject, SerializedLamb } } - static SerializedLambda serialize(Object lambda) { + private static SerializedLambda serialize(Object lambda) { try { Method method = lambda.getClass().getDeclaredMethod("writeReplace"); @@ -231,6 +216,40 @@ static SerializedLambda serialize(Object lambda) { } } + private static boolean isKotlinPropertyReference(SerializedLambda lambda) { + + return KotlinDetector.isKotlinReflectPresent() // + && lambda.getCapturedArgCount() == 1 // + && lambda.getCapturedArg(0) != null // + && KotlinDetector.isKotlinType(lambda.getCapturedArg(0).getClass()); + } + + /** + * Delegate to detect and read Kotlin property references. + *

+ * Inner class delays loading of Kotlin classes. + */ + static class KotlinDelegate { + + public static MemberDescriptor read(SerializedLambda lambda) { + + Object captured = lambda.getCapturedArg(0); + + if (captured instanceof PropertyReference propRef // + && propRef.getOwner() instanceof KClass owner // + && captured instanceof KProperty1 kProperty) { + return new KPropertyReferenceDescriptor(JvmClassMappingKt.getJavaClass(owner), kProperty); + } + + if (captured instanceof KPropertyPath propRef) { + return KPropertyPathDescriptor.create(propRef); + } + + throw new InvalidDataAccessApiUsageException("Cannot extract MemberDescriptor from: " + lambda); + } + + } + class LambdaReadingVisitor extends ClassVisitor { private final String implMethodName; @@ -306,14 +325,14 @@ public void visitInsn(int opcode) { @Override public void visitLdcInsn(Object value) { errors.add(new ReadingError(line, - "Lambda expressions may only contain method calls to getters, record components, or field access", null)); + "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, "Setting a field not allowed", null)); + errors.add(new ReadingError(line, String.format("Code attempts to set field '%s'", name), null)); return; } @@ -334,7 +353,7 @@ public void visitFieldInsn(int opcode, String owner, String name, String descrip public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { if (opcode == Opcodes.INVOKESPECIAL && name.equals("")) { - errors.add(new ReadingError(line, "Lambda must not invoke constructors", null)); + errors.add(new ReadingError(line, "Constructor calls not supported.", null)); return; } @@ -356,8 +375,8 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri Type[] argumentTypes = Type.getArgumentTypes(descriptor); String signature = Arrays.stream(argumentTypes).map(Type::getClassName).collect(Collectors.joining(", ")); errors.add(new ReadingError(line, - "Cannot derive a method reference from method invocation '%s(%s)' on a different type than the owning one.%nExpected owning type: '%s', but was: '%s'" - .formatted(name, signature, this.owningType.getClassName(), ownerType.getClassName()))); + "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; } diff --git a/src/main/java/org/springframework/data/core/SimplePropertyPath.java b/src/main/java/org/springframework/data/core/SimplePropertyPath.java index aaecacf172..a8dfb2211a 100644 --- a/src/main/java/org/springframework/data/core/SimplePropertyPath.java +++ b/src/main/java/org/springframework/data/core/SimplePropertyPath.java @@ -177,12 +177,12 @@ public boolean hasNext() { @Override public boolean equals(@Nullable Object o) { - return PropertyUtil.equals(this, o); + return PropertyPathUtil.equals(this, o); } @Override public int hashCode() { - return PropertyUtil.hashCode(this); + return PropertyPathUtil.hashCode(this); } /** diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPath.java b/src/main/java/org/springframework/data/core/TypedPropertyPath.java index bb1e1fdf71..66d788b2ce 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPath.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPath.java @@ -33,7 +33,7 @@ * for example: * *

- * TypedPropertyPath<Person, String> name = TypedPropertyPath.of(Person::getName);
+ * TypedPropertyPath.path(Person::getName);
  * 
* * The resulting object can be used to obtain the {@link #toDotPath() dot-path} and to interact with the targeting @@ -41,6 +41,10 @@ * {@link #then(PropertyReference)}: * *
+ * // factory method chaining
+ * TypedPropertyPath<Person, String> city = TypedPropertyPath.path(Person::getAddress, Address::getCity);
+ *
+ * // fluent API
  * TypedPropertyPath<Person, String> city = TypedPropertyPath.of(Person::getAddress).then(Address::getCity);
  * 
*

@@ -59,19 +63,23 @@ * @param

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

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

property type. * @return the typed property path. @@ -81,8 +89,8 @@ public interface TypedPropertyPath extends Proper } /** - * Syntax sugar to create a composed {@link TypedPropertyPath} from properties described as method reference. Suitable - * for static imports. + * Syntax sugar to create a composed {@link TypedPropertyPath} from properties described as method reference to a Java + * beans property. Suitable for static imports. *

* This method returns a resolved {@link TypedPropertyPath} by introspecting the given method references. * @@ -98,14 +106,14 @@ public interface TypedPropertyPath extends Proper } /** - * Syntax sugar to create a composed {@link TypedPropertyPath} from properties described as method reference. Suitable - * for static imports. + * Syntax sugar to create a composed {@link TypedPropertyPath} from properties described as method reference to a Java + * beans property. Suitable for static imports. *

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

property type. * @return the typed property path. @@ -116,14 +124,15 @@ public interface TypedPropertyPath extends Proper } /** - * Syntax sugar to create a composed {@link TypedPropertyPath} from properties described as method reference. Suitable - * for static imports. + * Syntax sugar to create a composed {@link TypedPropertyPath} from properties described as method reference to a Java + * beans property. Suitable for static imports. *

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

property type. * @return the typed property path. @@ -135,35 +144,35 @@ public interface TypedPropertyPath extends Proper } /** - * Syntax sugar to create a {@link TypedPropertyPath} from a method reference. + * Syntax sugar to create a {@link TypedPropertyPath} from a method reference to a Java beans property. *

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

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

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

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

property type. * @return the typed property path. */ @SuppressWarnings({ "unchecked", "rawtypes" }) - static TypedPropertyPath ofMany(TypedPropertyPath> propertyPath) { - return (TypedPropertyPath) TypedPropertyPaths.of(propertyPath); + static TypedPropertyPath ofMany(TypedPropertyPath> property) { + return (TypedPropertyPath) TypedPropertyPaths.of(property); } /** @@ -207,7 +216,7 @@ default Iterator iterator() { } /** - * Extend the property path by appending the {@code next} path segment and returning a new property path instance. + * 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. @@ -219,7 +228,7 @@ default Iterator iterator() { } /** - * Extend the property path by appending the {@code next} path segment and returning a new property path instance. + * Extend the property path by appending the {@code next} path segment and return a new property path instance. *

* Note that {@link #get(Object)} becomes unusable for collection properties as the property type adapted from * {@code Iterable <P>} and a single {@code P} cannot represent a collection of items. diff --git a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java index bc801aa3ba..d82c35904b 100644 --- a/src/main/java/org/springframework/data/core/TypedPropertyPaths.java +++ b/src/main/java/org/springframework/data/core/TypedPropertyPaths.java @@ -317,12 +317,12 @@ public List toList() { @Override public boolean equals(@Nullable Object obj) { - return PropertyUtil.equals(this, obj); + return PropertyPathUtil.equals(this, obj); } @Override public int hashCode() { - return PropertyUtil.hashCode(this); + return PropertyPathUtil.hashCode(this); } @Override @@ -386,12 +386,12 @@ public List toList() { @Override public boolean equals(@Nullable Object obj) { - return PropertyUtil.equals(this, obj); + return PropertyPathUtil.equals(this, obj); } @Override public int hashCode() { - return PropertyUtil.hashCode(this); + return PropertyPathUtil.hashCode(this); } @Override @@ -554,12 +554,12 @@ public Iterator iterator() { @Override public boolean equals(@Nullable Object o) { - return PropertyUtil.equals(this, o); + return PropertyPathUtil.equals(this, o); } @Override public int hashCode() { - return PropertyUtil.hashCode(this); + return PropertyPathUtil.hashCode(this); } @Override