Skip to content

Commit 6fd0eae

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 - PropertyOrFieldReference TODO: support the following operators: - MethodReference - Projection - Selection TODO: update reference manual Closes spring-projectsgh-20433
1 parent 656ffcc commit 6fd0eae

File tree

4 files changed

+221
-19
lines changed

4 files changed

+221
-19
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/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;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.expression.spel;
18+
19+
import java.util.List;
20+
import java.util.Optional;
21+
22+
import org.junit.jupiter.api.BeforeEach;
23+
import org.junit.jupiter.api.Nested;
24+
import org.junit.jupiter.api.Test;
25+
26+
import org.springframework.expression.Expression;
27+
import org.springframework.expression.spel.standard.SpelExpressionParser;
28+
import org.springframework.expression.spel.support.StandardEvaluationContext;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
32+
33+
/**
34+
* Tests which verify support for using {@link Optional} with the null-safe and
35+
* Elvis operators in SpEL expressions.
36+
*
37+
* @author Sam Brannen
38+
* @since 7.0
39+
*/
40+
class OptionalNullSafetyTests {
41+
42+
private final SpelExpressionParser parser = new SpelExpressionParser();
43+
44+
private final StandardEvaluationContext context = new StandardEvaluationContext();
45+
46+
47+
@BeforeEach
48+
void setUpContext() {
49+
context.setVariable("service", new Service());
50+
}
51+
52+
53+
@Test
54+
void invokeMethodDirectlyOnEmptyOptional() {
55+
assertInvocationFails("#service.findJediByName('').name");
56+
}
57+
58+
@Test
59+
void invokeMethodDirectlyOnNonEmptyOptional() {
60+
assertInvocationFails("#service.findJediByName('Yoda').name");
61+
}
62+
63+
private void assertInvocationFails(String expression) {
64+
Expression expr = parser.parseExpression(expression);
65+
66+
assertThatExceptionOfType(SpelEvaluationException.class)
67+
.isThrownBy(() -> expr.getValue(context))
68+
.satisfies(ex -> {
69+
assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE);
70+
assertThat(ex).hasMessageContaining("Property or field 'name' cannot be found on object of type 'java.util.Optional'");
71+
});
72+
}
73+
74+
75+
@Nested
76+
class NullSafeTests {
77+
78+
@Test
79+
void accessPropertyOnEmptyOptionalViaNullSafeOperator() {
80+
Expression expr = parser.parseExpression("#service.findJediByName('')?.name");
81+
82+
assertThat(expr.getValue(context)).isNull();
83+
}
84+
85+
@Test
86+
void accessPropertyOnNonEmptyOptionalViaNullSafeOperator() {
87+
Expression expr = parser.parseExpression("#service.findJediByName('Yoda')?.name");
88+
89+
assertThat(expr.getValue(context)).isEqualTo("Yoda");
90+
}
91+
92+
@Test
93+
@SuppressWarnings("unchecked")
94+
void accessIndexOnEmptyOptionalViaNullSafeOperator() {
95+
Expression expr = parser.parseExpression("#service.findFruitsByColor('blue')?.[1]");
96+
97+
assertThat(expr.getValue(context)).isNull();
98+
}
99+
100+
@Test
101+
@SuppressWarnings("unchecked")
102+
void accessIndexOnNonEmptyOptionalViaNullSafeOperator() {
103+
Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.[1]");
104+
105+
assertThat(expr.getValue(context)).isEqualTo("lemon");
106+
}
107+
108+
}
109+
110+
@Nested
111+
class ElvisTests {
112+
113+
@Test
114+
void elvisOperatorOnEmptyOptional() {
115+
Expression expr = parser.parseExpression("#service.findJediByName('') ?: 'unknown'");
116+
117+
assertThat(expr.getValue(context)).isEqualTo("unknown");
118+
}
119+
120+
@Test
121+
void elvisOperatorOnNonEmptyOptional() {
122+
Expression expr = parser.parseExpression("#service.findJediByName('Yoda') ?: 'unknown'");
123+
124+
assertThat(expr.getValue(context)).isEqualTo(new Jedi("Yoda"));
125+
}
126+
127+
}
128+
129+
130+
record Jedi(String name) {
131+
}
132+
133+
static class Service {
134+
135+
public Optional<Jedi> findJediByName(String name) {
136+
return (!name.isEmpty() ? Optional.of(new Jedi(name)) : Optional.empty());
137+
}
138+
139+
public Optional<List<String>> findFruitsByColor(String color) {
140+
return (color.equals("yellow") ? Optional.of(List.of("banana", "lemon")) : Optional.empty());
141+
}
142+
143+
}
144+
145+
}

0 commit comments

Comments
 (0)