diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-elvis.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-elvis.adoc index 964ba2b5811..e4c2ff636d3 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-elvis.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-elvis.adoc @@ -46,6 +46,17 @@ need to use `name != null && !name.isEmpty()` as the predicate to be compatible semantics of the SpEL Elvis operator. ==== +[TIP] +==== +As of Spring Framework 7.0, the SpEL Elvis operator supports `java.util.Optional` with +transparent unwrapping semantics. + +For example, given the expression `A ?: B`, if `A` is `null` or an _empty_ `Optional`, +the expression evaluates to `B`. However, if `A` is a non-empty `Optional` the expression +evaluates to the object contained in the `Optional`, thereby effectively unwrapping the +`Optional` which correlates to `A.get()`. +==== + The following listing shows a more complex example: [tabs] diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc index 7d789538998..2ab7a549818 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc @@ -350,6 +350,50 @@ Kotlin:: <2> Use null-safe projection operator on null `members` list ====== +[[expressions-operator-safe-navigation-optional]] +== Null-safe Operations on `Optional` + +As of Spring Framework 7.0, null-safe operations are supported on instances of +`java.util.Optional` with transparent unwrapping semantics. + +Specifically, when a null-safe operator is applied to an _empty_ `Optional`, it will be +treated as if the `Optional` were `null`, and the subsequent operation will evaluate to +`null`. However, if a null-safe operator is applied to a non-empty `Optional`, the +subsequent operation will be applied to the object contained in the `Optional`, thereby +effectively unwrapping the `Optional`. + +For example, if `user` is of type `Optional`, the expression `user?.name` will +evaluate to `null` if `user` is either `null` or an _empty_ `Optional` and will otherwise +evaluate to the `name` of the `user`, effectively `user.get().getName()` or +`user.get().name` for property or field access, respectively. + +[NOTE] +==== +Invocations of methods defined in the `Optional` API are still supported on an _empty_ +`Optional`. For example, if `name` is of type `Optional`, the expression +`name?.orElse('Unknown')` will evaluate to `"Unknown"` if `name` is an empty `Optional` +and will otherwise evaluate to the `String` contained in the `Optional` if `name` is a +non-empty `Optional`, effectively `name.get()`. +==== + +// NOTE: ⁠ is the Unicode Character 'WORD JOINER', which prevents undesired line wraps. + +Similarly, if `names` is of type `Optional>`, the expression +`names?.?⁠[#this.length > 5]` will evaluate to `null` if `names` is `null` or an _empty_ +`Optional` and will otherwise evaluate to a sequence containing the names whose lengths +are greater than 5, effectively +`names.get().stream().filter(s -> s.length() > 5).toList()`. + +The same semantics apply to all of the null-safe operators mentioned previously in this +chapter. + +For further details and examples, consult the javadoc for the following operators. + +* {spring-framework-api}/expression/spel/ast/PropertyOrFieldReference.html[`PropertyOrFieldReference`] +* {spring-framework-api}/expression/spel/ast/MethodReference.html[`MethodReference`] +* {spring-framework-api}/expression/spel/ast/Indexer.html[`Indexer`] +* {spring-framework-api}/expression/spel/ast/Selection.html[`Selection`] +* {spring-framework-api}/expression/spel/ast/Projection.html[`Projection`] [[expressions-operator-safe-navigation-compound-expressions]] == Null-safe Operations in Compound Expressions diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java index 6f6c26f8f29..f6c8fbbf374 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-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. @@ -16,6 +16,8 @@ package org.springframework.expression.spel.ast; +import java.util.Optional; + import org.springframework.asm.Label; import org.springframework.asm.MethodVisitor; import org.springframework.expression.EvaluationException; @@ -26,9 +28,13 @@ import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; /** - * Represents the Elvis operator ?:. For an expression a?:b if a is neither null - * nor an empty String, the value of the expression is a. - * If a is null or the empty String, then the value of the expression is b. + * Represents the Elvis operator {@code ?:}. + * + *

For the expression "{@code A ?: B}", if {@code A} is neither {@code null}, + * an empty {@link Optional}, nor an empty {@link String}, the value of the + * expression is {@code A}, or {@code A.get()} for an {@code Optional}. If + * {@code A} is {@code null}, an empty {@code Optional}, or an + * empty {@code String}, the value of the expression is {@code B}. * * @author Andy Clement * @author Juergen Hoeller @@ -43,18 +49,32 @@ public class Elvis extends SpelNodeImpl { /** - * Evaluate the condition and if neither null nor an empty String, return it. - * If it is null or an empty String, return the other value. + * If the left-hand operand is neither neither {@code null}, an empty + * {@link Optional}, nor an empty {@link String}, return its value, or the + * value contained in the {@code Optional}. If the left-hand operand is + * {@code null}, an empty {@code Optional}, or an empty {@code String}, + * return the other value. * @param state the expression state - * @throws EvaluationException if the condition does not evaluate correctly - * to a boolean or there is a problem executing the chosen alternative + * @throws EvaluationException if the null/empty check does not evaluate correctly + * or there is a problem evaluating the alternative */ @Override public TypedValue getValueInternal(ExpressionState state) throws EvaluationException { - TypedValue value = this.children[0].getValueInternal(state); + TypedValue leftHandTypedValue = this.children[0].getValueInternal(state); + Object leftHandValue = leftHandTypedValue.getValue(); + + if (leftHandValue instanceof Optional optional) { + // Compilation is currently not supported for Optional with the Elvis operator. + this.exitTypeDescriptor = null; + if (optional.isPresent()) { + return new TypedValue(optional.get()); + } + return this.children[1].getValueInternal(state); + } + // If this check is changed, the generateCode method will need changing too - if (value.getValue() != null && !"".equals(value.getValue())) { - return value; + if (leftHandValue != null && !"".equals(leftHandValue)) { + return leftHandTypedValue; } else { TypedValue result = this.children[1].getValueInternal(state); diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java index a32c17f2083..850e2c02a7c 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java @@ -20,6 +20,7 @@ import java.lang.reflect.Constructor; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Supplier; import org.jspecify.annotations.Nullable; @@ -67,7 +68,12 @@ import org.springframework.util.ReflectionUtils; *

As of Spring Framework 6.2, null-safe indexing is supported via the {@code '?.'} * operator. For example, {@code 'colors?.[0]'} will evaluate to {@code null} if * {@code colors} is {@code null} and will otherwise evaluate to the 0th - * color. + * color. As of Spring Framework 7.0, null-safe indexing also applies when + * indexing into a structure contained in an {@link Optional}. For example, if + * {@code colors} is of type {@code Optional}, the expression + * {@code 'colors?.[0]'} will evaluate to {@code null} if {@code colors} is + * {@code null} or {@link Optional#isEmpty() empty} and will otherwise evaluate + * to the 0th color, effectively {@code colors.get()[0]}. * * @author Andy Clement * @author Phillip Webb @@ -165,11 +171,20 @@ public class Indexer extends SpelNodeImpl { TypedValue context = state.getActiveContextObject(); Object target = context.getValue(); - if (target == null) { - if (isNullSafe()) { + if (isNullSafe()) { + if (target == null) { return ValueRef.NullValueRef.INSTANCE; } - // Raise a proper exception in case of a null target + if (target instanceof Optional optional) { + if (optional.isEmpty()) { + return ValueRef.NullValueRef.INSTANCE; + } + target = optional.get(); + } + } + + // Raise a proper exception in case of a null target + if (target == null) { throw new SpelEvaluationException(getStartPosition(), SpelMessage.CANNOT_INDEX_INTO_NULL_VALUE); } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java index 593c41e6e09..499419c2831 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java @@ -23,6 +23,7 @@ import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.StringJoiner; import org.jspecify.annotations.Nullable; @@ -50,6 +51,19 @@ import org.springframework.util.ObjectUtils; * Expression language AST node that represents a method reference (i.e., a * method invocation other than a simple property reference). * + *

Null-safe Invocation

+ * + *

Null-safe invocation is supported via the {@code '?.'} operator. For example, + * {@code 'counter?.incrementBy(1)'} will evaluate to {@code null} if {@code counter} + * is {@code null} and will otherwise evaluate to the value returned from the + * invocation of {@code counter.incrementBy(1)}. As of Spring Framework 7.0, + * null-safe invocation also applies when invoking a method on an {@link Optional} + * target. For example, if {@code counter} is of type {@code Optional}, + * the expression {@code 'counter?.incrementBy(1)'} will evaluate to {@code null} + * if {@code counter} is {@code null} or {@link Optional#isEmpty() empty} and will + * otherwise evaluate the value returned from the invocation of + * {@code counter.get().incrementBy(1)}. + * * @author Andy Clement * @author Juergen Hoeller * @author Sam Brannen @@ -93,7 +107,9 @@ public class MethodReference extends SpelNodeImpl { protected ValueRef getValueRef(ExpressionState state) throws EvaluationException { @Nullable Object[] arguments = getArguments(state); if (state.getActiveContextObject().getValue() == null) { - throwIfNotNullSafe(getArgumentTypes(arguments)); + if (!isNullSafe()) { + throw nullTargetException(getArgumentTypes(arguments)); + } return ValueRef.NullValueRef.INSTANCE; } return new MethodValueRef(state, arguments); @@ -115,9 +131,26 @@ public class MethodReference extends SpelNodeImpl { @Nullable TypeDescriptor targetType, @Nullable Object[] arguments) { List argumentTypes = getArgumentTypes(arguments); + Optional fallbackOptionalTarget = null; + boolean isEmptyOptional = false; + + if (isNullSafe()) { + if (target == null) { + return TypedValue.NULL; + } + if (target instanceof Optional optional) { + if (optional.isPresent()) { + target = optional.get(); + fallbackOptionalTarget = optional; + } + else { + isEmptyOptional = true; + } + } + } + if (target == null) { - throwIfNotNullSafe(argumentTypes); - return TypedValue.NULL; + throw nullTargetException(argumentTypes); } MethodExecutor executorToUse = getCachedExecutor(evaluationContext, target, targetType, argumentTypes); @@ -142,31 +175,64 @@ public class MethodReference extends SpelNodeImpl { // At this point we know it wasn't a user problem so worth a retry if a // better candidate can be found. this.cachedExecutor = null; + executorToUse = null; + } + } + + // Either there was no cached executor, or it no longer exists. + + // First, attempt to find the method on the target object. + Object targetToUse = target; + MethodExecutorSearchResult searchResult = findMethodExecutor(argumentTypes, target, evaluationContext); + if (searchResult.methodExecutor != null) { + executorToUse = searchResult.methodExecutor; + } + // Second, attempt to find the method on the original Optional instance. + else if (fallbackOptionalTarget != null) { + searchResult = findMethodExecutor(argumentTypes, fallbackOptionalTarget, evaluationContext); + if (searchResult.methodExecutor != null) { + executorToUse = searchResult.methodExecutor; + targetToUse = fallbackOptionalTarget; + } + } + // If we got this far, that means we failed to find an executor for both the + // target and the fallback target. So, we return NULL if the original target + // is a null-safe empty Optional. + else if (isEmptyOptional) { + return TypedValue.NULL; + } + + if (executorToUse == null) { + String method = FormatHelper.formatMethodForMessage(this.name, argumentTypes); + String className = FormatHelper.formatClassNameForMessage( + target instanceof Class clazz ? clazz : target.getClass()); + if (searchResult.accessException != null) { + throw new SpelEvaluationException( + getStartPosition(), searchResult.accessException, SpelMessage.PROBLEM_LOCATING_METHOD, method, className); + } + else { + throw new SpelEvaluationException(getStartPosition(), SpelMessage.METHOD_NOT_FOUND, method, className); } } - // either there was no accessor or it no longer existed - executorToUse = findMethodExecutor(argumentTypes, target, evaluationContext); this.cachedExecutor = new CachedMethodExecutor( - executorToUse, (target instanceof Class clazz ? clazz : null), targetType, argumentTypes); + executorToUse, (targetToUse instanceof Class clazz ? clazz : null), targetType, argumentTypes); try { - return executorToUse.execute(evaluationContext, target, arguments); + return executorToUse.execute(evaluationContext, targetToUse, arguments); } catch (AccessException ex) { // Same unwrapping exception handling as in above catch block - throwSimpleExceptionIfPossible(target, ex); + throwSimpleExceptionIfPossible(targetToUse, ex); throw new SpelEvaluationException(getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_METHOD_INVOCATION, this.name, - target.getClass().getName(), ex.getMessage()); + targetToUse.getClass().getName(), ex.getMessage()); } } - private void throwIfNotNullSafe(List argumentTypes) { - if (!isNullSafe()) { - throw new SpelEvaluationException(getStartPosition(), - SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED, - FormatHelper.formatMethodForMessage(this.name, argumentTypes)); - } + private SpelEvaluationException nullTargetException(List argumentTypes) { + return new SpelEvaluationException(getStartPosition(), + SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED, + FormatHelper.formatMethodForMessage(this.name, argumentTypes)); } private @Nullable Object[] getArguments(ExpressionState state) { @@ -209,7 +275,7 @@ public class MethodReference extends SpelNodeImpl { return null; } - private MethodExecutor findMethodExecutor(List argumentTypes, Object target, + private MethodExecutorSearchResult findMethodExecutor(List argumentTypes, Object target, EvaluationContext evaluationContext) throws SpelEvaluationException { AccessException accessException = null; @@ -218,7 +284,7 @@ public class MethodReference extends SpelNodeImpl { MethodExecutor methodExecutor = methodResolver.resolve( evaluationContext, target, this.name, argumentTypes); if (methodExecutor != null) { - return methodExecutor; + return new MethodExecutorSearchResult(methodExecutor, null); } } catch (AccessException ex) { @@ -227,16 +293,7 @@ public class MethodReference extends SpelNodeImpl { } } - String method = FormatHelper.formatMethodForMessage(this.name, argumentTypes); - String className = FormatHelper.formatClassNameForMessage( - target instanceof Class clazz ? clazz : target.getClass()); - if (accessException != null) { - throw new SpelEvaluationException( - getStartPosition(), accessException, SpelMessage.PROBLEM_LOCATING_METHOD, method, className); - } - else { - throw new SpelEvaluationException(getStartPosition(), SpelMessage.METHOD_NOT_FOUND, method, className); - } + return new MethodExecutorSearchResult(null, accessException); } /** @@ -411,6 +468,9 @@ public class MethodReference extends SpelNodeImpl { } + private record MethodExecutorSearchResult(@Nullable MethodExecutor methodExecutor, @Nullable AccessException accessException) { + } + private record CachedMethodExecutor(MethodExecutor methodExecutor, @Nullable Class staticClass, @Nullable TypeDescriptor targetType, List argumentTypes) { diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Projection.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Projection.java index 866fdc454a6..8ba69a49874 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Projection.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Projection.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Optional; import org.jspecify.annotations.Nullable; @@ -39,6 +40,19 @@ import org.springframework.util.ObjectUtils; *

For example: {1,2,3,4,5,6,7,8,9,10}.![#isEven(#this)] evaluates * to {@code [n, y, n, y, n, y, n, y, n, y]}. * + *

Null-safe Projection

+ * + *

Null-safe projection is supported via the {@code '?.!'} operator. For example, + * {@code 'names?.![#this.length]'} will evaluate to {@code null} if {@code names} + * is {@code null} and will otherwise evaluate to a sequence containing the lengths + * of the names. As of Spring Framework 7.0, null-safe projection also applies when + * performing projection on an {@link Optional} target. For example, if {@code names} + * is of type {@code Optional>}, the expression + * {@code 'names?.![#this.length]'} will evaluate to {@code null} if {@code names} + * is {@code null} or {@link Optional#isEmpty() empty} and will otherwise evaluate + * to a sequence containing the lengths of the names, effectively + * {@code names.get().stream().map(String::length).toList()}. + * * @author Andy Clement * @author Mark Fisher * @author Juergen Hoeller @@ -75,6 +89,22 @@ public class Projection extends SpelNodeImpl { TypedValue contextObject = state.getActiveContextObject(); Object operand = contextObject.getValue(); + if (isNullSafe()) { + if (operand == null) { + return ValueRef.NullValueRef.INSTANCE; + } + if (operand instanceof Optional optional) { + if (optional.isEmpty()) { + return ValueRef.NullValueRef.INSTANCE; + } + operand = optional.get(); + } + } + + if (operand == null) { + throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROJECTION_NOT_SUPPORTED_ON_TYPE, "null"); + } + // When the input is a map, we push a Map.Entry on the stack before calling // the specified operation. Map.Entry has two properties 'key' and 'value' // that can be referenced in the operation -- for example, @@ -130,13 +160,6 @@ public class Projection extends SpelNodeImpl { return new ValueRef.TypedValueHolderValueRef(new TypedValue(result),this); } - if (operand == null) { - if (isNullSafe()) { - return ValueRef.NullValueRef.INSTANCE; - } - throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROJECTION_NOT_SUPPORTED_ON_TYPE, "null"); - } - throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROJECTION_NOT_SUPPORTED_ON_TYPE, operand.getClass().getName()); } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java index 2f722a18655..078be1c4586 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Supplier; import org.jspecify.annotations.Nullable; @@ -43,7 +44,19 @@ import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; /** - * Represents a simple property or field reference. + * Represents a simple public property or field reference. + * + *

Null-safe Navigation

+ * + *

Null-safe navigation is supported via the {@code '?.'} operator. For example, + * {@code 'user?.name'} will evaluate to {@code null} if {@code user} is {@code null} + * and will otherwise evaluate to the name of the user. As of Spring Framework 7.0, + * null-safe navigation also applies when accessing a property or field on an + * {@link Optional} target. For example, if {@code user} is of type + * {@code Optional}, the expression {@code 'user?.name'} will evaluate to + * {@code null} if {@code user} is {@code null} or {@link Optional#isEmpty() empty} + * and will otherwise evaluate to the name of the user, effectively + * {@code user.get().getName()} or {@code user.get().name}. * * @author Andy Clement * @author Juergen Hoeller @@ -179,9 +192,24 @@ public class PropertyOrFieldReference extends SpelNodeImpl { private TypedValue readProperty(TypedValue contextObject, EvaluationContext evalContext, String name) throws EvaluationException { - Object target = contextObject.getValue(); - if (target == null && isNullSafe()) { - return TypedValue.NULL; + final Object originalTarget = contextObject.getValue(); + Object target = originalTarget; + Optional fallbackOptionalTarget = null; + boolean isEmptyOptional = false; + + if (isNullSafe()) { + if (target == null) { + return TypedValue.NULL; + } + if (target instanceof Optional optional) { + if (optional.isPresent()) { + target = optional.get(); + fallbackOptionalTarget = optional; + } + else { + isEmptyOptional = true; + } + } } PropertyAccessor accessorToUse = this.cachedReadAccessor; @@ -205,6 +233,7 @@ public class PropertyOrFieldReference extends SpelNodeImpl { // then ask them to read it. try { for (PropertyAccessor accessor : accessorsToTry) { + // First, attempt to find the property on the target object. if (accessor.canRead(evalContext, target, name)) { if (accessor instanceof ReflectivePropertyAccessor reflectivePropertyAccessor) { accessor = reflectivePropertyAccessor.createOptimalAccessor( @@ -213,18 +242,34 @@ public class PropertyOrFieldReference extends SpelNodeImpl { this.cachedReadAccessor = accessor; return accessor.read(evalContext, target, name); } + // Second, attempt to find the property on the original Optional instance. + else if (fallbackOptionalTarget != null && accessor.canRead(evalContext, fallbackOptionalTarget, name)) { + if (accessor instanceof ReflectivePropertyAccessor reflectivePropertyAccessor) { + accessor = reflectivePropertyAccessor.createOptimalAccessor( + evalContext, fallbackOptionalTarget, name); + } + this.cachedReadAccessor = accessor; + return accessor.read(evalContext, fallbackOptionalTarget, name); + } } } catch (Exception ex) { throw new SpelEvaluationException(ex, SpelMessage.EXCEPTION_DURING_PROPERTY_READ, name, ex.getMessage()); } - if (contextObject.getValue() == null) { + // If we got this far, that means we failed to find an accessor for both the + // target and the fallback target. So, we return NULL if the original target + // is a null-safe empty Optional. + if (isEmptyOptional) { + return TypedValue.NULL; + } + + if (originalTarget == null) { throw new SpelEvaluationException(SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE_ON_NULL, name); } else { throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE, name, - FormatHelper.formatClassNameForMessage(getObjectClass(contextObject.getValue()))); + FormatHelper.formatClassNameForMessage(getObjectClass(originalTarget))); } } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Selection.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Selection.java index 41106724c39..abe2e36486b 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Selection.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Selection.java @@ -22,6 +22,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.springframework.core.convert.TypeDescriptor; import org.springframework.expression.EvaluationException; @@ -43,6 +44,19 @@ import org.springframework.util.ObjectUtils; *

Basically a subset of the input data is returned based on the evaluation of * the expression supplied as selection criteria. * + *

Null-safe Selection

+ * + *

Null-safe selection is supported via the {@code '?.?'} operator. For example, + * {@code 'names?.?[#this.length > 5]'} will evaluate to {@code null} if {@code names} + * is {@code null} and will otherwise evaluate to a sequence containing the names + * whose length is greater than 5. As of Spring Framework 7.0, null-safe selection + * also applies when performing selection on an {@link Optional} target. For example, + * if {@code names} is of type {@code Optional>}, the expression + * {@code 'names?.?[#this.length > 5]'} will evaluate to {@code null} if {@code names} + * is {@code null} or {@link Optional#isEmpty() empty} and will otherwise evaluate + * to a sequence containing the names whose lengths are greater than 5, effectively + * {@code names.get().stream().filter(s -> s.length() > 5).toList()}. + * * @author Andy Clement * @author Mark Fisher * @author Sam Brannen @@ -96,6 +110,23 @@ public class Selection extends SpelNodeImpl { protected ValueRef getValueRef(ExpressionState state) throws EvaluationException { TypedValue contextObject = state.getActiveContextObject(); Object operand = contextObject.getValue(); + + if (isNullSafe()) { + if (operand == null) { + return ValueRef.NullValueRef.INSTANCE; + } + if (operand instanceof Optional optional) { + if (optional.isEmpty()) { + return ValueRef.NullValueRef.INSTANCE; + } + operand = optional.get(); + } + } + + if (operand == null) { + throw new SpelEvaluationException(getStartPosition(), SpelMessage.INVALID_TYPE_FOR_SELECTION, "null"); + } + SpelNodeImpl selectionCriteria = this.children[0]; if (operand instanceof Map mapdata) { @@ -198,13 +229,6 @@ public class Selection extends SpelNodeImpl { return new ValueRef.TypedValueHolderValueRef(new TypedValue(resultArray), this); } - if (operand == null) { - if (isNullSafe()) { - return ValueRef.NullValueRef.INSTANCE; - } - throw new SpelEvaluationException(getStartPosition(), SpelMessage.INVALID_TYPE_FOR_SELECTION, "null"); - } - throw new SpelEvaluationException(getStartPosition(), SpelMessage.INVALID_TYPE_FOR_SELECTION, operand.getClass().getName()); } diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java index 5fa0ccbdb37..ef5ec0a43fa 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java @@ -215,6 +215,144 @@ class OptionalNullSafetyTests { } + @Nested + class NullSafeTests { + + @Test + void accessPropertyOnEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findJediByName('')?.name"); + + // Invoke multiple times to ensure there are no caching issues. + assertThat(expr.getValue(context)).isNull(); + assertThat(expr.getValue(context)).isNull(); + } + + @Test + void accessPropertyOnNonEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findJediByName('Yoda')?.name"); + + // Invoke multiple times to ensure there are no caching issues. + assertThat(expr.getValue(context)).isEqualTo("Yoda"); + assertThat(expr.getValue(context)).isEqualTo("Yoda"); + } + + @Test + void invokeMethodOnEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findJediByName('')?.salutation('Master')"); + + // Invoke multiple times to ensure there are no caching issues. + assertThat(expr.getValue(context)).isNull(); + assertThat(expr.getValue(context)).isNull(); + } + + @Test + void invokeMethodOnNonEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findJediByName('Yoda')?.salutation('Master')"); + + // Invoke multiple times to ensure there are no caching issues. + assertThat(expr.getValue(context)).isEqualTo("Master Yoda"); + assertThat(expr.getValue(context)).isEqualTo("Master Yoda"); + } + + @Test + void accessIndexOnEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.[1]"); + + // Invoke multiple times to ensure there are no caching issues. + assertThat(expr.getValue(context)).isNull(); + assertThat(expr.getValue(context)).isNull(); + } + + @Test + void accessIndexOnNonEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.[1]"); + + // Invoke multiple times to ensure there are no caching issues. + assertThat(expr.getValue(context)).isEqualTo("lemon"); + assertThat(expr.getValue(context)).isEqualTo("lemon"); + } + + @Test + void projectionOnEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.![#this.length]"); + + assertThat(expr.getValue(context)).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + void projectionOnNonEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.![#this.length]"); + + assertThat(expr.getValue(context, List.class)).containsExactly(6, 5, 5, 9); + } + + @Test + void selectAllOnEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.?[#this.length > 5]"); + + assertThat(expr.getValue(context)).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + void selectAllOnNonEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.?[#this.length > 5]"); + + assertThat(expr.getValue(context, List.class)).containsExactly("banana", "pineapple"); + } + + @Test + void selectFirstOnEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.^[#this.length > 5]"); + + assertThat(expr.getValue(context)).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + void selectFirstOnNonEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.^[#this.length > 5]"); + + assertThat(expr.getValue(context, List.class)).containsExactly("banana"); + } + + @Test + void selectLastOnEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.$[#this.length > 5]"); + + assertThat(expr.getValue(context)).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + void selectLastOnNonEmptyOptionalViaNullSafeOperator() { + Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.$[#this.length > 5]"); + + assertThat(expr.getValue(context, List.class)).containsExactly("pineapple"); + } + + } + + @Nested + class ElvisTests { + + @Test + void elvisOperatorOnEmptyOptional() { + Expression expr = parser.parseExpression("#service.findJediByName('') ?: 'unknown'"); + + assertThat(expr.getValue(context)).isEqualTo("unknown"); + } + + @Test + void elvisOperatorOnNonEmptyOptional() { + Expression expr = parser.parseExpression("#service.findJediByName('Yoda') ?: 'unknown'"); + + assertThat(expr.getValue(context)).isEqualTo(new Jedi("Yoda")); + } + + } + record Jedi(String name) {