Support Optional with null-safe and Elvis operators in SpEL expressions

This commit introduces null-safe support for java.util.Optional in the
following SpEL operators:

- PropertyOrFieldReference
- MethodReference
- Indexer
- Projection
- Selection
- Elvis

Specifically, when a null-safe operator is applied to an empty
`Optional`, it will be treated as if the `Optional` were `null`, and
the subsequent operation will evaluate to `null`. However, if a
null-safe operator is applied to a non-empty `Optional`, the subsequent
operation will be applied to the object contained in the `Optional`,
thereby effectively unwrapping the `Optional`.

For example, if `user` is of type `Optional<User>`, the expression
`user?.name` will evaluate to `null` if `user` is either `null` or an
empty `Optional` and will otherwise evaluate to the `name` of the
`user`, effectively `user.get().getName()` for property access.

Note, however, that invocations of methods defined in the `Optional`
API are still supported on an empty `Optional`. For example, if `name`
is of type `Optional<String>`, the expression `name?.orElse('Unknown')`
will evaluate to "Unknown" if `name` is an empty `Optional` and will
otherwise evaluate to the `String` contained in the `Optional` if
`name` is a non-empty `Optional`, effectively `name.get()`.

Closes gh-20433
This commit is contained in:
Sam Brannen
2025-03-12 14:53:06 +01:00
parent 1780e30a43
commit 68fce29ae9
9 changed files with 442 additions and 62 deletions
@@ -215,6 +215,144 @@ class OptionalNullSafetyTests {
}
@Nested
class NullSafeTests {
@Test
void accessPropertyOnEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName('')?.name");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context)).isNull();
assertThat(expr.getValue(context)).isNull();
}
@Test
void accessPropertyOnNonEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName('Yoda')?.name");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context)).isEqualTo("Yoda");
assertThat(expr.getValue(context)).isEqualTo("Yoda");
}
@Test
void invokeMethodOnEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName('')?.salutation('Master')");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context)).isNull();
assertThat(expr.getValue(context)).isNull();
}
@Test
void invokeMethodOnNonEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findJediByName('Yoda')?.salutation('Master')");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context)).isEqualTo("Master Yoda");
assertThat(expr.getValue(context)).isEqualTo("Master Yoda");
}
@Test
void accessIndexOnEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.[1]");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context)).isNull();
assertThat(expr.getValue(context)).isNull();
}
@Test
void accessIndexOnNonEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.[1]");
// Invoke multiple times to ensure there are no caching issues.
assertThat(expr.getValue(context)).isEqualTo("lemon");
assertThat(expr.getValue(context)).isEqualTo("lemon");
}
@Test
void projectionOnEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.![#this.length]");
assertThat(expr.getValue(context)).isNull();
}
@Test
@SuppressWarnings("unchecked")
void projectionOnNonEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.![#this.length]");
assertThat(expr.getValue(context, List.class)).containsExactly(6, 5, 5, 9);
}
@Test
void selectAllOnEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.?[#this.length > 5]");
assertThat(expr.getValue(context)).isNull();
}
@Test
@SuppressWarnings("unchecked")
void selectAllOnNonEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.?[#this.length > 5]");
assertThat(expr.getValue(context, List.class)).containsExactly("banana", "pineapple");
}
@Test
void selectFirstOnEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.^[#this.length > 5]");
assertThat(expr.getValue(context)).isNull();
}
@Test
@SuppressWarnings("unchecked")
void selectFirstOnNonEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.^[#this.length > 5]");
assertThat(expr.getValue(context, List.class)).containsExactly("banana");
}
@Test
void selectLastOnEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('')?.$[#this.length > 5]");
assertThat(expr.getValue(context)).isNull();
}
@Test
@SuppressWarnings("unchecked")
void selectLastOnNonEmptyOptionalViaNullSafeOperator() {
Expression expr = parser.parseExpression("#service.findFruitsByColor('yellow')?.$[#this.length > 5]");
assertThat(expr.getValue(context, List.class)).containsExactly("pineapple");
}
}
@Nested
class ElvisTests {
@Test
void elvisOperatorOnEmptyOptional() {
Expression expr = parser.parseExpression("#service.findJediByName('') ?: 'unknown'");
assertThat(expr.getValue(context)).isEqualTo("unknown");
}
@Test
void elvisOperatorOnNonEmptyOptional() {
Expression expr = parser.parseExpression("#service.findJediByName('Yoda') ?: 'unknown'");
assertThat(expr.getValue(context)).isEqualTo(new Jedi("Yoda"));
}
}
record Jedi(String name) {