Skip to content

Commit 68fce29

Browse files
committed
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
1 parent 1780e30 commit 68fce29

File tree

9 files changed

+442
-62
lines changed

9 files changed

+442
-62
lines changed

framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-elvis.adoc

+11
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ need to use `name != null && !name.isEmpty()` as the predicate to be compatible
4646
semantics of the SpEL Elvis operator.
4747
====
4848

49+
[TIP]
50+
====
51+
As of Spring Framework 7.0, the SpEL Elvis operator supports `java.util.Optional` with
52+
transparent unwrapping semantics.
53+
54+
For example, given the expression `A ?: B`, if `A` is `null` or an _empty_ `Optional`,
55+
the expression evaluates to `B`. However, if `A` is a non-empty `Optional` the expression
56+
evaluates to the object contained in the `Optional`, thereby effectively unwrapping the
57+
`Optional` which correlates to `A.get()`.
58+
====
59+
4960
The following listing shows a more complex example:
5061

5162
[tabs]

framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc

+44
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,50 @@ Kotlin::
350350
<2> Use null-safe projection operator on null `members` list
351351
======
352352

353+
[[expressions-operator-safe-navigation-optional]]
354+
== Null-safe Operations on `Optional`
355+
356+
As of Spring Framework 7.0, null-safe operations are supported on instances of
357+
`java.util.Optional` with transparent unwrapping semantics.
358+
359+
Specifically, when a null-safe operator is applied to an _empty_ `Optional`, it will be
360+
treated as if the `Optional` were `null`, and the subsequent operation will evaluate to
361+
`null`. However, if a null-safe operator is applied to a non-empty `Optional`, the
362+
subsequent operation will be applied to the object contained in the `Optional`, thereby
363+
effectively unwrapping the `Optional`.
364+
365+
For example, if `user` is of type `Optional<User>`, the expression `user?.name` will
366+
evaluate to `null` if `user` is either `null` or an _empty_ `Optional` and will otherwise
367+
evaluate to the `name` of the `user`, effectively `user.get().getName()` or
368+
`user.get().name` for property or field access, respectively.
369+
370+
[NOTE]
371+
====
372+
Invocations of methods defined in the `Optional` API are still supported on an _empty_
373+
`Optional`. For example, if `name` is of type `Optional<String>`, the expression
374+
`name?.orElse('Unknown')` will evaluate to `"Unknown"` if `name` is an empty `Optional`
375+
and will otherwise evaluate to the `String` contained in the `Optional` if `name` is a
376+
non-empty `Optional`, effectively `name.get()`.
377+
====
378+
379+
// NOTE: &#8288; is the Unicode Character 'WORD JOINER', which prevents undesired line wraps.
380+
381+
Similarly, if `names` is of type `Optional<List<String>>`, the expression
382+
`names?.?&#8288;[#this.length > 5]` will evaluate to `null` if `names` is `null` or an _empty_
383+
`Optional` and will otherwise evaluate to a sequence containing the names whose lengths
384+
are greater than 5, effectively
385+
`names.get().stream().filter(s -> s.length() > 5).toList()`.
386+
387+
The same semantics apply to all of the null-safe operators mentioned previously in this
388+
chapter.
389+
390+
For further details and examples, consult the javadoc for the following operators.
391+
392+
* {spring-framework-api}/expression/spel/ast/PropertyOrFieldReference.html[`PropertyOrFieldReference`]
393+
* {spring-framework-api}/expression/spel/ast/MethodReference.html[`MethodReference`]
394+
* {spring-framework-api}/expression/spel/ast/Indexer.html[`Indexer`]
395+
* {spring-framework-api}/expression/spel/ast/Selection.html[`Selection`]
396+
* {spring-framework-api}/expression/spel/ast/Projection.html[`Projection`]
353397

354398
[[expressions-operator-safe-navigation-compound-expressions]]
355399
== Null-safe Operations in Compound Expressions

spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java

+31-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,6 +16,8 @@
1616

1717
package org.springframework.expression.spel.ast;
1818

19+
import java.util.Optional;
20+
1921
import org.springframework.asm.Label;
2022
import org.springframework.asm.MethodVisitor;
2123
import org.springframework.expression.EvaluationException;
@@ -26,9 +28,13 @@
2628
import org.springframework.util.ObjectUtils;
2729

2830
/**
29-
* Represents the Elvis operator <code>?:</code>. For an expression <code>a?:b</code> if <code>a</code> is neither null
30-
* nor an empty String, the value of the expression is <code>a</code>.
31-
* If <code>a</code> is null or the empty String, then the value of the expression is <code>b</code>.
31+
* Represents the Elvis operator {@code ?:}.
32+
*
33+
* <p>For the expression "{@code A ?: B}", if {@code A} is neither {@code null},
34+
* an empty {@link Optional}, nor an empty {@link String}, the value of the
35+
* expression is {@code A}, or {@code A.get()} for an {@code Optional}. If
36+
* {@code A} is {@code null}, an empty {@code Optional}, or an
37+
* empty {@code String}, the value of the expression is {@code B}.
3238
*
3339
* @author Andy Clement
3440
* @author Juergen Hoeller
@@ -43,18 +49,32 @@ public Elvis(int startPos, int endPos, SpelNodeImpl... args) {
4349

4450

4551
/**
46-
* Evaluate the condition and if neither null nor an empty String, return it.
47-
* If it is null or an empty String, return the other value.
52+
* If the left-hand operand is neither neither {@code null}, an empty
53+
* {@link Optional}, nor an empty {@link String}, return its value, or the
54+
* value contained in the {@code Optional}. If the left-hand operand is
55+
* {@code null}, an empty {@code Optional}, or an empty {@code String},
56+
* return the other value.
4857
* @param state the expression state
49-
* @throws EvaluationException if the condition does not evaluate correctly
50-
* to a boolean or there is a problem executing the chosen alternative
58+
* @throws EvaluationException if the null/empty check does not evaluate correctly
59+
* or there is a problem evaluating the alternative
5160
*/
5261
@Override
5362
public TypedValue getValueInternal(ExpressionState state) throws EvaluationException {
54-
TypedValue value = this.children[0].getValueInternal(state);
63+
TypedValue leftHandTypedValue = this.children[0].getValueInternal(state);
64+
Object leftHandValue = leftHandTypedValue.getValue();
65+
66+
if (leftHandValue instanceof Optional<?> optional) {
67+
// Compilation is currently not supported for Optional with the Elvis operator.
68+
this.exitTypeDescriptor = null;
69+
if (optional.isPresent()) {
70+
return new TypedValue(optional.get());
71+
}
72+
return this.children[1].getValueInternal(state);
73+
}
74+
5575
// If this check is changed, the generateCode method will need changing too
56-
if (value.getValue() != null && !"".equals(value.getValue())) {
57-
return value;
76+
if (leftHandValue != null && !"".equals(leftHandValue)) {
77+
return leftHandTypedValue;
5878
}
5979
else {
6080
TypedValue result = this.children[1].getValueInternal(state);

spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java

+19-4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.Collection;
2121
import java.util.List;
2222
import java.util.Map;
23+
import java.util.Optional;
2324
import java.util.function.Supplier;
2425

2526
import org.jspecify.annotations.Nullable;
@@ -67,7 +68,12 @@
6768
* <p>As of Spring Framework 6.2, null-safe indexing is supported via the {@code '?.'}
6869
* operator. For example, {@code 'colors?.[0]'} will evaluate to {@code null} if
6970
* {@code colors} is {@code null} and will otherwise evaluate to the 0<sup>th</sup>
70-
* color.
71+
* color. As of Spring Framework 7.0, null-safe indexing also applies when
72+
* indexing into a structure contained in an {@link Optional}. For example, if
73+
* {@code colors} is of type {@code Optional<Colors>}, the expression
74+
* {@code 'colors?.[0]'} will evaluate to {@code null} if {@code colors} is
75+
* {@code null} or {@link Optional#isEmpty() empty} and will otherwise evaluate
76+
* to the 0<sup>th</sup> color, effectively {@code colors.get()[0]}.
7177
*
7278
* @author Andy Clement
7379
* @author Phillip Webb
@@ -165,11 +171,20 @@ private ValueRef getValueRef(ExpressionState state, AccessMode accessMode) throw
165171
TypedValue context = state.getActiveContextObject();
166172
Object target = context.getValue();
167173

168-
if (target == null) {
169-
if (isNullSafe()) {
174+
if (isNullSafe()) {
175+
if (target == null) {
170176
return ValueRef.NullValueRef.INSTANCE;
171177
}
172-
// Raise a proper exception in case of a null target
178+
if (target instanceof Optional<?> optional) {
179+
if (optional.isEmpty()) {
180+
return ValueRef.NullValueRef.INSTANCE;
181+
}
182+
target = optional.get();
183+
}
184+
}
185+
186+
// Raise a proper exception in case of a null target
187+
if (target == null) {
173188
throw new SpelEvaluationException(getStartPosition(), SpelMessage.CANNOT_INDEX_INTO_NULL_VALUE);
174189
}
175190

spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java

+87-27
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.ArrayList;
2424
import java.util.Collections;
2525
import java.util.List;
26+
import java.util.Optional;
2627
import java.util.StringJoiner;
2728

2829
import org.jspecify.annotations.Nullable;
@@ -50,6 +51,19 @@
5051
* Expression language AST node that represents a method reference (i.e., a
5152
* method invocation other than a simple property reference).
5253
*
54+
* <h3>Null-safe Invocation</h3>
55+
*
56+
* <p>Null-safe invocation is supported via the {@code '?.'} operator. For example,
57+
* {@code 'counter?.incrementBy(1)'} will evaluate to {@code null} if {@code counter}
58+
* is {@code null} and will otherwise evaluate to the value returned from the
59+
* invocation of {@code counter.incrementBy(1)}. As of Spring Framework 7.0,
60+
* null-safe invocation also applies when invoking a method on an {@link Optional}
61+
* target. For example, if {@code counter} is of type {@code Optional<Counter>},
62+
* the expression {@code 'counter?.incrementBy(1)'} will evaluate to {@code null}
63+
* if {@code counter} is {@code null} or {@link Optional#isEmpty() empty} and will
64+
* otherwise evaluate the value returned from the invocation of
65+
* {@code counter.get().incrementBy(1)}.
66+
*
5367
* @author Andy Clement
5468
* @author Juergen Hoeller
5569
* @author Sam Brannen
@@ -93,7 +107,9 @@ public final String getName() {
93107
protected ValueRef getValueRef(ExpressionState state) throws EvaluationException {
94108
@Nullable Object[] arguments = getArguments(state);
95109
if (state.getActiveContextObject().getValue() == null) {
96-
throwIfNotNullSafe(getArgumentTypes(arguments));
110+
if (!isNullSafe()) {
111+
throw nullTargetException(getArgumentTypes(arguments));
112+
}
97113
return ValueRef.NullValueRef.INSTANCE;
98114
}
99115
return new MethodValueRef(state, arguments);
@@ -115,9 +131,26 @@ private TypedValue getValueInternal(EvaluationContext evaluationContext, @Nullab
115131
@Nullable TypeDescriptor targetType, @Nullable Object[] arguments) {
116132

117133
List<TypeDescriptor> argumentTypes = getArgumentTypes(arguments);
134+
Optional<?> fallbackOptionalTarget = null;
135+
boolean isEmptyOptional = false;
136+
137+
if (isNullSafe()) {
138+
if (target == null) {
139+
return TypedValue.NULL;
140+
}
141+
if (target instanceof Optional<?> optional) {
142+
if (optional.isPresent()) {
143+
target = optional.get();
144+
fallbackOptionalTarget = optional;
145+
}
146+
else {
147+
isEmptyOptional = true;
148+
}
149+
}
150+
}
151+
118152
if (target == null) {
119-
throwIfNotNullSafe(argumentTypes);
120-
return TypedValue.NULL;
153+
throw nullTargetException(argumentTypes);
121154
}
122155

123156
MethodExecutor executorToUse = getCachedExecutor(evaluationContext, target, targetType, argumentTypes);
@@ -142,31 +175,64 @@ private TypedValue getValueInternal(EvaluationContext evaluationContext, @Nullab
142175
// At this point we know it wasn't a user problem so worth a retry if a
143176
// better candidate can be found.
144177
this.cachedExecutor = null;
178+
executorToUse = null;
179+
}
180+
}
181+
182+
// Either there was no cached executor, or it no longer exists.
183+
184+
// First, attempt to find the method on the target object.
185+
Object targetToUse = target;
186+
MethodExecutorSearchResult searchResult = findMethodExecutor(argumentTypes, target, evaluationContext);
187+
if (searchResult.methodExecutor != null) {
188+
executorToUse = searchResult.methodExecutor;
189+
}
190+
// Second, attempt to find the method on the original Optional instance.
191+
else if (fallbackOptionalTarget != null) {
192+
searchResult = findMethodExecutor(argumentTypes, fallbackOptionalTarget, evaluationContext);
193+
if (searchResult.methodExecutor != null) {
194+
executorToUse = searchResult.methodExecutor;
195+
targetToUse = fallbackOptionalTarget;
196+
}
197+
}
198+
// If we got this far, that means we failed to find an executor for both the
199+
// target and the fallback target. So, we return NULL if the original target
200+
// is a null-safe empty Optional.
201+
else if (isEmptyOptional) {
202+
return TypedValue.NULL;
203+
}
204+
205+
if (executorToUse == null) {
206+
String method = FormatHelper.formatMethodForMessage(this.name, argumentTypes);
207+
String className = FormatHelper.formatClassNameForMessage(
208+
target instanceof Class<?> clazz ? clazz : target.getClass());
209+
if (searchResult.accessException != null) {
210+
throw new SpelEvaluationException(
211+
getStartPosition(), searchResult.accessException, SpelMessage.PROBLEM_LOCATING_METHOD, method, className);
212+
}
213+
else {
214+
throw new SpelEvaluationException(getStartPosition(), SpelMessage.METHOD_NOT_FOUND, method, className);
145215
}
146216
}
147217

148-
// either there was no accessor or it no longer existed
149-
executorToUse = findMethodExecutor(argumentTypes, target, evaluationContext);
150218
this.cachedExecutor = new CachedMethodExecutor(
151-
executorToUse, (target instanceof Class<?> clazz ? clazz : null), targetType, argumentTypes);
219+
executorToUse, (targetToUse instanceof Class<?> clazz ? clazz : null), targetType, argumentTypes);
152220
try {
153-
return executorToUse.execute(evaluationContext, target, arguments);
221+
return executorToUse.execute(evaluationContext, targetToUse, arguments);
154222
}
155223
catch (AccessException ex) {
156224
// Same unwrapping exception handling as in above catch block
157-
throwSimpleExceptionIfPossible(target, ex);
225+
throwSimpleExceptionIfPossible(targetToUse, ex);
158226
throw new SpelEvaluationException(getStartPosition(), ex,
159227
SpelMessage.EXCEPTION_DURING_METHOD_INVOCATION, this.name,
160-
target.getClass().getName(), ex.getMessage());
228+
targetToUse.getClass().getName(), ex.getMessage());
161229
}
162230
}
163231

164-
private void throwIfNotNullSafe(List<TypeDescriptor> argumentTypes) {
165-
if (!isNullSafe()) {
166-
throw new SpelEvaluationException(getStartPosition(),
167-
SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED,
168-
FormatHelper.formatMethodForMessage(this.name, argumentTypes));
169-
}
232+
private SpelEvaluationException nullTargetException(List<TypeDescriptor> argumentTypes) {
233+
return new SpelEvaluationException(getStartPosition(),
234+
SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED,
235+
FormatHelper.formatMethodForMessage(this.name, argumentTypes));
170236
}
171237

172238
private @Nullable Object[] getArguments(ExpressionState state) {
@@ -209,7 +275,7 @@ private List<TypeDescriptor> getArgumentTypes(@Nullable Object... arguments) {
209275
return null;
210276
}
211277

212-
private MethodExecutor findMethodExecutor(List<TypeDescriptor> argumentTypes, Object target,
278+
private MethodExecutorSearchResult findMethodExecutor(List<TypeDescriptor> argumentTypes, Object target,
213279
EvaluationContext evaluationContext) throws SpelEvaluationException {
214280

215281
AccessException accessException = null;
@@ -218,7 +284,7 @@ private MethodExecutor findMethodExecutor(List<TypeDescriptor> argumentTypes, Ob
218284
MethodExecutor methodExecutor = methodResolver.resolve(
219285
evaluationContext, target, this.name, argumentTypes);
220286
if (methodExecutor != null) {
221-
return methodExecutor;
287+
return new MethodExecutorSearchResult(methodExecutor, null);
222288
}
223289
}
224290
catch (AccessException ex) {
@@ -227,16 +293,7 @@ private MethodExecutor findMethodExecutor(List<TypeDescriptor> argumentTypes, Ob
227293
}
228294
}
229295

230-
String method = FormatHelper.formatMethodForMessage(this.name, argumentTypes);
231-
String className = FormatHelper.formatClassNameForMessage(
232-
target instanceof Class<?> clazz ? clazz : target.getClass());
233-
if (accessException != null) {
234-
throw new SpelEvaluationException(
235-
getStartPosition(), accessException, SpelMessage.PROBLEM_LOCATING_METHOD, method, className);
236-
}
237-
else {
238-
throw new SpelEvaluationException(getStartPosition(), SpelMessage.METHOD_NOT_FOUND, method, className);
239-
}
296+
return new MethodExecutorSearchResult(null, accessException);
240297
}
241298

242299
/**
@@ -411,6 +468,9 @@ public boolean isWritable() {
411468
}
412469

413470

471+
private record MethodExecutorSearchResult(@Nullable MethodExecutor methodExecutor, @Nullable AccessException accessException) {
472+
}
473+
414474
private record CachedMethodExecutor(MethodExecutor methodExecutor, @Nullable Class<?> staticClass,
415475
@Nullable TypeDescriptor targetType, List<TypeDescriptor> argumentTypes) {
416476

0 commit comments

Comments
 (0)