Browse Source

Introduce null-safe index operator in SpEL

This set of commits introduces support for a null-safe operator in the
Spring Expression Language (SpEL), including support for compiling
expressions that use the null-safe index operator.

Note, however, that compilation is not supported for null-safe indexing
into a String or any kind of Collection (other than a List).

See gh-21468
Closes gh-29847
pull/32551/head
Sam Brannen 2 years ago
parent
commit
2a74fe5ddb
  1. 60
      framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc
  2. 4
      framework-docs/modules/ROOT/pages/core/expressions/language-ref/properties-arrays.adoc
  3. 103
      spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java
  4. 11
      spring-expression/src/main/java/org/springframework/expression/spel/standard/InternalSpelExpressionParser.java
  5. 73
      spring-expression/src/test/java/org/springframework/expression/spel/IndexingTests.java
  6. 4
      spring-expression/src/test/java/org/springframework/expression/spel/ParsingTests.java
  7. 203
      spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java
  8. 18
      spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java

60
framework-docs/modules/ROOT/pages/core/expressions/language-ref/operator-safe-navigation.adoc

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
[[expressions-operator-safe-navigation]]
= Safe Navigation Operator
The safe navigation operator (`?`) is used to avoid a `NullPointerException` and comes
The safe navigation operator (`?.`) is used to avoid a `NullPointerException` and comes
from the https://www.groovy-lang.org/operators.html#_safe_navigation_operator[Groovy]
language. Typically, when you have a reference to an object, you might need to verify
that it is not `null` before accessing methods or properties of the object. To avoid
@ -81,6 +81,64 @@ For example, the expression `#calculator?.max(4, 2)` evaluates to `null` if the @@ -81,6 +81,64 @@ For example, the expression `#calculator?.max(4, 2)` evaluates to `null` if the
`max(int, int)` method will be invoked on the `#calculator`.
====
[[expressions-operator-safe-navigation-indexing]]
== Safe Index Access
Since Spring Framework 6.2, the Spring Expression Language supports safe navigation for
indexing into the following types of structures.
* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-arrays-and-collections[arrays and collections]
* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-strings[strings]
* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-maps[maps]
* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-objects[objects]
The following example shows how to use the safe navigation operator for indexing into
a list (`?.[]`).
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes",role="primary"]
----
ExpressionParser parser = new SpelExpressionParser();
IEEE society = new IEEE();
EvaluationContext context = new StandardEvaluationContext(society);
// evaluates to Inventor("Nikola Tesla")
Inventor inventor = parser.parseExpression("members?.[0]") // <1>
.getValue(context, Inventor.class);
society.members = null;
// evaluates to null - does not throw an exception
inventor = parser.parseExpression("members?.[0]") // <2>
.getValue(context, Inventor.class);
----
<1> Use null-safe index operator on a non-null `members` list
<2> Use null-safe index operator on a null `members` list
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"]
----
val parser = SpelExpressionParser()
val society = IEEE()
val context = StandardEvaluationContext(society)
// evaluates to Inventor("Nikola Tesla")
var inventor = parser.parseExpression("members?.[0]") // <1>
.getValue(context, Inventor::class.java)
society.members = null
// evaluates to null - does not throw an exception
inventor = parser.parseExpression("members?.[0]") // <2>
.getValue(context, Inventor::class.java)
----
<1> Use null-safe index operator on a non-null `members` list
<2> Use null-safe index operator on a null `members` list
======
[[expressions-operator-safe-navigation-selection-and-projection]]
== Safe Collection Selection and Projection

4
framework-docs/modules/ROOT/pages/core/expressions/language-ref/properties-arrays.adoc

@ -7,6 +7,10 @@ into various structures. @@ -7,6 +7,10 @@ into various structures.
NOTE: Numerical index values are zero-based, such as when accessing the n^th^ element of
an array in Java.
TIP: See the xref:core/expressions/language-ref/operator-safe-navigation.adoc[Safe Navigation Operator]
section for details on how to navigate object graphs and index into various structures
using the null-safe operator.
[[expressions-property-navigation]]
== Property Navigation

103
spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java

@ -22,6 +22,7 @@ import java.util.List; @@ -22,6 +22,7 @@ import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.springframework.asm.Label;
import org.springframework.asm.MethodVisitor;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.expression.AccessException;
@ -57,6 +58,13 @@ import org.springframework.util.ReflectionUtils; @@ -57,6 +58,13 @@ import org.springframework.util.ReflectionUtils;
* <li>Objects: the property with the specified name</li>
* </ul>
*
* <h3>Null-safe Indexing</h3>
*
* <p>As of Spring Framework 6.2, null-safe indexing is supported via the {@code '?.'}
* operator. For example, {@code 'colors?.[0]'} will evaluate to {@code null} if
* {@code colors} is {@code null} and will otherwise evaluate to the 0<sup>th</sup>
* color.
*
* @author Andy Clement
* @author Phillip Webb
* @author Stephane Nicoll
@ -68,9 +76,14 @@ public class Indexer extends SpelNodeImpl { @@ -68,9 +76,14 @@ public class Indexer extends SpelNodeImpl {
private enum IndexedType {ARRAY, LIST, MAP, STRING, OBJECT}
private final boolean nullSafe;
@Nullable
private IndexedType indexedType;
@Nullable
private String originalPrimitiveExitTypeDescriptor;
@Nullable
private volatile String arrayTypeDescriptor;
@ -106,12 +119,34 @@ public class Indexer extends SpelNodeImpl { @@ -106,12 +119,34 @@ public class Indexer extends SpelNodeImpl {
/**
* Create an {@code Indexer} with the given start position, end position, and
* index expression.
* @see #Indexer(boolean, int, int, SpelNodeImpl)
* @deprecated as of Spring Framework 6.2, in favor of {@link #Indexer(boolean, int, int, SpelNodeImpl)}
*/
@Deprecated(since = "6.2", forRemoval = true)
public Indexer(int startPos, int endPos, SpelNodeImpl indexExpression) {
this(false, startPos, endPos, indexExpression);
}
/**
* Create an {@code Indexer} with the given null-safe flag, start position,
* end position, and index expression.
* @since 6.2
*/
public Indexer(boolean nullSafe, int startPos, int endPos, SpelNodeImpl indexExpression) {
super(startPos, endPos, indexExpression);
this.nullSafe = nullSafe;
}
/**
* Does this node represent a null-safe index operation?
* @since 6.2
*/
@Override
public final boolean isNullSafe() {
return this.nullSafe;
}
@Override
public TypedValue getValueInternal(ExpressionState state) throws EvaluationException {
return getValueRef(state).getValue();
@ -136,6 +171,15 @@ public class Indexer extends SpelNodeImpl { @@ -136,6 +171,15 @@ public class Indexer extends SpelNodeImpl {
protected ValueRef getValueRef(ExpressionState state) throws EvaluationException {
TypedValue context = state.getActiveContextObject();
Object target = context.getValue();
if (target == null) {
if (this.nullSafe) {
return ValueRef.NullValueRef.INSTANCE;
}
// Raise a proper exception in case of a null target
throw new SpelEvaluationException(getStartPosition(), SpelMessage.CANNOT_INDEX_INTO_NULL_VALUE);
}
TypeDescriptor targetDescriptor = context.getTypeDescriptor();
TypedValue indexValue;
Object index;
@ -159,11 +203,6 @@ public class Indexer extends SpelNodeImpl { @@ -159,11 +203,6 @@ public class Indexer extends SpelNodeImpl {
}
}
// Raise a proper exception in case of a null target
if (target == null) {
throw new SpelEvaluationException(getStartPosition(), SpelMessage.CANNOT_INDEX_INTO_NULL_VALUE);
}
// At this point, we need a TypeDescriptor for a non-null target object
Assert.state(targetDescriptor != null, "No type descriptor");
@ -243,6 +282,17 @@ public class Indexer extends SpelNodeImpl { @@ -243,6 +282,17 @@ public class Indexer extends SpelNodeImpl {
cf.loadTarget(mv);
}
Label skipIfNull = null;
if (this.nullSafe) {
mv.visitInsn(DUP);
skipIfNull = new Label();
Label continueLabel = new Label();
mv.visitJumpInsn(IFNONNULL, continueLabel);
CodeFlow.insertCheckCast(mv, exitTypeDescriptor);
mv.visitJumpInsn(GOTO, skipIfNull);
mv.visitLabel(continueLabel);
}
SpelNodeImpl index = this.children[0];
if (this.indexedType == IndexedType.ARRAY) {
@ -305,6 +355,16 @@ public class Indexer extends SpelNodeImpl { @@ -305,6 +355,16 @@ public class Indexer extends SpelNodeImpl {
}
cf.pushDescriptor(exitTypeDescriptor);
if (skipIfNull != null) {
if (this.originalPrimitiveExitTypeDescriptor != null) {
// The output of the indexer is a primitive, but from the logic above it
// might be null. So, to have a common stack element type at the skipIfNull
// target, it is necessary to box the primitive.
CodeFlow.insertBoxIfNecessary(mv, this.originalPrimitiveExitTypeDescriptor);
}
mv.visitLabel(skipIfNull);
}
}
@Override
@ -368,56 +428,56 @@ public class Indexer extends SpelNodeImpl { @@ -368,56 +428,56 @@ public class Indexer extends SpelNodeImpl {
if (arrayComponentType == boolean.class) {
boolean[] array = (boolean[]) ctx;
checkAccess(array.length, idx);
this.exitTypeDescriptor = "Z";
setExitTypeDescriptor("Z");
this.arrayTypeDescriptor = "[Z";
return array[idx];
}
else if (arrayComponentType == byte.class) {
byte[] array = (byte[]) ctx;
checkAccess(array.length, idx);
this.exitTypeDescriptor = "B";
setExitTypeDescriptor("B");
this.arrayTypeDescriptor = "[B";
return array[idx];
}
else if (arrayComponentType == char.class) {
char[] array = (char[]) ctx;
checkAccess(array.length, idx);
this.exitTypeDescriptor = "C";
setExitTypeDescriptor("C");
this.arrayTypeDescriptor = "[C";
return array[idx];
}
else if (arrayComponentType == double.class) {
double[] array = (double[]) ctx;
checkAccess(array.length, idx);
this.exitTypeDescriptor = "D";
setExitTypeDescriptor("D");
this.arrayTypeDescriptor = "[D";
return array[idx];
}
else if (arrayComponentType == float.class) {
float[] array = (float[]) ctx;
checkAccess(array.length, idx);
this.exitTypeDescriptor = "F";
setExitTypeDescriptor("F");
this.arrayTypeDescriptor = "[F";
return array[idx];
}
else if (arrayComponentType == int.class) {
int[] array = (int[]) ctx;
checkAccess(array.length, idx);
this.exitTypeDescriptor = "I";
setExitTypeDescriptor("I");
this.arrayTypeDescriptor = "[I";
return array[idx];
}
else if (arrayComponentType == long.class) {
long[] array = (long[]) ctx;
checkAccess(array.length, idx);
this.exitTypeDescriptor = "J";
setExitTypeDescriptor("J");
this.arrayTypeDescriptor = "[J";
return array[idx];
}
else if (arrayComponentType == short.class) {
short[] array = (short[]) ctx;
checkAccess(array.length, idx);
this.exitTypeDescriptor = "S";
setExitTypeDescriptor("S");
this.arrayTypeDescriptor = "[S";
return array[idx];
}
@ -425,7 +485,7 @@ public class Indexer extends SpelNodeImpl { @@ -425,7 +485,7 @@ public class Indexer extends SpelNodeImpl {
Object[] array = (Object[]) ctx;
checkAccess(array.length, idx);
Object retValue = array[idx];
this.exitTypeDescriptor = CodeFlow.toDescriptor(arrayComponentType);
setExitTypeDescriptor(CodeFlow.toDescriptor(arrayComponentType));
this.arrayTypeDescriptor = CodeFlow.toDescriptor(array.getClass());
return retValue;
}
@ -438,6 +498,19 @@ public class Indexer extends SpelNodeImpl { @@ -438,6 +498,19 @@ public class Indexer extends SpelNodeImpl {
}
}
private void setExitTypeDescriptor(String descriptor) {
// If this indexer would return a primitive - and yet it is also marked
// null-safe - then the exit type descriptor must be promoted to the box
// type to allow a null value to be passed on.
if (this.nullSafe && CodeFlow.isPrimitive(descriptor)) {
this.originalPrimitiveExitTypeDescriptor = descriptor;
this.exitTypeDescriptor = CodeFlow.toBoxedDescriptor(descriptor);
}
else {
this.exitTypeDescriptor = descriptor;
}
}
@SuppressWarnings("unchecked")
private <T> T convertValue(TypeConverter converter, @Nullable Object value, Class<T> targetType) {
T result = (T) converter.convertValue(
@ -574,7 +647,7 @@ public class Indexer extends SpelNodeImpl { @@ -574,7 +647,7 @@ public class Indexer extends SpelNodeImpl {
Indexer.this.cachedReadName = this.name;
Indexer.this.cachedReadTargetType = targetObjectRuntimeClass;
if (accessor instanceof CompilablePropertyAccessor compilablePropertyAccessor) {
Indexer.this.exitTypeDescriptor = CodeFlow.toDescriptor(compilablePropertyAccessor.getPropertyType());
setExitTypeDescriptor(CodeFlow.toDescriptor(compilablePropertyAccessor.getPropertyType()));
}
return accessor.read(this.evaluationContext, this.targetObject, this.name);
}

11
spring-expression/src/main/java/org/springframework/expression/spel/standard/InternalSpelExpressionParser.java

@ -399,7 +399,7 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser { @@ -399,7 +399,7 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser {
@Nullable
private SpelNodeImpl eatNonDottedNode() {
if (peekToken(TokenKind.LSQUARE)) {
if (maybeEatIndexer()) {
if (maybeEatIndexer(false)) {
return pop();
}
}
@ -419,7 +419,8 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser { @@ -419,7 +419,8 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser {
Token t = takeToken(); // it was a '.' or a '?.'
boolean nullSafeNavigation = (t.kind == TokenKind.SAFE_NAVI);
if (maybeEatMethodOrProperty(nullSafeNavigation) || maybeEatFunctionOrVar() ||
maybeEatProjection(nullSafeNavigation) || maybeEatSelection(nullSafeNavigation)) {
maybeEatProjection(nullSafeNavigation) || maybeEatSelection(nullSafeNavigation) ||
maybeEatIndexer(nullSafeNavigation)) {
return pop();
}
if (peekToken() == null) {
@ -537,7 +538,7 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser { @@ -537,7 +538,7 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser {
else if (maybeEatBeanReference()) {
return pop();
}
else if (maybeEatProjection(false) || maybeEatSelection(false) || maybeEatIndexer()) {
else if (maybeEatProjection(false) || maybeEatSelection(false) || maybeEatIndexer(false)) {
return pop();
}
else if (maybeEatInlineListOrMap()) {
@ -699,7 +700,7 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser { @@ -699,7 +700,7 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser {
return true;
}
private boolean maybeEatIndexer() {
private boolean maybeEatIndexer(boolean nullSafeNavigation) {
Token t = peekToken();
if (t == null || !peekToken(TokenKind.LSQUARE, true)) {
return false;
@ -709,7 +710,7 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser { @@ -709,7 +710,7 @@ class InternalSpelExpressionParser extends TemplateAwareExpressionParser {
throw internalException(t.startPos, SpelMessage.MISSING_SELECTION_EXPRESSION);
}
eatToken(TokenKind.RSQUARE);
this.constructedNodes.push(new Indexer(t.startPos, t.endPos, expr));
this.constructedNodes.push(new Indexer(nullSafeNavigation, t.startPos, t.endPos, expr));
return true;
}

73
spring-expression/src/test/java/org/springframework/expression/spel/IndexingTests.java

@ -26,7 +26,9 @@ import java.util.Arrays; @@ -26,7 +26,9 @@ import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.expression.EvaluationContext;
@ -35,6 +37,7 @@ import org.springframework.expression.PropertyAccessor; @@ -35,6 +37,7 @@ import org.springframework.expression.PropertyAccessor;
import org.springframework.expression.TypedValue;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.expression.spel.testresources.Person;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@ -376,6 +379,76 @@ class IndexingTests { @@ -376,6 +379,76 @@ class IndexingTests {
assertThat(expression.getValue(this, String.class)).isEqualTo("apple");
}
@Nested
class NullSafeIndexTests { // gh-29847
private final RootContextWithIndexedProperties rootContext = new RootContextWithIndexedProperties();
private final StandardEvaluationContext context = new StandardEvaluationContext(rootContext);
private final SpelExpressionParser parser = new SpelExpressionParser();
private Expression expression;
@Test
void nullSafeIndexIntoArray() {
expression = parser.parseExpression("array?.[0]");
assertThat(expression.getValue(context)).isNull();
rootContext.array = new int[] {42};
assertThat(expression.getValue(context)).isEqualTo(42);
}
@Test
void nullSafeIndexIntoList() {
expression = parser.parseExpression("list?.[0]");
assertThat(expression.getValue(context)).isNull();
rootContext.list = List.of(42);
assertThat(expression.getValue(context)).isEqualTo(42);
}
@Test
void nullSafeIndexIntoSet() {
expression = parser.parseExpression("set?.[0]");
assertThat(expression.getValue(context)).isNull();
rootContext.set = Set.of(42);
assertThat(expression.getValue(context)).isEqualTo(42);
}
@Test
void nullSafeIndexIntoString() {
expression = parser.parseExpression("string?.[0]");
assertThat(expression.getValue(context)).isNull();
rootContext.string = "XYZ";
assertThat(expression.getValue(context)).isEqualTo("X");
}
@Test
void nullSafeIndexIntoMap() {
expression = parser.parseExpression("map?.['enigma']");
assertThat(expression.getValue(context)).isNull();
rootContext.map = Map.of("enigma", 42);
assertThat(expression.getValue(context)).isEqualTo(42);
}
@Test
void nullSafeIndexIntoObject() {
expression = parser.parseExpression("person?.['name']");
assertThat(expression.getValue(context)).isNull();
rootContext.person = new Person("Jane");
assertThat(expression.getValue(context)).isEqualTo("Jane");
}
static class RootContextWithIndexedProperties {
public int[] array;
public List<Integer> list;
public Set<Integer> set;
public String string;
public Map<String, Integer> map;
public Person person;
}
}
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)

4
spring-expression/src/test/java/org/springframework/expression/spel/ParsingTests.java

@ -60,8 +60,8 @@ class ParsingTests { @@ -60,8 +60,8 @@ class ParsingTests {
parseCheck("property1?.property2?.methodOne()");
parseCheck("property1?.methodOne('enigma')?.methodTwo(42)");
parseCheck("property1?.methodOne()?.property2?.methodTwo()");
parseCheck("property1[0]?.property2['key']?.methodTwo()");
parseCheck("property1[0][1]?.property2['key'][42]?.methodTwo()");
parseCheck("property1?.[0]?.property2?.['key']?.methodTwo()");
parseCheck("property1?.[0]?.[1]?.property2?.['key']?.[42]?.methodTwo()");
}
@Test

203
spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java

@ -725,6 +725,198 @@ public class SpelCompilationCoverageTests extends AbstractExpressionTests { @@ -725,6 +725,198 @@ public class SpelCompilationCoverageTests extends AbstractExpressionTests {
}
@Nested
class NullSafeIndexTests { // gh-29847
private final RootContextWithIndexedProperties rootContext = new RootContextWithIndexedProperties();
private final StandardEvaluationContext context = new StandardEvaluationContext(rootContext);
@Test
void nullSafeIndexIntoPrimitiveIntArray() {
expression = parser.parseExpression("intArray?.[0]");
// Cannot compile before the array type is known.
assertThat(expression.getValue(context)).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isNull();
rootContext.intArray = new int[] { 8, 9, 10 };
assertThat(expression.getValue(context)).isEqualTo(8);
assertCanCompile(expression);
assertThat(expression.getValue(context)).isEqualTo(8);
// Normally we would expect the exit type descriptor to be "I" for an
// element of an int[]. However, with null-safe indexing support the
// only way for it to evaluate to null is to box the 'int' to an 'Integer'.
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Integer");
// Null-safe support should have been compiled once the array type is known.
rootContext.intArray = null;
assertThat(expression.getValue(context)).isNull();
assertCanCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Integer");
}
@Test
void nullSafeIndexIntoNumberArray() {
expression = parser.parseExpression("numberArray?.[0]");
// Cannot compile before the array type is known.
assertThat(expression.getValue(context)).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isNull();
rootContext.numberArray = new Number[] { 8, 9, 10 };
assertThat(expression.getValue(context)).isEqualTo(8);
assertCanCompile(expression);
assertThat(expression.getValue(context)).isEqualTo(8);
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Number");
// Null-safe support should have been compiled once the array type is known.
rootContext.numberArray = null;
assertThat(expression.getValue(context)).isNull();
assertCanCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Number");
}
@Test
void nullSafeIndexIntoList() {
expression = parser.parseExpression("list?.[0]");
// Cannot compile before the list type is known.
assertThat(expression.getValue(context)).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isNull();
rootContext.list = List.of(42);
assertThat(expression.getValue(context)).isEqualTo(42);
assertCanCompile(expression);
assertThat(expression.getValue(context)).isEqualTo(42);
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Object");
// Null-safe support should have been compiled once the list type is known.
rootContext.list = null;
assertThat(expression.getValue(context)).isNull();
assertCanCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Object");
}
@Test
void nullSafeIndexIntoSetCannotBeCompiled() {
expression = parser.parseExpression("set?.[0]");
assertThat(expression.getValue(context)).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isNull();
rootContext.set = Set.of(42);
assertThat(expression.getValue(context)).isEqualTo(42);
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isEqualTo(42);
assertThat(getAst().getExitDescriptor()).isNull();
}
@Test
void nullSafeIndexIntoStringCannotBeCompiled() {
expression = parser.parseExpression("string?.[0]");
assertThat(expression.getValue(context)).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isNull();
rootContext.string = "XYZ";
assertThat(expression.getValue(context)).isEqualTo("X");
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isEqualTo("X");
assertThat(getAst().getExitDescriptor()).isNull();
}
@Test
void nullSafeIndexIntoMap() {
expression = parser.parseExpression("map?.['enigma']");
// Cannot compile before the map type is known.
assertThat(expression.getValue(context)).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isNull();
rootContext.map = Map.of("enigma", 42);
assertThat(expression.getValue(context)).isEqualTo(42);
assertCanCompile(expression);
assertThat(expression.getValue(context)).isEqualTo(42);
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Object");
// Null-safe support should have been compiled once the map type is known.
rootContext.map = null;
assertThat(expression.getValue(context)).isNull();
assertCanCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Object");
}
@Test
void nullSafeIndexIntoObjectViaPrimitiveProperty() {
expression = parser.parseExpression("person?.['age']");
// Cannot compile before the Person type is known.
assertThat(expression.getValue(context)).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isNull();
rootContext.person = new Person("Jane");
rootContext.person.setAge(42);
assertThat(expression.getValue(context)).isEqualTo(42);
assertCanCompile(expression);
assertThat(expression.getValue(context)).isEqualTo(42);
// Normally we would expect the exit type descriptor to be "I" for
// an int. However, with null-safe indexing support the only way
// for it to evaluate to null is to box the 'int' to an 'Integer'.
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Integer");
// Null-safe support should have been compiled once the Person type is known.
rootContext.person = null;
assertThat(expression.getValue(context)).isNull();
assertCanCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/Integer");
}
@Test
void nullSafeIndexIntoObjectViaStringProperty() {
expression = parser.parseExpression("person?.['name']");
// Cannot compile before the Person type is known.
assertThat(expression.getValue(context)).isNull();
assertCannotCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isNull();
rootContext.person = new Person("Jane");
assertThat(expression.getValue(context)).isEqualTo("Jane");
assertCanCompile(expression);
assertThat(expression.getValue(context)).isEqualTo("Jane");
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/String");
// Null-safe support should have been compiled once the Person type is known.
rootContext.person = null;
assertThat(expression.getValue(context)).isNull();
assertCanCompile(expression);
assertThat(expression.getValue(context)).isNull();
assertThat(getAst().getExitDescriptor()).isEqualTo("Ljava/lang/String");
}
}
@Nested
class PropertyVisibilityTests {
@ -6736,4 +6928,15 @@ public class SpelCompilationCoverageTests extends AbstractExpressionTests { @@ -6736,4 +6928,15 @@ public class SpelCompilationCoverageTests extends AbstractExpressionTests {
}
}
// Must be public with public fields/properties.
public static class RootContextWithIndexedProperties {
public int[] intArray;
public Number[] numberArray;
public List<Integer> list;
public Set<Integer> set;
public String string;
public Map<String, Integer> map;
public Person person;
}
}

18
spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java

@ -688,6 +688,24 @@ class SpelDocumentationTests extends AbstractExpressionTests { @@ -688,6 +688,24 @@ class SpelDocumentationTests extends AbstractExpressionTests {
assertThat(city).isNull();
}
@Test
void nullSafeIndexing() {
IEEE society = new IEEE();
EvaluationContext context = new StandardEvaluationContext(society);
// evaluates to Inventor("Nikola Tesla")
Inventor inventor = parser.parseExpression("members?.[0]") // <1>
.getValue(context, Inventor.class);
assertThat(inventor).extracting(Inventor::getName).isEqualTo("Nikola Tesla");
society.members = null;
// evaluates to null - does not throw an Exception
inventor = parser.parseExpression("members?.[0]") // <2>
.getValue(context, Inventor.class);
assertThat(inventor).isNull();
}
@Test
@SuppressWarnings("unchecked")
void nullSafeSelection() {

Loading…
Cancel
Save