2323import java .util .ArrayList ;
2424import java .util .Collections ;
2525import java .util .List ;
26+ import java .util .Optional ;
2627import java .util .StringJoiner ;
2728
2829import org .jspecify .annotations .Nullable ;
5051 * Expression language AST node that represents a method reference (i.e., a
5152 * method invocation other than a simple property reference).
5253 *
54+ * <h3>Null-safe Invocation</h3>
55+ *
56+ * <p>Null-safe invocation is supported via the {@code '?.'} operator. For example,
57+ * {@code 'counter?.incrementBy(1)'} will evaluate to {@code null} if {@code counter}
58+ * is {@code null} and will otherwise evaluate to the value returned from the
59+ * invocation of {@code counter.incrementBy(1)}. As of Spring Framework 7.0,
60+ * null-safe invocation also applies when invoking a method on an {@link Optional}
61+ * target. For example, if {@code counter} is of type {@code Optional<Counter>},
62+ * the expression {@code 'counter?.incrementBy(1)'} will evaluate to {@code null}
63+ * if {@code counter} is {@code null} or {@link Optional#isEmpty() empty} and will
64+ * otherwise evaluate the value returned from the invocation of
65+ * {@code counter.get().incrementBy(1)}.
66+ *
5367 * @author Andy Clement
5468 * @author Juergen Hoeller
5569 * @author Sam Brannen
@@ -93,7 +107,9 @@ public final String getName() {
93107 protected ValueRef getValueRef (ExpressionState state ) throws EvaluationException {
94108 @ Nullable Object [] arguments = getArguments (state );
95109 if (state .getActiveContextObject ().getValue () == null ) {
96- throwIfNotNullSafe (getArgumentTypes (arguments ));
110+ if (!isNullSafe ()) {
111+ throw nullTargetException (getArgumentTypes (arguments ));
112+ }
97113 return ValueRef .NullValueRef .INSTANCE ;
98114 }
99115 return new MethodValueRef (state , arguments );
@@ -115,9 +131,26 @@ private TypedValue getValueInternal(EvaluationContext evaluationContext, @Nullab
115131 @ Nullable TypeDescriptor targetType , @ Nullable Object [] arguments ) {
116132
117133 List <TypeDescriptor > argumentTypes = getArgumentTypes (arguments );
134+ Optional <?> fallbackOptionalTarget = null ;
135+ boolean isEmptyOptional = false ;
136+
137+ if (isNullSafe ()) {
138+ if (target == null ) {
139+ return TypedValue .NULL ;
140+ }
141+ if (target instanceof Optional <?> optional ) {
142+ if (optional .isPresent ()) {
143+ target = optional .get ();
144+ fallbackOptionalTarget = optional ;
145+ }
146+ else {
147+ isEmptyOptional = true ;
148+ }
149+ }
150+ }
151+
118152 if (target == null ) {
119- throwIfNotNullSafe (argumentTypes );
120- return TypedValue .NULL ;
153+ throw nullTargetException (argumentTypes );
121154 }
122155
123156 MethodExecutor executorToUse = getCachedExecutor (evaluationContext , target , targetType , argumentTypes );
@@ -142,31 +175,64 @@ private TypedValue getValueInternal(EvaluationContext evaluationContext, @Nullab
142175 // At this point we know it wasn't a user problem so worth a retry if a
143176 // better candidate can be found.
144177 this .cachedExecutor = null ;
178+ executorToUse = null ;
179+ }
180+ }
181+
182+ // Either there was no cached executor, or it no longer exists.
183+
184+ // First, attempt to find the method on the target object.
185+ Object targetToUse = target ;
186+ MethodExecutorSearchResult searchResult = findMethodExecutor (argumentTypes , target , evaluationContext );
187+ if (searchResult .methodExecutor != null ) {
188+ executorToUse = searchResult .methodExecutor ;
189+ }
190+ // Second, attempt to find the method on the original Optional instance.
191+ else if (fallbackOptionalTarget != null ) {
192+ searchResult = findMethodExecutor (argumentTypes , fallbackOptionalTarget , evaluationContext );
193+ if (searchResult .methodExecutor != null ) {
194+ executorToUse = searchResult .methodExecutor ;
195+ targetToUse = fallbackOptionalTarget ;
196+ }
197+ }
198+ // If we got this far, that means we failed to find an executor for both the
199+ // target and the fallback target. So, we return NULL if the original target
200+ // is a null-safe empty Optional.
201+ else if (isEmptyOptional ) {
202+ return TypedValue .NULL ;
203+ }
204+
205+ if (executorToUse == null ) {
206+ String method = FormatHelper .formatMethodForMessage (this .name , argumentTypes );
207+ String className = FormatHelper .formatClassNameForMessage (
208+ target instanceof Class <?> clazz ? clazz : target .getClass ());
209+ if (searchResult .accessException != null ) {
210+ throw new SpelEvaluationException (
211+ getStartPosition (), searchResult .accessException , SpelMessage .PROBLEM_LOCATING_METHOD , method , className );
212+ }
213+ else {
214+ throw new SpelEvaluationException (getStartPosition (), SpelMessage .METHOD_NOT_FOUND , method , className );
145215 }
146216 }
147217
148- // either there was no accessor or it no longer existed
149- executorToUse = findMethodExecutor (argumentTypes , target , evaluationContext );
150218 this .cachedExecutor = new CachedMethodExecutor (
151- executorToUse , (target instanceof Class <?> clazz ? clazz : null ), targetType , argumentTypes );
219+ executorToUse , (targetToUse instanceof Class <?> clazz ? clazz : null ), targetType , argumentTypes );
152220 try {
153- return executorToUse .execute (evaluationContext , target , arguments );
221+ return executorToUse .execute (evaluationContext , targetToUse , arguments );
154222 }
155223 catch (AccessException ex ) {
156224 // Same unwrapping exception handling as in above catch block
157- throwSimpleExceptionIfPossible (target , ex );
225+ throwSimpleExceptionIfPossible (targetToUse , ex );
158226 throw new SpelEvaluationException (getStartPosition (), ex ,
159227 SpelMessage .EXCEPTION_DURING_METHOD_INVOCATION , this .name ,
160- target .getClass ().getName (), ex .getMessage ());
228+ targetToUse .getClass ().getName (), ex .getMessage ());
161229 }
162230 }
163231
164- private void throwIfNotNullSafe (List <TypeDescriptor > argumentTypes ) {
165- if (!isNullSafe ()) {
166- throw new SpelEvaluationException (getStartPosition (),
167- SpelMessage .METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED ,
168- FormatHelper .formatMethodForMessage (this .name , argumentTypes ));
169- }
232+ private SpelEvaluationException nullTargetException (List <TypeDescriptor > argumentTypes ) {
233+ return new SpelEvaluationException (getStartPosition (),
234+ SpelMessage .METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED ,
235+ FormatHelper .formatMethodForMessage (this .name , argumentTypes ));
170236 }
171237
172238 private @ Nullable Object [] getArguments (ExpressionState state ) {
@@ -209,7 +275,7 @@ private List<TypeDescriptor> getArgumentTypes(@Nullable Object... arguments) {
209275 return null ;
210276 }
211277
212- private MethodExecutor findMethodExecutor (List <TypeDescriptor > argumentTypes , Object target ,
278+ private MethodExecutorSearchResult findMethodExecutor (List <TypeDescriptor > argumentTypes , Object target ,
213279 EvaluationContext evaluationContext ) throws SpelEvaluationException {
214280
215281 AccessException accessException = null ;
@@ -218,7 +284,7 @@ private MethodExecutor findMethodExecutor(List<TypeDescriptor> argumentTypes, Ob
218284 MethodExecutor methodExecutor = methodResolver .resolve (
219285 evaluationContext , target , this .name , argumentTypes );
220286 if (methodExecutor != null ) {
221- return methodExecutor ;
287+ return new MethodExecutorSearchResult ( methodExecutor , null ) ;
222288 }
223289 }
224290 catch (AccessException ex ) {
@@ -227,16 +293,7 @@ private MethodExecutor findMethodExecutor(List<TypeDescriptor> argumentTypes, Ob
227293 }
228294 }
229295
230- String method = FormatHelper .formatMethodForMessage (this .name , argumentTypes );
231- String className = FormatHelper .formatClassNameForMessage (
232- target instanceof Class <?> clazz ? clazz : target .getClass ());
233- if (accessException != null ) {
234- throw new SpelEvaluationException (
235- getStartPosition (), accessException , SpelMessage .PROBLEM_LOCATING_METHOD , method , className );
236- }
237- else {
238- throw new SpelEvaluationException (getStartPosition (), SpelMessage .METHOD_NOT_FOUND , method , className );
239- }
296+ return new MethodExecutorSearchResult (null , accessException );
240297 }
241298
242299 /**
@@ -411,6 +468,9 @@ public boolean isWritable() {
411468 }
412469
413470
471+ private record MethodExecutorSearchResult (@ Nullable MethodExecutor methodExecutor , @ Nullable AccessException accessException ) {
472+ }
473+
414474 private record CachedMethodExecutor (MethodExecutor methodExecutor , @ Nullable Class <?> staticClass ,
415475 @ Nullable TypeDescriptor targetType , List <TypeDescriptor > argumentTypes ) {
416476
0 commit comments