Compile SpEL expressions that use Optional with null-safe & Elvis operators

In Spring Framework 7.0, we introduced support for using `Optional`
with the null-safe and Elvis operators in SpEL expressions; however,
such expressions were previously not compilable.

To address that, this commit introduces a new
insertOptionalUnwrapIfNecessary() method in CodeFlow which effectively
inserts byte code instructions for `myOptional.orElse(null)`, and the
Elvis, Indexer, MethodReference, and PropertyOrFieldReference
implementations have been modified to track the need to unwrap an
`Optional` in compiled mode and delegate to
insertOptionalUnwrapIfNecessary() accordingly.

See gh-20433
See gh-36331
Closes gh-36330
This commit is contained in:
Sam Brannen
2026-04-15 14:09:57 +02:00
parent 829add3aa9
commit 9c8535f5e4
8 changed files with 339 additions and 9 deletions
@@ -354,14 +354,18 @@ class OptionalNullSafetyTests {
}
record Jedi(String name) {
public record Jedi(String name) {
public static Jedi from(String name) {
return new Jedi(name);
}
public String salutation(String salutation) {
return salutation + " " + this.name;
}
}
static class Service {
public static class Service {
public Optional<Jedi> findJediByName(@Nullable String name) {
if (name == null) {
@@ -29,6 +29,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.stream.Collectors;
@@ -37,6 +38,7 @@ import java.util.stream.Stream;
import example.Color;
import example.FruitMap;
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.junit.jupiter.params.ParameterizedTest;
@@ -49,6 +51,8 @@ import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.IndexAccessor;
import org.springframework.expression.TypedValue;
import org.springframework.expression.spel.OptionalNullSafetyTests.Jedi;
import org.springframework.expression.spel.OptionalNullSafetyTests.Service;
import org.springframework.expression.spel.ast.CompoundExpression;
import org.springframework.expression.spel.ast.InlineList;
import org.springframework.expression.spel.ast.OpLT;
@@ -62,6 +66,7 @@ import org.springframework.expression.spel.support.ReflectiveIndexAccessor;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.expression.spel.testdata.PersonInOtherPackage;
import org.springframework.expression.spel.testresources.Person;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
import static java.util.stream.Collectors.joining;
@@ -6294,6 +6299,258 @@ public class SpelCompilationCoverageTests extends AbstractExpressionTests {
}
}
/**
* @since 7.1
*/
@Nested
class OptionalNullSafeTests { // gh-36330
private final StandardEvaluationContext context = new StandardEvaluationContext();
@Test
void accessOptionalPropertyOnEmptyOptionalViaNullSafeOperator() {
String exitDescriptor = CodeFlow.toDescriptor(Boolean.class);
context.setVariable("service", new Service());
expression = parser.parseExpression("#service.findJediByName('')?.present");
assertThat(expression.getValue(context, Boolean.class)).isFalse();
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context, Boolean.class)).isFalse();
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertIsCompiled(expression);
}
@Test
void accessOptionalPropertyOnNonEmptyOptionalViaNullSafeOperator() {
String exitDescriptor = CodeFlow.toDescriptor(Boolean.class);
context.setVariable("service", new Service());
expression = parser.parseExpression("#service.findJediByName('Yoda')?.present");
assertThat(expression.getValue(context, Boolean.class)).isTrue();
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context, Boolean.class)).isTrue();
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertIsCompiled(expression);
}
@Test
void invokeOptionalMethodOnEmptyOptionalViaNullSafeOperator() {
// Object instead of String, since Optional#orElse returns T.
String exitDescriptor = CodeFlow.toDescriptor(Object.class);
context.setVariable("service", new Service());
expression = parser.parseExpression("#service.findJediByName('')?.orElse('Luke')");
assertThat(expression.getValue(context)).isEqualTo("Luke");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isEqualTo("Luke");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertIsCompiled(expression);
}
@Test
void invokeOptionalMethodOnNonEmptyOptionalViaNullSafeOperator() {
// Object instead of Jedi, since Optional#orElse returns T.
String exitDescriptor = CodeFlow.toDescriptor(Object.class);
context.setVariable("service", new Service());
expression = parser.parseExpression("#service.findJediByName('Yoda')?.orElse('Luke')");
assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda"));
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda"));
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertIsCompiled(expression);
}
@Test
void accessPropertyOnOptionalViaNullSafeOperator() {
String exitDescriptor = CodeFlow.toDescriptor(String.class);
expression = parser.parseExpression("#jedi?.name");
// 1) Start with empty Optional
context.setVariable("jedi", Optional.empty());
assertThat(expression.getValue(context)).isNull();
// Cannot compile before the "name" property type is known.
assertThat(getAst().getExitDescriptor()).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
// 2) Switch to non-empty Optional
context.setVariable("jedi", Optional.of(new Jedi("Yoda")));
assertThat(expression.getValue(context)).isEqualTo("Yoda");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isEqualTo("Yoda");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
// 3) Switch back to empty Optional
context.setVariable("jedi", Optional.empty());
assertThat(expression.getValue(context)).isNull();
// Exit descriptor hasn't changed.
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertIsCompiled(expression);
}
@Test
void invokeMethodOnOptionalViaNullSafeOperator() {
String exitDescriptor = CodeFlow.toDescriptor(String.class);
expression = parser.parseExpression("#jedi?.salutation('Master')");
// 1) Start with empty Optional
context.setVariable("jedi", Optional.empty());
assertThat(expression.getValue(context)).isNull();
// Cannot compile before the "salutation()" return type is known.
assertThat(getAst().getExitDescriptor()).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
// 2) Switch to non-empty Optional
context.setVariable("jedi", Optional.of(new Jedi("Yoda")));
assertThat(expression.getValue(context)).isEqualTo("Master Yoda");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isEqualTo("Master Yoda");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
// 3) Switch back to empty Optional
context.setVariable("jedi", Optional.empty());
assertThat(expression.getValue(context)).isNull();
// Exit descriptor hasn't changed.
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
}
@Test
void accessIndexOnOptionalViaNullSafeOperator() {
// Object instead of String, since Indexer.CollectionIndexingValueRef
// sets the exit descriptor to Object for any Collection.
String exitDescriptor = CodeFlow.toDescriptor(Object.class);
expression = parser.parseExpression("#fruits?.[1]");
// 1) Start with empty Optional
context.setVariable("fruits", Optional.empty());
assertThat(expression.getValue(context)).isNull();
// Cannot compile before the indexed value type is known.
assertThat(getAst().getExitDescriptor()).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
// 2) Switch to non-empty Optional
context.setVariable("fruits", Optional.of(List.of("banana", "lemon", "mango")));
assertThat(expression.getValue(context)).isEqualTo("lemon");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isEqualTo("lemon");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
// 3) Switch back to empty Optional
context.setVariable("fruits", Optional.empty());
assertThat(expression.getValue(context)).isNull();
// Exit descriptor hasn't changed.
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
}
}
/**
* @since 7.1
*/
@Nested
class OptionalElvisTests { // gh-36330
private final StandardEvaluationContext context = new StandardEvaluationContext();
@BeforeEach
void configureContext() {
context.setVariable("service", new Service());
context.registerFunction("jedi", ClassUtils.getMethod(Jedi.class, "from", String.class));
}
@Test
void elvisOperatorOnEmptyOptional() {
// Object instead of Optional or String, since the Elvis operator uses
// "the easiest to compute common supertype" if the types for the
// LHS and RHS do not match.
String exitDescriptor = CodeFlow.toDescriptor(Object.class);
expression = parser.parseExpression("#service.findJediByName('') ?: 'unknown'");
assertThat(expression.getValue(context)).isEqualTo("unknown");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isEqualTo("unknown");
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
}
@Test
void elvisOperatorOnNonEmptyOptionalWithNonMatchingElseType() {
// Object instead of Jedi or String, since the Elvis operator uses
// "the easiest to compute common supertype" if the types for the
// LHS and RHS do not match.
String exitDescriptor = CodeFlow.toDescriptor(Object.class);
expression = parser.parseExpression("#service.findJediByName('Yoda') ?: 'unknown'");
assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda"));
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda"));
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
}
@Test
void elvisOperatorOnNonEmptyOptionalWithMatchingElseType() {
// Jedi, since the known types for the LHS and RHS eventually match.
String exitDescriptor = CodeFlow.toDescriptor(Jedi.class);
expression = parser.parseExpression("#service.findJediByName(#name) ?: #jedi('unknown')");
// 1) Start with non-empty Optional
context.setVariable("name", "Yoda");
assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda"));
// Cannot compile before the types for the LHS and RHS are known.
assertThat(getAst().getExitDescriptor()).isNull();
assertCannotCompile(expression);
assertThat(getAst().getExitDescriptor()).isNull();
// 2) Switch to empty Optional
context.setVariable("name", "");
assertThat(expression.getValue(context)).isEqualTo(new Jedi("unknown"));
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
// 3) Switch back to non-empty Optional
context.setVariable("name", "Luke");
assertThat(expression.getValue(context)).isEqualTo(new Jedi("Luke"));
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
assertCanCompile(expression);
assertIsCompiled(expression);
assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor);
}
}
@Nested
class MethodVisibilityTests {