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
@@ -531,7 +531,6 @@ following kinds of expressions cannot be compiled.
* Expressions relying on the conversion service
* Expressions using custom resolvers
* Expressions using overloaded operators
* Expressions using `Optional` with the null-safe or Elvis operator
* Expressions using array construction syntax
* Expressions using selection or projection
* Expressions using bean references
@@ -1017,6 +1017,24 @@ public class CodeFlow implements Opcodes {
};
}
/**
* If the provided descriptor represents a {@link java.util.Optional}, insert
* the necessary bytecode to unwrap it.
* <p>An empty {@code Optional} will be replaced with {@code null}.
* @param mv the method visitor into which instructions should be inserted
* @param descriptor the descriptor of a type that may or may not need unwrapping
* @since 7.1
* @see java.util.Optional#orElse(Object)
*/
public static void insertOptionalUnwrapIfNecessary(MethodVisitor mv, @Nullable String descriptor) {
if ("Ljava/util/Optional".equals(descriptor)) {
// Push 'null' onto the stack as the argument for orElse
mv.visitInsn(ACONST_NULL);
// Invoke java.util.Optional.orElse(null)
mv.visitMethodInsn(INVOKEVIRTUAL, "java/util/Optional", "orElse",
"(Ljava/lang/Object;)Ljava/lang/Object;", false);
}
}
/**
* Interface used to generate fields.
@@ -19,6 +19,8 @@ package org.springframework.expression.spel.ast;
import java.util.Objects;
import java.util.Optional;
import org.jspecify.annotations.Nullable;
import org.springframework.asm.Label;
import org.springframework.asm.MethodVisitor;
import org.springframework.expression.EvaluationException;
@@ -43,6 +45,15 @@ import org.springframework.util.Assert;
*/
public class Elvis extends SpelNodeImpl {
/**
* Tracks the descriptor for the value contained in an {@link Optional} that
* was unwrapped in {@link #getValueInternal(ExpressionState)} using the
* null-safe operator.
* @since 7.1
*/
private @Nullable String unwrappedOptionalDescriptor;
public Elvis(int startPos, int endPos, SpelNodeImpl... args) {
super(startPos, endPos, args);
}
@@ -65,10 +76,10 @@ public class Elvis extends SpelNodeImpl {
Object leftHandValue = leftHandTypedValue.getValue();
if (leftHandValue instanceof Optional<?> optional) {
// Compilation is currently not supported for Optional with the Elvis operator.
this.exitTypeDescriptor = null;
if (optional.isPresent()) {
result = new TypedValue(optional.get());
Object value = optional.get();
this.unwrappedOptionalDescriptor = CodeFlow.toDescriptor(value.getClass());
result = new TypedValue(value);
}
else {
result = this.children[1].getValueInternal(state);
@@ -94,7 +105,8 @@ public class Elvis extends SpelNodeImpl {
public boolean isCompilable() {
SpelNodeImpl condition = this.children[0];
SpelNodeImpl ifNullValue = this.children[1];
String conditionDescriptor = condition.exitTypeDescriptor;
String conditionDescriptor = (this.unwrappedOptionalDescriptor != null ?
this.unwrappedOptionalDescriptor : condition.exitTypeDescriptor);
String ifNullValueDescriptor = ifNullValue.exitTypeDescriptor;
return (condition.isCompilable() && ifNullValue.isCompilable() &&
@@ -116,7 +128,12 @@ public class Elvis extends SpelNodeImpl {
this.children[0].generateCode(mv, cf);
String lastDesc = cf.lastDescriptor();
Assert.state(lastDesc != null, "No last descriptor");
CodeFlow.insertBoxIfNecessary(mv, lastDesc.charAt(0));
if ("Ljava/util/Optional".equals(lastDesc)) {
CodeFlow.insertOptionalUnwrapIfNecessary(mv, lastDesc);
}
else {
CodeFlow.insertBoxIfNecessary(mv, lastDesc.charAt(0));
}
cf.exitCompilationScope();
mv.visitInsn(DUP);
@@ -147,7 +164,8 @@ public class Elvis extends SpelNodeImpl {
private void computeExitTypeDescriptor() {
SpelNodeImpl condition = this.children[0];
SpelNodeImpl ifNullValue = this.children[1];
String conditionDescriptor = condition.exitTypeDescriptor;
String conditionDescriptor = (this.unwrappedOptionalDescriptor != null ?
this.unwrappedOptionalDescriptor : condition.exitTypeDescriptor);
String ifNullValueDescriptor = ifNullValue.exitTypeDescriptor;
if (this.exitTypeDescriptor == null && conditionDescriptor != null && ifNullValueDescriptor != null) {
@@ -346,6 +346,7 @@ public class Indexer extends SpelNodeImpl {
Label skipIfNull = null;
if (isNullSafe()) {
CodeFlow.insertOptionalUnwrapIfNecessary(mv, descriptor);
mv.visitInsn(DUP);
skipIfNull = new Label();
Label continueLabel = new Label();
@@ -77,6 +77,15 @@ public class MethodReference extends SpelNodeImpl {
private @Nullable Character originalPrimitiveExitTypeDescriptor;
/**
* Tracks whether an {@link Optional} was unwrapped in
* {@link #getValueInternal(EvaluationContext, Object, TypeDescriptor, Object[])}
* using the null-safe operator and therefore needs to be unwrapped in a
* compiled expression.
* @since 7.1
*/
private boolean unwrapOptional;
private volatile @Nullable CachedMethodExecutor cachedExecutor;
@@ -133,6 +142,7 @@ public class MethodReference extends SpelNodeImpl {
List<TypeDescriptor> argumentTypes = getArgumentTypes(arguments);
Optional<?> fallbackOptionalTarget = null;
boolean isEmptyOptional = false;
this.unwrapOptional = false;
if (isNullSafe()) {
if (target == null) {
@@ -142,6 +152,7 @@ public class MethodReference extends SpelNodeImpl {
if (optional.isPresent()) {
target = optional.get();
fallbackOptionalTarget = optional;
this.unwrapOptional = true;
}
else {
isEmptyOptional = true;
@@ -193,6 +204,9 @@ public class MethodReference extends SpelNodeImpl {
if (searchResult.methodExecutor != null) {
executorToUse = searchResult.methodExecutor;
targetToUse = fallbackOptionalTarget;
// If we end up using a method on the original Optional instance,
// we don't need to unwrap the Optional in the compiled expression.
this.unwrapOptional = false;
}
}
// If we got this far, that means we failed to find an executor for both the
@@ -384,6 +398,9 @@ public class MethodReference extends SpelNodeImpl {
Label skipIfNull = null;
if (isNullSafe() && (descriptor != null || !isStatic)) {
if (this.unwrapOptional) {
CodeFlow.insertOptionalUnwrapIfNecessary(mv, descriptor);
}
skipIfNull = new Label();
Label continueLabel = new Label();
mv.visitInsn(DUP);
@@ -72,6 +72,14 @@ public class PropertyOrFieldReference extends SpelNodeImpl {
private @Nullable String originalPrimitiveExitTypeDescriptor;
/**
* Tracks whether an {@link Optional} was unwrapped in
* {@link #readProperty(TypedValue, EvaluationContext, String)} using the
* null-safe operator and therefore needs to be unwrapped in a compiled expression.
* @since 7.1
*/
private boolean unwrapOptional;
private volatile @Nullable PropertyAccessor cachedReadAccessor;
private volatile @Nullable PropertyAccessor cachedWriteAccessor;
@@ -196,6 +204,7 @@ public class PropertyOrFieldReference extends SpelNodeImpl {
Object target = originalTarget;
Optional<?> fallbackOptionalTarget = null;
boolean isEmptyOptional = false;
this.unwrapOptional = false;
if (isNullSafe()) {
if (target == null) {
@@ -205,6 +214,7 @@ public class PropertyOrFieldReference extends SpelNodeImpl {
if (optional.isPresent()) {
target = optional.get();
fallbackOptionalTarget = optional;
this.unwrapOptional = true;
}
else {
isEmptyOptional = true;
@@ -249,6 +259,9 @@ public class PropertyOrFieldReference extends SpelNodeImpl {
evalContext, fallbackOptionalTarget, name);
}
this.cachedReadAccessor = accessor;
// If we end up using a property on the original Optional instance,
// we don't need to unwrap the Optional in the compiled expression.
this.unwrapOptional = false;
return accessor.read(evalContext, fallbackOptionalTarget, name);
}
}
@@ -357,6 +370,9 @@ public class PropertyOrFieldReference extends SpelNodeImpl {
Label skipIfNull = null;
if (isNullSafe()) {
if (this.unwrapOptional) {
CodeFlow.insertOptionalUnwrapIfNecessary(mv, cf.lastDescriptor());
}
mv.visitInsn(DUP);
skipIfNull = new Label();
Label continueLabel = new Label();
@@ -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 {