Test status quo for Optional support in SpEL expressions

This is a prerequisite for null-safe Optional support.

See gh-20433
This commit is contained in:
Sam Brannen 2025-03-12 14:02:17 +01:00
parent 71716e848d
commit c7b0550e43
1 changed files with 250 additions and 0 deletions

View File

@ -0,0 +1,250 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.expression.spel;
import java.util.List;
import java.util.Optional;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.springframework.expression.spel.SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE;
import static org.springframework.expression.spel.SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE_ON_NULL;
/**
* Tests which verify support for using {@link Optional} with the null-safe and
* Elvis operators in SpEL expressions.
*
* @author Sam Brannen
* @since 7.0
*/
class OptionalNullSafetyTests {
private final SpelExpressionParser parser = new SpelExpressionParser();
private final StandardEvaluationContext context = new StandardEvaluationContext();
@BeforeEach
void setUpContext() {
context.setVariable("service", new Service());
}
/**
* Tests for the status quo when using {@link Optional} in SpEL expressions,
* before explicit null-safe support was added in 7.0.
*/
@Nested
class LegacyOptionalTests {
@Test
void accessPropertyOnNullOptional() {
Expression expr = parser.parseExpression("#service.findJediByName(null).empty");
assertThatExceptionOfType(SpelEvaluationException.class)
.isThrownBy(() -> expr.getValue(context))
.satisfies(ex -> {
assertThat(ex.getMessageCode()).isEqualTo(PROPERTY_OR_FIELD_NOT_READABLE_ON_NULL);
assertThat(ex).hasMessageContaining("Property or field 'empty' cannot be found on null");
});
}
@Test
void accessPropertyOnNullOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName(null)?.empty");
assertThat(expr.getValue(context)).isNull();
}
@Test
void invokeMethodOnNullOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName(null)?.salutation('Master')");
assertThat(expr.getValue(context)).isNull();
}
@Test
void accessIndexOnNullOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.[1]");
assertThat(expr.getValue(context)).isNull();
}
@Test
void projectionOnNullOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.![#this.length]");
assertThat(expr.getValue(context)).isNull();
}
@Test
void selectAllOnNullOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.?[#this.length > 5]");
assertThat(expr.getValue(context)).isNull();
}
@Test
void selectFirstOnNullOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.^[#this.length > 5]");
assertThat(expr.getValue(context)).isNull();
}
@Test
void selectLastOnNullOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor(null)?.$[#this.length > 5]");
assertThat(expr.getValue(context)).isNull();
}
@Test
void elvisOperatorOnNullOptional() {
Expression expr = parser.parseExpression("#service.findJediByName(null) ?: 'unknown'");
assertThat(expr.getValue(context)).isEqualTo("unknown");
}
@Test
void accessNonexistentPropertyOnEmptyOptional() {
assertPropertyNotReadable("#service.findJediByName('').name");
}
@Test
void accessNonexistentPropertyOnNonEmptyOptional() {
assertPropertyNotReadable("#service.findJediByName('Yoda').name");
}
@Test
void accessOptionalPropertyOnEmptyOptional() {
Expression expr = parser.parseExpression("#service.findJediByName('').present");
assertThat(expr.getValue(context, Boolean.class)).isFalse();
}
@Test
void accessOptionalPropertyOnEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName('')?.present");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context, Boolean.class)).isFalse();
assertThat(expr.getValue(context, Boolean.class)).isFalse();
}
@Test
void accessOptionalPropertyOnNonEmptyOptional() {
Expression expr = parser.parseExpression("#service.findJediByName('Yoda').present");
assertThat(expr.getValue(context, Boolean.class)).isTrue();
}
@Test
void accessOptionalPropertyOnNonEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName('Yoda')?.present");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context, Boolean.class)).isTrue();
assertThat(expr.getValue(context, Boolean.class)).isTrue();
}
@Test
void invokeOptionalMethodOnEmptyOptional() {
Expression expr = parser.parseExpression("#service.findJediByName('').orElse('Luke')");
assertThat(expr.getValue(context)).isEqualTo("Luke");
}
@Test
void invokeOptionalMethodOnEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName('')?.orElse('Luke')");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context)).isEqualTo("Luke");
assertThat(expr.getValue(context)).isEqualTo("Luke");
}
@Test
void invokeOptionalMethodOnNonEmptyOptional() {
Expression expr = parser.parseExpression("#service.findJediByName('Yoda').orElse('Luke')");
assertThat(expr.getValue(context)).isEqualTo(new Jedi("Yoda"));
}
@Test
void invokeOptionalMethodOnNonEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName('Yoda')?.orElse('Luke')");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context)).isEqualTo(new Jedi("Yoda"));
assertThat(expr.getValue(context)).isEqualTo(new Jedi("Yoda"));
}
private void assertPropertyNotReadable(String expression) {
Expression expr = parser.parseExpression(expression);
assertThatExceptionOfType(SpelEvaluationException.class)
.isThrownBy(() -> expr.getValue(context))
.satisfies(ex -> {
assertThat(ex.getMessageCode()).isEqualTo(PROPERTY_OR_FIELD_NOT_READABLE);
assertThat(ex).hasMessageContaining("Property or field 'name' cannot be found on object of type 'java.util.Optional'");
});
}
}
record Jedi(String name) {
public String salutation(String salutation) {
return salutation + " " + this.name;
}
}
static class Service {
public Optional<Jedi> findJediByName(@Nullable String name) {
if (name == null) {
return null;
}
if (name.isEmpty()) {
return Optional.empty();
}
return Optional.of(new Jedi(name));
}
public Optional<List<String>> findFruitsByColor(@Nullable String color) {
if (color == null) {
return null;
}
if (color.isEmpty()) {
return Optional.empty();
}
return Optional.of(List.of("banana", "lemon", "mango", "pineapple"));
}
}
}