Skip to content

Commit c7b0550

Browse files
committed
Test status quo for Optional support in SpEL expressions
This is a prerequisite for null-safe Optional support. See gh-20433
1 parent 71716e8 commit c7b0550

File tree

1 file changed

+250
-0
lines changed

1 file changed

+250
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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.jspecify.annotations.Nullable;
23+
import org.junit.jupiter.api.BeforeEach;
24+
import org.junit.jupiter.api.Nested;
25+
import org.junit.jupiter.api.Test;
26+
27+
import org.springframework.expression.Expression;
28+
import org.springframework.expression.spel.standard.SpelExpressionParser;
29+
import org.springframework.expression.spel.support.StandardEvaluationContext;
30+
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
33+
import static org.springframework.expression.spel.SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE;
34+
import static org.springframework.expression.spel.SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE_ON_NULL;
35+
36+
/**
37+
* Tests which verify support for using {@link Optional} with the null-safe and
38+
* Elvis operators in SpEL expressions.
39+
*
40+
* @author Sam Brannen
41+
* @since 7.0
42+
*/
43+
class OptionalNullSafetyTests {
44+
45+
private final SpelExpressionParser parser = new SpelExpressionParser();
46+
47+
private final StandardEvaluationContext context = new StandardEvaluationContext();
48+
49+
50+
@BeforeEach
51+
void setUpContext() {
52+
context.setVariable("service", new Service());
53+
}
54+
55+
56+
/**
57+
* Tests for the status quo when using {@link Optional} in SpEL expressions,
58+
* before explicit null-safe support was added in 7.0.
59+
*/
60+
@Nested
61+
class LegacyOptionalTests {
62+
63+
@Test
64+
void accessPropertyOnNullOptional() {
65+
Expression expr = parser.parseExpression("#service.findJediByName(null).empty");
66+
67+
assertThatExceptionOfType(SpelEvaluationException.class)
68+
.isThrownBy(() -> expr.getValue(context))
69+
.satisfies(ex -> {
70+
assertThat(ex.getMessageCode()).isEqualTo(PROPERTY_OR_FIELD_NOT_READABLE_ON_NULL);
71+
assertThat(ex).hasMessageContaining("Property or field 'empty' cannot be found on null");
72+
});
73+
}
74+
75+
@Test
76+
void accessPropertyOnNullOptionalViaNullSafeOperator() {
77+
Expression expr = parser.parseExpression("#service.findJediByName(null)?.empty");
78+
79+
assertThat(expr.getValue(context)).isNull();
80+
}
81+
82+
@Test
83+
void invokeMethodOnNullOptionalViaNullSafeOperator() {
84+
Expression expr = parser.parseExpression("#service.findJediByName(null)?.salutation('Master')");
85+
86+
assertThat(expr.getValue(context)).isNull();
87+
}
88+
89+
@Test
90+
void accessIndexOnNullOptionalViaNullSafeOperator() {
91+
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.[1]");
92+
93+
assertThat(expr.getValue(context)).isNull();
94+
}
95+
96+
@Test
97+
void projectionOnNullOptionalViaNullSafeOperator() {
98+
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.![#this.length]");
99+
100+
assertThat(expr.getValue(context)).isNull();
101+
}
102+
103+
@Test
104+
void selectAllOnNullOptionalViaNullSafeOperator() {
105+
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.?[#this.length > 5]");
106+
107+
assertThat(expr.getValue(context)).isNull();
108+
}
109+
110+
@Test
111+
void selectFirstOnNullOptionalViaNullSafeOperator() {
112+
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.^[#this.length > 5]");
113+
114+
assertThat(expr.getValue(context)).isNull();
115+
}
116+
117+
@Test
118+
void selectLastOnNullOptionalViaNullSafeOperator() {
119+
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.$[#this.length > 5]");
120+
121+
assertThat(expr.getValue(context)).isNull();
122+
}
123+
124+
@Test
125+
void elvisOperatorOnNullOptional() {
126+
Expression expr = parser.parseExpression("#service.findJediByName(null) ?: 'unknown'");
127+
128+
assertThat(expr.getValue(context)).isEqualTo("unknown");
129+
}
130+
131+
@Test
132+
void accessNonexistentPropertyOnEmptyOptional() {
133+
assertPropertyNotReadable("#service.findJediByName('').name");
134+
}
135+
136+
@Test
137+
void accessNonexistentPropertyOnNonEmptyOptional() {
138+
assertPropertyNotReadable("#service.findJediByName('Yoda').name");
139+
}
140+
141+
@Test
142+
void accessOptionalPropertyOnEmptyOptional() {
143+
Expression expr = parser.parseExpression("#service.findJediByName('').present");
144+
145+
assertThat(expr.getValue(context, Boolean.class)).isFalse();
146+
}
147+
148+
@Test
149+
void accessOptionalPropertyOnEmptyOptionalViaNullSafeOperator() {
150+
Expression expr = parser.parseExpression("#service.findJediByName('')?.present");
151+
152+
// Invoke multiple times to ensure there are no caching issues.
153+
assertThat(expr.getValue(context, Boolean.class)).isFalse();
154+
assertThat(expr.getValue(context, Boolean.class)).isFalse();
155+
}
156+
157+
@Test
158+
void accessOptionalPropertyOnNonEmptyOptional() {
159+
Expression expr = parser.parseExpression("#service.findJediByName('Yoda').present");
160+
161+
assertThat(expr.getValue(context, Boolean.class)).isTrue();
162+
}
163+
164+
@Test
165+
void accessOptionalPropertyOnNonEmptyOptionalViaNullSafeOperator() {
166+
Expression expr = parser.parseExpression("#service.findJediByName('Yoda')?.present");
167+
168+
// Invoke multiple times to ensure there are no caching issues.
169+
assertThat(expr.getValue(context, Boolean.class)).isTrue();
170+
assertThat(expr.getValue(context, Boolean.class)).isTrue();
171+
}
172+
173+
@Test
174+
void invokeOptionalMethodOnEmptyOptional() {
175+
Expression expr = parser.parseExpression("#service.findJediByName('').orElse('Luke')");
176+
177+
assertThat(expr.getValue(context)).isEqualTo("Luke");
178+
}
179+
180+
@Test
181+
void invokeOptionalMethodOnEmptyOptionalViaNullSafeOperator() {
182+
Expression expr = parser.parseExpression("#service.findJediByName('')?.orElse('Luke')");
183+
184+
// Invoke multiple times to ensure there are no caching issues.
185+
assertThat(expr.getValue(context)).isEqualTo("Luke");
186+
assertThat(expr.getValue(context)).isEqualTo("Luke");
187+
}
188+
189+
@Test
190+
void invokeOptionalMethodOnNonEmptyOptional() {
191+
Expression expr = parser.parseExpression("#service.findJediByName('Yoda').orElse('Luke')");
192+
193+
assertThat(expr.getValue(context)).isEqualTo(new Jedi("Yoda"));
194+
}
195+
196+
@Test
197+
void invokeOptionalMethodOnNonEmptyOptionalViaNullSafeOperator() {
198+
Expression expr = parser.parseExpression("#service.findJediByName('Yoda')?.orElse('Luke')");
199+
200+
// Invoke multiple times to ensure there are no caching issues.
201+
assertThat(expr.getValue(context)).isEqualTo(new Jedi("Yoda"));
202+
assertThat(expr.getValue(context)).isEqualTo(new Jedi("Yoda"));
203+
}
204+
205+
private void assertPropertyNotReadable(String expression) {
206+
Expression expr = parser.parseExpression(expression);
207+
208+
assertThatExceptionOfType(SpelEvaluationException.class)
209+
.isThrownBy(() -> expr.getValue(context))
210+
.satisfies(ex -> {
211+
assertThat(ex.getMessageCode()).isEqualTo(PROPERTY_OR_FIELD_NOT_READABLE);
212+
assertThat(ex).hasMessageContaining("Property or field 'name' cannot be found on object of type 'java.util.Optional'");
213+
});
214+
}
215+
216+
}
217+
218+
219+
record Jedi(String name) {
220+
221+
public String salutation(String salutation) {
222+
return salutation + " " + this.name;
223+
}
224+
}
225+
226+
static class Service {
227+
228+
public Optional<Jedi> findJediByName(@Nullable String name) {
229+
if (name == null) {
230+
return null;
231+
}
232+
if (name.isEmpty()) {
233+
return Optional.empty();
234+
}
235+
return Optional.of(new Jedi(name));
236+
}
237+
238+
public Optional<List<String>> findFruitsByColor(@Nullable String color) {
239+
if (color == null) {
240+
return null;
241+
}
242+
if (color.isEmpty()) {
243+
return Optional.empty();
244+
}
245+
return Optional.of(List.of("banana", "lemon", "mango", "pineapple"));
246+
}
247+
248+
}
249+
250+
}

0 commit comments

Comments
 (0)