diff --git a/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc b/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc
index 137c0496f84..2501e72e4b3 100644
--- a/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc
+++ b/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc
@@ -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
diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/CodeFlow.java b/spring-expression/src/main/java/org/springframework/expression/spel/CodeFlow.java
index f34ff43d1a9..2edd7ad47a6 100644
--- a/spring-expression/src/main/java/org/springframework/expression/spel/CodeFlow.java
+++ b/spring-expression/src/main/java/org/springframework/expression/spel/CodeFlow.java
@@ -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.
+ *
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.
diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java
index c3ebc93ac21..155730f9ec5 100644
--- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java
+++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java
@@ -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) {
diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java
index 09b270d5bfc..2a6ef4b2cce 100644
--- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java
+++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java
@@ -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();
diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java
index 096c55eb0ed..2b37979548c 100644
--- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java
+++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java
@@ -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 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);
diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java
index b0cfd040b85..07313f1a9bd 100644
--- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java
+++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java
@@ -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();
diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java
index 4308c0e5a83..09a3dcdb672 100644
--- a/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java
+++ b/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java
@@ -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 findJediByName(@Nullable String name) {
if (name == null) {
diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java
index 0fbd98ab010..58ec82749b2 100644
--- a/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java
+++ b/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java
@@ -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 {