Support Optional with null-safe and Elvis operators in SpEL expressions
This commit introduces null-safe support for java.util.Optional in the following SpEL operators: - PropertyOrFieldReference - MethodReference - Indexer - Projection - Selection - Elvis 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<User>`, 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()` for property access. Note, however, that invocations of methods defined in the `Optional` API are still supported on an empty `Optional`. For example, if `name` is of type `Optional<String>`, 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()`. Closes gh-20433
This commit is contained in:
parent
1780e30a43
commit
68fce29ae9
|
@ -46,6 +46,17 @@ need to use `name != null && !name.isEmpty()` as the predicate to be compatible
|
||||||
semantics of the SpEL Elvis operator.
|
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:
|
The following listing shows a more complex example:
|
||||||
|
|
||||||
[tabs]
|
[tabs]
|
||||||
|
|
|
@ -350,6 +350,50 @@ Kotlin::
|
||||||
<2> Use null-safe projection operator on null `members` list
|
<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<User>`, 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<String>`, 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<List<String>>`, 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]]
|
[[expressions-operator-safe-navigation-compound-expressions]]
|
||||||
== Null-safe Operations in Compound Expressions
|
== Null-safe Operations in Compound Expressions
|
||||||
|
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
package org.springframework.expression.spel.ast;
|
package org.springframework.expression.spel.ast;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.springframework.asm.Label;
|
import org.springframework.asm.Label;
|
||||||
import org.springframework.asm.MethodVisitor;
|
import org.springframework.asm.MethodVisitor;
|
||||||
import org.springframework.expression.EvaluationException;
|
import org.springframework.expression.EvaluationException;
|
||||||
|
@ -26,9 +28,13 @@ import org.springframework.util.Assert;
|
||||||
import org.springframework.util.ObjectUtils;
|
import org.springframework.util.ObjectUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the Elvis operator <code>?:</code>. For an expression <code>a?:b</code> if <code>a</code> is neither null
|
* Represents the Elvis operator {@code ?:}.
|
||||||
* nor an empty String, the value of the expression is <code>a</code>.
|
*
|
||||||
* If <code>a</code> is null or the empty String, then the value of the expression is <code>b</code>.
|
* <p>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 Andy Clement
|
||||||
* @author Juergen Hoeller
|
* @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 the left-hand operand is neither neither {@code null}, an empty
|
||||||
* If it is null or an empty String, return the other value.
|
* {@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
|
* @param state the expression state
|
||||||
* @throws EvaluationException if the condition does not evaluate correctly
|
* @throws EvaluationException if the null/empty check does not evaluate correctly
|
||||||
* to a boolean or there is a problem executing the chosen alternative
|
* or there is a problem evaluating the alternative
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public TypedValue getValueInternal(ExpressionState state) throws EvaluationException {
|
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 this check is changed, the generateCode method will need changing too
|
||||||
if (value.getValue() != null && !"".equals(value.getValue())) {
|
if (leftHandValue != null && !"".equals(leftHandValue)) {
|
||||||
return value;
|
return leftHandTypedValue;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
TypedValue result = this.children[1].getValueInternal(state);
|
TypedValue result = this.children[1].getValueInternal(state);
|
||||||
|
|
|
@ -20,6 +20,7 @@ import java.lang.reflect.Constructor;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import org.jspecify.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
@ -67,7 +68,12 @@ import org.springframework.util.ReflectionUtils;
|
||||||
* <p>As of Spring Framework 6.2, null-safe indexing is supported via the {@code '?.'}
|
* <p>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
|
* operator. For example, {@code 'colors?.[0]'} will evaluate to {@code null} if
|
||||||
* {@code colors} is {@code null} and will otherwise evaluate to the 0<sup>th</sup>
|
* {@code colors} is {@code null} and will otherwise evaluate to the 0<sup>th</sup>
|
||||||
* 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<Colors>}, 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 0<sup>th</sup> color, effectively {@code colors.get()[0]}.
|
||||||
*
|
*
|
||||||
* @author Andy Clement
|
* @author Andy Clement
|
||||||
* @author Phillip Webb
|
* @author Phillip Webb
|
||||||
|
@ -165,11 +171,20 @@ public class Indexer extends SpelNodeImpl {
|
||||||
TypedValue context = state.getActiveContextObject();
|
TypedValue context = state.getActiveContextObject();
|
||||||
Object target = context.getValue();
|
Object target = context.getValue();
|
||||||
|
|
||||||
if (target == null) {
|
if (isNullSafe()) {
|
||||||
if (isNullSafe()) {
|
if (target == null) {
|
||||||
return ValueRef.NullValueRef.INSTANCE;
|
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);
|
throw new SpelEvaluationException(getStartPosition(), SpelMessage.CANNOT_INDEX_INTO_NULL_VALUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ import java.lang.reflect.Proxy;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.StringJoiner;
|
import java.util.StringJoiner;
|
||||||
|
|
||||||
import org.jspecify.annotations.Nullable;
|
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
|
* Expression language AST node that represents a method reference (i.e., a
|
||||||
* method invocation other than a simple property reference).
|
* method invocation other than a simple property reference).
|
||||||
*
|
*
|
||||||
|
* <h3>Null-safe Invocation</h3>
|
||||||
|
*
|
||||||
|
* <p>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<Counter>},
|
||||||
|
* 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 Andy Clement
|
||||||
* @author Juergen Hoeller
|
* @author Juergen Hoeller
|
||||||
* @author Sam Brannen
|
* @author Sam Brannen
|
||||||
|
@ -93,7 +107,9 @@ public class MethodReference extends SpelNodeImpl {
|
||||||
protected ValueRef getValueRef(ExpressionState state) throws EvaluationException {
|
protected ValueRef getValueRef(ExpressionState state) throws EvaluationException {
|
||||||
@Nullable Object[] arguments = getArguments(state);
|
@Nullable Object[] arguments = getArguments(state);
|
||||||
if (state.getActiveContextObject().getValue() == null) {
|
if (state.getActiveContextObject().getValue() == null) {
|
||||||
throwIfNotNullSafe(getArgumentTypes(arguments));
|
if (!isNullSafe()) {
|
||||||
|
throw nullTargetException(getArgumentTypes(arguments));
|
||||||
|
}
|
||||||
return ValueRef.NullValueRef.INSTANCE;
|
return ValueRef.NullValueRef.INSTANCE;
|
||||||
}
|
}
|
||||||
return new MethodValueRef(state, arguments);
|
return new MethodValueRef(state, arguments);
|
||||||
|
@ -115,9 +131,26 @@ public class MethodReference extends SpelNodeImpl {
|
||||||
@Nullable TypeDescriptor targetType, @Nullable Object[] arguments) {
|
@Nullable TypeDescriptor targetType, @Nullable Object[] arguments) {
|
||||||
|
|
||||||
List<TypeDescriptor> argumentTypes = getArgumentTypes(arguments);
|
List<TypeDescriptor> 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) {
|
if (target == null) {
|
||||||
throwIfNotNullSafe(argumentTypes);
|
throw nullTargetException(argumentTypes);
|
||||||
return TypedValue.NULL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MethodExecutor executorToUse = getCachedExecutor(evaluationContext, target, targetType, 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
|
// At this point we know it wasn't a user problem so worth a retry if a
|
||||||
// better candidate can be found.
|
// better candidate can be found.
|
||||||
this.cachedExecutor = null;
|
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(
|
this.cachedExecutor = new CachedMethodExecutor(
|
||||||
executorToUse, (target instanceof Class<?> clazz ? clazz : null), targetType, argumentTypes);
|
executorToUse, (targetToUse instanceof Class<?> clazz ? clazz : null), targetType, argumentTypes);
|
||||||
try {
|
try {
|
||||||
return executorToUse.execute(evaluationContext, target, arguments);
|
return executorToUse.execute(evaluationContext, targetToUse, arguments);
|
||||||
}
|
}
|
||||||
catch (AccessException ex) {
|
catch (AccessException ex) {
|
||||||
// Same unwrapping exception handling as in above catch block
|
// Same unwrapping exception handling as in above catch block
|
||||||
throwSimpleExceptionIfPossible(target, ex);
|
throwSimpleExceptionIfPossible(targetToUse, ex);
|
||||||
throw new SpelEvaluationException(getStartPosition(), ex,
|
throw new SpelEvaluationException(getStartPosition(), ex,
|
||||||
SpelMessage.EXCEPTION_DURING_METHOD_INVOCATION, this.name,
|
SpelMessage.EXCEPTION_DURING_METHOD_INVOCATION, this.name,
|
||||||
target.getClass().getName(), ex.getMessage());
|
targetToUse.getClass().getName(), ex.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void throwIfNotNullSafe(List<TypeDescriptor> argumentTypes) {
|
private SpelEvaluationException nullTargetException(List<TypeDescriptor> argumentTypes) {
|
||||||
if (!isNullSafe()) {
|
return new SpelEvaluationException(getStartPosition(),
|
||||||
throw new SpelEvaluationException(getStartPosition(),
|
SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED,
|
||||||
SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED,
|
FormatHelper.formatMethodForMessage(this.name, argumentTypes));
|
||||||
FormatHelper.formatMethodForMessage(this.name, argumentTypes));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private @Nullable Object[] getArguments(ExpressionState state) {
|
private @Nullable Object[] getArguments(ExpressionState state) {
|
||||||
|
@ -209,7 +275,7 @@ public class MethodReference extends SpelNodeImpl {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MethodExecutor findMethodExecutor(List<TypeDescriptor> argumentTypes, Object target,
|
private MethodExecutorSearchResult findMethodExecutor(List<TypeDescriptor> argumentTypes, Object target,
|
||||||
EvaluationContext evaluationContext) throws SpelEvaluationException {
|
EvaluationContext evaluationContext) throws SpelEvaluationException {
|
||||||
|
|
||||||
AccessException accessException = null;
|
AccessException accessException = null;
|
||||||
|
@ -218,7 +284,7 @@ public class MethodReference extends SpelNodeImpl {
|
||||||
MethodExecutor methodExecutor = methodResolver.resolve(
|
MethodExecutor methodExecutor = methodResolver.resolve(
|
||||||
evaluationContext, target, this.name, argumentTypes);
|
evaluationContext, target, this.name, argumentTypes);
|
||||||
if (methodExecutor != null) {
|
if (methodExecutor != null) {
|
||||||
return methodExecutor;
|
return new MethodExecutorSearchResult(methodExecutor, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (AccessException ex) {
|
catch (AccessException ex) {
|
||||||
|
@ -227,16 +293,7 @@ public class MethodReference extends SpelNodeImpl {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String method = FormatHelper.formatMethodForMessage(this.name, argumentTypes);
|
return new MethodExecutorSearchResult(null, accessException);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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,
|
private record CachedMethodExecutor(MethodExecutor methodExecutor, @Nullable Class<?> staticClass,
|
||||||
@Nullable TypeDescriptor targetType, List<TypeDescriptor> argumentTypes) {
|
@Nullable TypeDescriptor targetType, List<TypeDescriptor> argumentTypes) {
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.jspecify.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
|
@ -39,6 +40,19 @@ import org.springframework.util.ObjectUtils;
|
||||||
* <p>For example: <code>{1,2,3,4,5,6,7,8,9,10}.![#isEven(#this)]</code> evaluates
|
* <p>For example: <code>{1,2,3,4,5,6,7,8,9,10}.![#isEven(#this)]</code> evaluates
|
||||||
* to {@code [n, y, n, y, n, y, n, y, n, y]}.
|
* to {@code [n, y, n, y, n, y, n, y, n, y]}.
|
||||||
*
|
*
|
||||||
|
* <h3>Null-safe Projection</h3>
|
||||||
|
*
|
||||||
|
* <p>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<List<String>>}, 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 Andy Clement
|
||||||
* @author Mark Fisher
|
* @author Mark Fisher
|
||||||
* @author Juergen Hoeller
|
* @author Juergen Hoeller
|
||||||
|
@ -75,6 +89,22 @@ public class Projection extends SpelNodeImpl {
|
||||||
TypedValue contextObject = state.getActiveContextObject();
|
TypedValue contextObject = state.getActiveContextObject();
|
||||||
Object operand = contextObject.getValue();
|
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
|
// 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'
|
// the specified operation. Map.Entry has two properties 'key' and 'value'
|
||||||
// that can be referenced in the operation -- for example,
|
// 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);
|
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,
|
throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROJECTION_NOT_SUPPORTED_ON_TYPE,
|
||||||
operand.getClass().getName());
|
operand.getClass().getName());
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import org.jspecify.annotations.Nullable;
|
import org.jspecify.annotations.Nullable;
|
||||||
|
@ -43,7 +44,19 @@ import org.springframework.util.Assert;
|
||||||
import org.springframework.util.ReflectionUtils;
|
import org.springframework.util.ReflectionUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a simple property or field reference.
|
* Represents a simple public property or field reference.
|
||||||
|
*
|
||||||
|
* <h3>Null-safe Navigation</h3>
|
||||||
|
*
|
||||||
|
* <p>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<User>}, 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 Andy Clement
|
||||||
* @author Juergen Hoeller
|
* @author Juergen Hoeller
|
||||||
|
@ -179,9 +192,24 @@ public class PropertyOrFieldReference extends SpelNodeImpl {
|
||||||
private TypedValue readProperty(TypedValue contextObject, EvaluationContext evalContext, String name)
|
private TypedValue readProperty(TypedValue contextObject, EvaluationContext evalContext, String name)
|
||||||
throws EvaluationException {
|
throws EvaluationException {
|
||||||
|
|
||||||
Object target = contextObject.getValue();
|
final Object originalTarget = contextObject.getValue();
|
||||||
if (target == null && isNullSafe()) {
|
Object target = originalTarget;
|
||||||
return TypedValue.NULL;
|
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;
|
PropertyAccessor accessorToUse = this.cachedReadAccessor;
|
||||||
|
@ -205,6 +233,7 @@ public class PropertyOrFieldReference extends SpelNodeImpl {
|
||||||
// then ask them to read it.
|
// then ask them to read it.
|
||||||
try {
|
try {
|
||||||
for (PropertyAccessor accessor : accessorsToTry) {
|
for (PropertyAccessor accessor : accessorsToTry) {
|
||||||
|
// First, attempt to find the property on the target object.
|
||||||
if (accessor.canRead(evalContext, target, name)) {
|
if (accessor.canRead(evalContext, target, name)) {
|
||||||
if (accessor instanceof ReflectivePropertyAccessor reflectivePropertyAccessor) {
|
if (accessor instanceof ReflectivePropertyAccessor reflectivePropertyAccessor) {
|
||||||
accessor = reflectivePropertyAccessor.createOptimalAccessor(
|
accessor = reflectivePropertyAccessor.createOptimalAccessor(
|
||||||
|
@ -213,18 +242,34 @@ public class PropertyOrFieldReference extends SpelNodeImpl {
|
||||||
this.cachedReadAccessor = accessor;
|
this.cachedReadAccessor = accessor;
|
||||||
return accessor.read(evalContext, target, name);
|
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) {
|
catch (Exception ex) {
|
||||||
throw new SpelEvaluationException(ex, SpelMessage.EXCEPTION_DURING_PROPERTY_READ, name, ex.getMessage());
|
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);
|
throw new SpelEvaluationException(SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE_ON_NULL, name);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE, name,
|
throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE, name,
|
||||||
FormatHelper.formatClassNameForMessage(getObjectClass(contextObject.getValue())));
|
FormatHelper.formatClassNameForMessage(getObjectClass(originalTarget)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.springframework.core.convert.TypeDescriptor;
|
import org.springframework.core.convert.TypeDescriptor;
|
||||||
import org.springframework.expression.EvaluationException;
|
import org.springframework.expression.EvaluationException;
|
||||||
|
@ -43,6 +44,19 @@ import org.springframework.util.ObjectUtils;
|
||||||
* <p>Basically a subset of the input data is returned based on the evaluation of
|
* <p>Basically a subset of the input data is returned based on the evaluation of
|
||||||
* the expression supplied as selection criteria.
|
* the expression supplied as selection criteria.
|
||||||
*
|
*
|
||||||
|
* <h3>Null-safe Selection</h3>
|
||||||
|
*
|
||||||
|
* <p>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<List<String>>}, 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 Andy Clement
|
||||||
* @author Mark Fisher
|
* @author Mark Fisher
|
||||||
* @author Sam Brannen
|
* @author Sam Brannen
|
||||||
|
@ -96,6 +110,23 @@ public class Selection extends SpelNodeImpl {
|
||||||
protected ValueRef getValueRef(ExpressionState state) throws EvaluationException {
|
protected ValueRef getValueRef(ExpressionState state) throws EvaluationException {
|
||||||
TypedValue contextObject = state.getActiveContextObject();
|
TypedValue contextObject = state.getActiveContextObject();
|
||||||
Object operand = contextObject.getValue();
|
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];
|
SpelNodeImpl selectionCriteria = this.children[0];
|
||||||
|
|
||||||
if (operand instanceof Map<?, ?> mapdata) {
|
if (operand instanceof Map<?, ?> mapdata) {
|
||||||
|
@ -198,13 +229,6 @@ public class Selection extends SpelNodeImpl {
|
||||||
return new ValueRef.TypedValueHolderValueRef(new TypedValue(resultArray), this);
|
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,
|
throw new SpelEvaluationException(getStartPosition(), SpelMessage.INVALID_TYPE_FOR_SELECTION,
|
||||||
operand.getClass().getName());
|
operand.getClass().getName());
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
record Jedi(String name) {
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue