Skip to content

Commit 2a74fe5

Browse files
committed
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
2 parents 2a1abb5 + 218a148 commit 2a74fe5

File tree

8 files changed

+453
-23
lines changed

8 files changed

+453
-23
lines changed

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

+59-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[[expressions-operator-safe-navigation]]
22
= Safe Navigation Operator
33

4-
The safe navigation operator (`?`) is used to avoid a `NullPointerException` and comes
4+
The safe navigation operator (`?.`) is used to avoid a `NullPointerException` and comes
55
from the https://www.groovy-lang.org/operators.html#_safe_navigation_operator[Groovy]
66
language. Typically, when you have a reference to an object, you might need to verify
77
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
8181
`max(int, int)` method will be invoked on the `#calculator`.
8282
====
8383

84+
[[expressions-operator-safe-navigation-indexing]]
85+
== Safe Index Access
86+
87+
Since Spring Framework 6.2, the Spring Expression Language supports safe navigation for
88+
indexing into the following types of structures.
89+
90+
* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-arrays-and-collections[arrays and collections]
91+
* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-strings[strings]
92+
* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-maps[maps]
93+
* xref:core/expressions/language-ref/properties-arrays.adoc#expressions-indexing-objects[objects]
94+
95+
The following example shows how to use the safe navigation operator for indexing into
96+
a list (`?.[]`).
97+
98+
[tabs]
99+
======
100+
Java::
101+
+
102+
[source,java,indent=0,subs="verbatim,quotes",role="primary"]
103+
----
104+
ExpressionParser parser = new SpelExpressionParser();
105+
IEEE society = new IEEE();
106+
EvaluationContext context = new StandardEvaluationContext(society);
107+
108+
// evaluates to Inventor("Nikola Tesla")
109+
Inventor inventor = parser.parseExpression("members?.[0]") // <1>
110+
.getValue(context, Inventor.class);
111+
112+
society.members = null;
113+
114+
// evaluates to null - does not throw an exception
115+
inventor = parser.parseExpression("members?.[0]") // <2>
116+
.getValue(context, Inventor.class);
117+
----
118+
<1> Use null-safe index operator on a non-null `members` list
119+
<2> Use null-safe index operator on a null `members` list
120+
121+
Kotlin::
122+
+
123+
[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"]
124+
----
125+
val parser = SpelExpressionParser()
126+
val society = IEEE()
127+
val context = StandardEvaluationContext(society)
128+
129+
// evaluates to Inventor("Nikola Tesla")
130+
var inventor = parser.parseExpression("members?.[0]") // <1>
131+
.getValue(context, Inventor::class.java)
132+
133+
society.members = null
134+
135+
// evaluates to null - does not throw an exception
136+
inventor = parser.parseExpression("members?.[0]") // <2>
137+
.getValue(context, Inventor::class.java)
138+
----
139+
<1> Use null-safe index operator on a non-null `members` list
140+
<2> Use null-safe index operator on a null `members` list
141+
======
84142

85143
[[expressions-operator-safe-navigation-selection-and-projection]]
86144
== Safe Collection Selection and Projection

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

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ into various structures.
77
NOTE: Numerical index values are zero-based, such as when accessing the n^th^ element of
88
an array in Java.
99

10+
TIP: See the xref:core/expressions/language-ref/operator-safe-navigation.adoc[Safe Navigation Operator]
11+
section for details on how to navigate object graphs and index into various structures
12+
using the null-safe operator.
13+
1014
[[expressions-property-navigation]]
1115
== Property Navigation
1216

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

+88-15
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.Map;
2323
import java.util.function.Supplier;
2424

25+
import org.springframework.asm.Label;
2526
import org.springframework.asm.MethodVisitor;
2627
import org.springframework.core.convert.TypeDescriptor;
2728
import org.springframework.expression.AccessException;
@@ -57,6 +58,13 @@
5758
* <li>Objects: the property with the specified name</li>
5859
* </ul>
5960
*
61+
* <h3>Null-safe Indexing</h3>
62+
*
63+
* <p>As of Spring Framework 6.2, null-safe indexing is supported via the {@code '?.'}
64+
* operator. For example, {@code 'colors?.[0]'} will evaluate to {@code null} if
65+
* {@code colors} is {@code null} and will otherwise evaluate to the 0<sup>th</sup>
66+
* color.
67+
*
6068
* @author Andy Clement
6169
* @author Phillip Webb
6270
* @author Stephane Nicoll
@@ -68,9 +76,14 @@ public class Indexer extends SpelNodeImpl {
6876
private enum IndexedType {ARRAY, LIST, MAP, STRING, OBJECT}
6977

7078

79+
private final boolean nullSafe;
80+
7181
@Nullable
7282
private IndexedType indexedType;
7383

84+
@Nullable
85+
private String originalPrimitiveExitTypeDescriptor;
86+
7487
@Nullable
7588
private volatile String arrayTypeDescriptor;
7689

@@ -106,12 +119,34 @@ private enum IndexedType {ARRAY, LIST, MAP, STRING, OBJECT}
106119
/**
107120
* Create an {@code Indexer} with the given start position, end position, and
108121
* index expression.
122+
* @see #Indexer(boolean, int, int, SpelNodeImpl)
123+
* @deprecated as of Spring Framework 6.2, in favor of {@link #Indexer(boolean, int, int, SpelNodeImpl)}
109124
*/
125+
@Deprecated(since = "6.2", forRemoval = true)
110126
public Indexer(int startPos, int endPos, SpelNodeImpl indexExpression) {
127+
this(false, startPos, endPos, indexExpression);
128+
}
129+
130+
/**
131+
* Create an {@code Indexer} with the given null-safe flag, start position,
132+
* end position, and index expression.
133+
* @since 6.2
134+
*/
135+
public Indexer(boolean nullSafe, int startPos, int endPos, SpelNodeImpl indexExpression) {
111136
super(startPos, endPos, indexExpression);
137+
this.nullSafe = nullSafe;
112138
}
113139

114140

141+
/**
142+
* Does this node represent a null-safe index operation?
143+
* @since 6.2
144+
*/
145+
@Override
146+
public final boolean isNullSafe() {
147+
return this.nullSafe;
148+
}
149+
115150
@Override
116151
public TypedValue getValueInternal(ExpressionState state) throws EvaluationException {
117152
return getValueRef(state).getValue();
@@ -136,6 +171,15 @@ public boolean isWritable(ExpressionState expressionState) throws SpelEvaluation
136171
protected ValueRef getValueRef(ExpressionState state) throws EvaluationException {
137172
TypedValue context = state.getActiveContextObject();
138173
Object target = context.getValue();
174+
175+
if (target == null) {
176+
if (this.nullSafe) {
177+
return ValueRef.NullValueRef.INSTANCE;
178+
}
179+
// Raise a proper exception in case of a null target
180+
throw new SpelEvaluationException(getStartPosition(), SpelMessage.CANNOT_INDEX_INTO_NULL_VALUE);
181+
}
182+
139183
TypeDescriptor targetDescriptor = context.getTypeDescriptor();
140184
TypedValue indexValue;
141185
Object index;
@@ -159,11 +203,6 @@ protected ValueRef getValueRef(ExpressionState state) throws EvaluationException
159203
}
160204
}
161205

162-
// Raise a proper exception in case of a null target
163-
if (target == null) {
164-
throw new SpelEvaluationException(getStartPosition(), SpelMessage.CANNOT_INDEX_INTO_NULL_VALUE);
165-
}
166-
167206
// At this point, we need a TypeDescriptor for a non-null target object
168207
Assert.state(targetDescriptor != null, "No type descriptor");
169208

@@ -243,6 +282,17 @@ public void generateCode(MethodVisitor mv, CodeFlow cf) {
243282
cf.loadTarget(mv);
244283
}
245284

285+
Label skipIfNull = null;
286+
if (this.nullSafe) {
287+
mv.visitInsn(DUP);
288+
skipIfNull = new Label();
289+
Label continueLabel = new Label();
290+
mv.visitJumpInsn(IFNONNULL, continueLabel);
291+
CodeFlow.insertCheckCast(mv, exitTypeDescriptor);
292+
mv.visitJumpInsn(GOTO, skipIfNull);
293+
mv.visitLabel(continueLabel);
294+
}
295+
246296
SpelNodeImpl index = this.children[0];
247297

248298
if (this.indexedType == IndexedType.ARRAY) {
@@ -305,6 +355,16 @@ else if (this.indexedType == IndexedType.OBJECT) {
305355
}
306356

307357
cf.pushDescriptor(exitTypeDescriptor);
358+
359+
if (skipIfNull != null) {
360+
if (this.originalPrimitiveExitTypeDescriptor != null) {
361+
// The output of the indexer is a primitive, but from the logic above it
362+
// might be null. So, to have a common stack element type at the skipIfNull
363+
// target, it is necessary to box the primitive.
364+
CodeFlow.insertBoxIfNecessary(mv, this.originalPrimitiveExitTypeDescriptor);
365+
}
366+
mv.visitLabel(skipIfNull);
367+
}
308368
}
309369

310370
@Override
@@ -368,64 +428,64 @@ private Object accessArrayElement(Object ctx, int idx) throws SpelEvaluationExce
368428
if (arrayComponentType == boolean.class) {
369429
boolean[] array = (boolean[]) ctx;
370430
checkAccess(array.length, idx);
371-
this.exitTypeDescriptor = "Z";
431+
setExitTypeDescriptor("Z");
372432
this.arrayTypeDescriptor = "[Z";
373433
return array[idx];
374434
}
375435
else if (arrayComponentType == byte.class) {
376436
byte[] array = (byte[]) ctx;
377437
checkAccess(array.length, idx);
378-
this.exitTypeDescriptor = "B";
438+
setExitTypeDescriptor("B");
379439
this.arrayTypeDescriptor = "[B";
380440
return array[idx];
381441
}
382442
else if (arrayComponentType == char.class) {
383443
char[] array = (char[]) ctx;
384444
checkAccess(array.length, idx);
385-
this.exitTypeDescriptor = "C";
445+
setExitTypeDescriptor("C");
386446
this.arrayTypeDescriptor = "[C";
387447
return array[idx];
388448
}
389449
else if (arrayComponentType == double.class) {
390450
double[] array = (double[]) ctx;
391451
checkAccess(array.length, idx);
392-
this.exitTypeDescriptor = "D";
452+
setExitTypeDescriptor("D");
393453
this.arrayTypeDescriptor = "[D";
394454
return array[idx];
395455
}
396456
else if (arrayComponentType == float.class) {
397457
float[] array = (float[]) ctx;
398458
checkAccess(array.length, idx);
399-
this.exitTypeDescriptor = "F";
459+
setExitTypeDescriptor("F");
400460
this.arrayTypeDescriptor = "[F";
401461
return array[idx];
402462
}
403463
else if (arrayComponentType == int.class) {
404464
int[] array = (int[]) ctx;
405465
checkAccess(array.length, idx);
406-
this.exitTypeDescriptor = "I";
466+
setExitTypeDescriptor("I");
407467
this.arrayTypeDescriptor = "[I";
408468
return array[idx];
409469
}
410470
else if (arrayComponentType == long.class) {
411471
long[] array = (long[]) ctx;
412472
checkAccess(array.length, idx);
413-
this.exitTypeDescriptor = "J";
473+
setExitTypeDescriptor("J");
414474
this.arrayTypeDescriptor = "[J";
415475
return array[idx];
416476
}
417477
else if (arrayComponentType == short.class) {
418478
short[] array = (short[]) ctx;
419479
checkAccess(array.length, idx);
420-
this.exitTypeDescriptor = "S";
480+
setExitTypeDescriptor("S");
421481
this.arrayTypeDescriptor = "[S";
422482
return array[idx];
423483
}
424484
else {
425485
Object[] array = (Object[]) ctx;
426486
checkAccess(array.length, idx);
427487
Object retValue = array[idx];
428-
this.exitTypeDescriptor = CodeFlow.toDescriptor(arrayComponentType);
488+
setExitTypeDescriptor(CodeFlow.toDescriptor(arrayComponentType));
429489
this.arrayTypeDescriptor = CodeFlow.toDescriptor(array.getClass());
430490
return retValue;
431491
}
@@ -438,6 +498,19 @@ private void checkAccess(int arrayLength, int index) throws SpelEvaluationExcept
438498
}
439499
}
440500

501+
private void setExitTypeDescriptor(String descriptor) {
502+
// If this indexer would return a primitive - and yet it is also marked
503+
// null-safe - then the exit type descriptor must be promoted to the box
504+
// type to allow a null value to be passed on.
505+
if (this.nullSafe && CodeFlow.isPrimitive(descriptor)) {
506+
this.originalPrimitiveExitTypeDescriptor = descriptor;
507+
this.exitTypeDescriptor = CodeFlow.toBoxedDescriptor(descriptor);
508+
}
509+
else {
510+
this.exitTypeDescriptor = descriptor;
511+
}
512+
}
513+
441514
@SuppressWarnings("unchecked")
442515
private <T> T convertValue(TypeConverter converter, @Nullable Object value, Class<T> targetType) {
443516
T result = (T) converter.convertValue(
@@ -574,7 +647,7 @@ public TypedValue getValue() {
574647
Indexer.this.cachedReadName = this.name;
575648
Indexer.this.cachedReadTargetType = targetObjectRuntimeClass;
576649
if (accessor instanceof CompilablePropertyAccessor compilablePropertyAccessor) {
577-
Indexer.this.exitTypeDescriptor = CodeFlow.toDescriptor(compilablePropertyAccessor.getPropertyType());
650+
setExitTypeDescriptor(CodeFlow.toDescriptor(compilablePropertyAccessor.getPropertyType()));
578651
}
579652
return accessor.read(this.evaluationContext, this.targetObject, this.name);
580653
}

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

+6-5
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ private SpelNodeImpl eatNode() {
399399
@Nullable
400400
private SpelNodeImpl eatNonDottedNode() {
401401
if (peekToken(TokenKind.LSQUARE)) {
402-
if (maybeEatIndexer()) {
402+
if (maybeEatIndexer(false)) {
403403
return pop();
404404
}
405405
}
@@ -419,7 +419,8 @@ private SpelNodeImpl eatDottedNode() {
419419
Token t = takeToken(); // it was a '.' or a '?.'
420420
boolean nullSafeNavigation = (t.kind == TokenKind.SAFE_NAVI);
421421
if (maybeEatMethodOrProperty(nullSafeNavigation) || maybeEatFunctionOrVar() ||
422-
maybeEatProjection(nullSafeNavigation) || maybeEatSelection(nullSafeNavigation)) {
422+
maybeEatProjection(nullSafeNavigation) || maybeEatSelection(nullSafeNavigation) ||
423+
maybeEatIndexer(nullSafeNavigation)) {
423424
return pop();
424425
}
425426
if (peekToken() == null) {
@@ -537,7 +538,7 @@ else if (maybeEatTypeReference() || maybeEatNullReference() || maybeEatConstruct
537538
else if (maybeEatBeanReference()) {
538539
return pop();
539540
}
540-
else if (maybeEatProjection(false) || maybeEatSelection(false) || maybeEatIndexer()) {
541+
else if (maybeEatProjection(false) || maybeEatSelection(false) || maybeEatIndexer(false)) {
541542
return pop();
542543
}
543544
else if (maybeEatInlineListOrMap()) {
@@ -699,7 +700,7 @@ else if (peekToken(TokenKind.COLON, true)) { // map!
699700
return true;
700701
}
701702

702-
private boolean maybeEatIndexer() {
703+
private boolean maybeEatIndexer(boolean nullSafeNavigation) {
703704
Token t = peekToken();
704705
if (t == null || !peekToken(TokenKind.LSQUARE, true)) {
705706
return false;
@@ -709,7 +710,7 @@ private boolean maybeEatIndexer() {
709710
throw internalException(t.startPos, SpelMessage.MISSING_SELECTION_EXPRESSION);
710711
}
711712
eatToken(TokenKind.RSQUARE);
712-
this.constructedNodes.push(new Indexer(t.startPos, t.endPos, expr));
713+
this.constructedNodes.push(new Indexer(nullSafeNavigation, t.startPos, t.endPos, expr));
713714
return true;
714715
}
715716

0 commit comments

Comments
 (0)