Skip to content

Commit

Permalink
Introduce null-safe index operator in SpEL
Browse files Browse the repository at this point in the history
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 spring-projectsgh-21468
Closes spring-projectsgh-29847
  • Loading branch information
sbrannen committed Mar 23, 2024
2 parents 2a1abb5 + 218a148 commit 2a74fe5
Show file tree
Hide file tree
Showing 8 changed files with 453 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
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;
Expand Down Expand Up @@ -57,6 +58,13 @@
* <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
Expand All @@ -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;

Expand Down Expand Up @@ -106,12 +119,34 @@ private enum IndexedType {ARRAY, LIST, MAP, STRING, OBJECT}
/**
* 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();
Expand All @@ -136,6 +171,15 @@ public boolean isWritable(ExpressionState expressionState) throws SpelEvaluation
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;
Expand All @@ -159,11 +203,6 @@ protected ValueRef getValueRef(ExpressionState state) throws EvaluationException
}
}

// 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");

Expand Down Expand Up @@ -243,6 +282,17 @@ public void generateCode(MethodVisitor mv, CodeFlow cf) {
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) {
Expand Down Expand Up @@ -305,6 +355,16 @@ else if (this.indexedType == IndexedType.OBJECT) {
}

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
Expand Down Expand Up @@ -368,64 +428,64 @@ private Object accessArrayElement(Object ctx, int idx) throws SpelEvaluationExce
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];
}
else {
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;
}
Expand All @@ -438,6 +498,19 @@ private void checkAccess(int arrayLength, int index) throws SpelEvaluationExcept
}
}

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(
Expand Down Expand Up @@ -574,7 +647,7 @@ public TypedValue getValue() {
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ private SpelNodeImpl eatNode() {
@Nullable
private SpelNodeImpl eatNonDottedNode() {
if (peekToken(TokenKind.LSQUARE)) {
if (maybeEatIndexer()) {
if (maybeEatIndexer(false)) {
return pop();
}
}
Expand All @@ -419,7 +419,8 @@ private SpelNodeImpl eatDottedNode() {
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) {
Expand Down Expand Up @@ -537,7 +538,7 @@ else if (maybeEatTypeReference() || maybeEatNullReference() || maybeEatConstruct
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()) {
Expand Down Expand Up @@ -699,7 +700,7 @@ else if (peekToken(TokenKind.COLON, true)) { // map!
return true;
}

private boolean maybeEatIndexer() {
private boolean maybeEatIndexer(boolean nullSafeNavigation) {
Token t = peekToken();
if (t == null || !peekToken(TokenKind.LSQUARE, true)) {
return false;
Expand All @@ -709,7 +710,7 @@ private boolean maybeEatIndexer() {
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;
}

Expand Down
Loading

0 comments on commit 2a74fe5

Please sign in to comment.