Skip to content

Commit 1d4e895

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: - Elvis - Indexer - MethodReference - PropertyOrFieldReference - Projection TODOs: - Support the Selection operator. - Update the SpEL chapter of the reference manual. Closes spring-projectsgh-20433
1 parent 86d8163 commit 1d4e895

File tree

6 files changed

+339
-52
lines changed

6 files changed

+339
-52
lines changed

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

+49-21
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;
@@ -47,7 +48,21 @@
4748
import org.springframework.util.ObjectUtils;
4849

4950
/**
50-
* Expression language AST node that represents a method reference.
51+
* Expression language AST node that represents a method reference (i.e., a
52+
* method invocation other than a simple property reference).
53+
*
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)}.
5166
*
5267
* @author Andy Clement
5368
* @author Juergen Hoeller
@@ -92,7 +107,9 @@ public final String getName() {
92107
protected ValueRef getValueRef(ExpressionState state) throws EvaluationException {
93108
@Nullable Object[] arguments = getArguments(state);
94109
if (state.getActiveContextObject().getValue() == null) {
95-
throwIfNotNullSafe(getArgumentTypes(arguments));
110+
if (!isNullSafe()) {
111+
throw nullTargetObjectException(getArgumentTypes(arguments));
112+
}
96113
return ValueRef.NullValueRef.INSTANCE;
97114
}
98115
return new MethodValueRef(state, arguments);
@@ -109,19 +126,32 @@ public TypedValue getValueInternal(ExpressionState state) throws EvaluationExcep
109126
return result;
110127
}
111128

112-
private TypedValue getValueInternal(EvaluationContext evaluationContext,
113-
@Nullable Object value, @Nullable TypeDescriptor targetType, @Nullable Object[] arguments) {
129+
private TypedValue getValueInternal(EvaluationContext evaluationContext, final @Nullable Object value,
130+
@Nullable TypeDescriptor targetType, @Nullable Object[] arguments) {
114131

132+
Object target = value;
115133
List<TypeDescriptor> argumentTypes = getArgumentTypes(arguments);
116-
if (value == null) {
117-
throwIfNotNullSafe(argumentTypes);
118-
return TypedValue.NULL;
134+
135+
if (isNullSafe()) {
136+
if (target == null) {
137+
return TypedValue.NULL;
138+
}
139+
if (target instanceof Optional<?> optional) {
140+
if (optional.isEmpty()) {
141+
return TypedValue.NULL;
142+
}
143+
target = optional.get();
144+
}
119145
}
120146

121-
MethodExecutor executorToUse = getCachedExecutor(evaluationContext, value, targetType, argumentTypes);
147+
if (target == null) {
148+
throw nullTargetObjectException(argumentTypes);
149+
}
150+
151+
MethodExecutor executorToUse = getCachedExecutor(evaluationContext, target, targetType, argumentTypes);
122152
if (executorToUse != null) {
123153
try {
124-
return executorToUse.execute(evaluationContext, value, arguments);
154+
return executorToUse.execute(evaluationContext, target, arguments);
125155
}
126156
catch (AccessException ex) {
127157
// Two reasons this can occur:
@@ -135,7 +165,7 @@ private TypedValue getValueInternal(EvaluationContext evaluationContext,
135165
// To determine the situation, the AccessException will contain a cause.
136166
// If the cause is an InvocationTargetException, a user exception was
137167
// thrown inside the method. Otherwise the method could not be invoked.
138-
throwSimpleExceptionIfPossible(value, ex);
168+
throwSimpleExceptionIfPossible(target, ex);
139169

140170
// At this point we know it wasn't a user problem so worth a retry if a
141171
// better candidate can be found.
@@ -144,27 +174,25 @@ private TypedValue getValueInternal(EvaluationContext evaluationContext,
144174
}
145175

146176
// either there was no accessor or it no longer existed
147-
executorToUse = findAccessorForMethod(argumentTypes, value, evaluationContext);
177+
executorToUse = findAccessorForMethod(argumentTypes, target, evaluationContext);
148178
this.cachedExecutor = new CachedMethodExecutor(
149-
executorToUse, (value instanceof Class<?> clazz ? clazz : null), targetType, argumentTypes);
179+
executorToUse, (target instanceof Class<?> clazz ? clazz : null), targetType, argumentTypes);
150180
try {
151-
return executorToUse.execute(evaluationContext, value, arguments);
181+
return executorToUse.execute(evaluationContext, target, arguments);
152182
}
153183
catch (AccessException ex) {
154184
// Same unwrapping exception handling as above in above catch block
155-
throwSimpleExceptionIfPossible(value, ex);
185+
throwSimpleExceptionIfPossible(target, ex);
156186
throw new SpelEvaluationException(getStartPosition(), ex,
157187
SpelMessage.EXCEPTION_DURING_METHOD_INVOCATION, this.name,
158-
value.getClass().getName(), ex.getMessage());
188+
(value != null ? value.getClass().getName() : "null"), ex.getMessage());
159189
}
160190
}
161191

162-
private void throwIfNotNullSafe(List<TypeDescriptor> argumentTypes) {
163-
if (!isNullSafe()) {
164-
throw new SpelEvaluationException(getStartPosition(),
165-
SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED,
166-
FormatHelper.formatMethodForMessage(this.name, argumentTypes));
167-
}
192+
private SpelEvaluationException nullTargetObjectException(List<TypeDescriptor> argumentTypes) {
193+
return new SpelEvaluationException(getStartPosition(),
194+
SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED,
195+
FormatHelper.formatMethodForMessage(this.name, argumentTypes));
168196
}
169197

170198
private @Nullable Object[] getArguments(ExpressionState state) {

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

+36-12
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.Arrays;
2222
import java.util.List;
2323
import java.util.Map;
24+
import java.util.Optional;
2425

2526
import org.jspecify.annotations.Nullable;
2627

@@ -33,12 +34,25 @@
3334
import org.springframework.util.ObjectUtils;
3435

3536
/**
36-
* Represents projection, where a given operation is performed on all elements in some
37-
* input sequence, returning a new sequence of the same size.
37+
* Represents projection, where a given operation is performed on all elements in
38+
* some input sequence, returning a new sequence of the same size.
3839
*
3940
* <p>For example: <code>{1,2,3,4,5,6,7,8,9,10}.![#isEven(#this)]</code> evaluates
4041
* to {@code [n, y, n, y, n, y, n, y, n, y]}.
4142
*
43+
* <h3>Null-safe Projection</h3>
44+
*
45+
* <p>Null-safe projection is supported via the {@code '?.!'} operator. For example,
46+
* {@code 'names?.![#this.length]'} will evaluate to {@code null} if {@code names}
47+
* is {@code null} and will otherwise evaluate to a sequence containing the lengths
48+
* of the names. As of Spring Framework 7.0, null-safe projection also applies when
49+
* performing projection on an {@link Optional} target. For example, if {@code names}
50+
* is of type {@code Optional<List<String>>}, the expression
51+
* {@code 'names?.![#this.length]'} will evaluate to {@code null} if {@code names}
52+
* is {@code null} or {@link Optional#isEmpty() empty} and will otherwise evaluate
53+
* to a sequence containing the lengths of the names, effectively
54+
* {@code names.get().stream().map(String::length).toList()}.
55+
*
4256
* @author Andy Clement
4357
* @author Mark Fisher
4458
* @author Juergen Hoeller
@@ -72,8 +86,24 @@ public TypedValue getValueInternal(ExpressionState state) throws EvaluationExcep
7286

7387
@Override
7488
protected ValueRef getValueRef(ExpressionState state) throws EvaluationException {
75-
TypedValue op = state.getActiveContextObject();
76-
Object operand = op.getValue();
89+
TypedValue contextObject = state.getActiveContextObject();
90+
Object operand = contextObject.getValue();
91+
92+
if (isNullSafe()) {
93+
if (operand == null) {
94+
return ValueRef.NullValueRef.INSTANCE;
95+
}
96+
if (operand instanceof Optional<?> optional) {
97+
if (optional.isEmpty()) {
98+
return ValueRef.NullValueRef.INSTANCE;
99+
}
100+
operand = optional.get();
101+
}
102+
}
103+
104+
if (operand == null) {
105+
throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROJECTION_NOT_SUPPORTED_ON_TYPE, "null");
106+
}
77107

78108
// When the input is a map, we push a Map.Entry on the stack before calling
79109
// the specified operation. Map.Entry has two properties 'key' and 'value'
@@ -130,15 +160,9 @@ protected ValueRef getValueRef(ExpressionState state) throws EvaluationException
130160
return new ValueRef.TypedValueHolderValueRef(new TypedValue(result),this);
131161
}
132162

133-
if (operand == null) {
134-
if (isNullSafe()) {
135-
return ValueRef.NullValueRef.INSTANCE;
136-
}
137-
throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROJECTION_NOT_SUPPORTED_ON_TYPE, "null");
138-
}
139-
163+
Object value = contextObject.getValue();
140164
throw new SpelEvaluationException(getStartPosition(), SpelMessage.PROJECTION_NOT_SUPPORTED_ON_TYPE,
141-
operand.getClass().getName());
165+
(value != null ? value.getClass().getName() : "null"));
142166
}
143167

144168
@Override

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

+26-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 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.
@@ -21,6 +21,7 @@
2121
import java.util.HashMap;
2222
import java.util.List;
2323
import java.util.Map;
24+
import java.util.Optional;
2425
import java.util.function.Supplier;
2526

2627
import org.jspecify.annotations.Nullable;
@@ -43,7 +44,19 @@
4344
import org.springframework.util.ReflectionUtils;
4445

4546
/**
46-
* Represents a simple property or field reference.
47+
* Represents a simple public property or field reference.
48+
*
49+
* <h3>Null-safe Navigation</h3>
50+
*
51+
* <p>Null-safe navigation is supported via the {@code '?.'} operator. For example,
52+
* {@code 'user?.name'} will evaluate to {@code null} if {@code user} is {@code null}
53+
* and will otherwise evaluate to the name of the user. As of Spring Framework 7.0,
54+
* null-safe navigation also applies when accessing a property or field on an
55+
* {@link Optional} target. For example, if {@code user} is of type
56+
* {@code Optional<User>}, the expression {@code 'user?.name'} will evaluate to
57+
* {@code null} if {@code user} is {@code null} or {@link Optional#isEmpty() empty}
58+
* and will otherwise evaluate to the name of the user, effectively
59+
* {@code user.get().getName()} or {@code user.get().name}.
4760
*
4861
* @author Andy Clement
4962
* @author Juergen Hoeller
@@ -180,8 +193,17 @@ private TypedValue readProperty(TypedValue contextObject, EvaluationContext eval
180193
throws EvaluationException {
181194

182195
Object targetObject = contextObject.getValue();
183-
if (targetObject == null && isNullSafe()) {
184-
return TypedValue.NULL;
196+
197+
if (isNullSafe()) {
198+
if (targetObject == null) {
199+
return TypedValue.NULL;
200+
}
201+
if (targetObject instanceof Optional<?> optional) {
202+
if (optional.isEmpty()) {
203+
return TypedValue.NULL;
204+
}
205+
targetObject = optional.get();
206+
}
185207
}
186208

187209
PropertyAccessor accessorToUse = this.cachedReadAccessor;

0 commit comments

Comments
 (0)