From a894818c9e351f2db61dfde0108c6df86a4a5d0e Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:54:15 +0300 Subject: [PATCH] Document lifecycle and reuse contract for SpEL expressions and contexts This commit adds a "Lifecycle and Reuse" section to the SpEL reference documentation, immediately following the security considerations introduced for gh-36997, explaining that AST nodes within a parsed Expression may cache resolved PropertyAccessor, IndexAccessor, MethodExecutor, and ConstructorExecutor instances for performance. We also now document that reusing a parsed Expression across EvaluationContext instances of the same type and with equivalent configuration is supported (even if atypical), including when accessors or resolvers registered with a context change between evaluations, but that reusing a parsed Expression across contexts with different security implications (for example, first against a StandardEvaluationContext and later against a SimpleEvaluationContext) is not supported, since cached state from a more permissive evaluation may be reused during a more restrictive one. The Javadoc for Expression, SpelExpression, EvaluationContext, StandardEvaluationContext, SimpleEvaluationContext, PropertyAccessor, IndexAccessor, MethodExecutor, and ConstructorExecutor has also been updated to make these contracts discoverable via the API as well. Closes gh-36968 --- .../pages/core/expressions/evaluation.adoc | 40 +++++++++++++++++++ .../expression/ConstructorExecutor.java | 6 +++ .../expression/EvaluationContext.java | 12 ++++++ .../expression/Expression.java | 17 ++++++++ .../expression/IndexAccessor.java | 11 +++++ .../expression/MethodExecutor.java | 6 +++ .../expression/PropertyAccessor.java | 11 +++++ .../spel/standard/SpelExpression.java | 12 ++++++ .../spel/support/SimpleEvaluationContext.java | 10 +++++ .../support/StandardEvaluationContext.java | 9 +++++ 10 files changed, 134 insertions(+) diff --git a/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc b/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc index e7bee3b66b6..f2ba820702d 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc @@ -286,6 +286,46 @@ registering property accessors, resolvers, variables, or functions – to ensure of the objects reachable via the context expose operations that would be dangerous if invoked by an expression from an untrusted source. +[[expressions-evaluation-context-lifecycle]] +=== Lifecycle and Reuse + +For performance, the AST nodes that make up a parsed `Expression` may cache the specific +`PropertyAccessor`, `IndexAccessor`, `MethodExecutor`, or `ConstructorExecutor` that +satisfied a previous evaluation, so that later evaluations of the same node can avoid +asking every registered accessor or resolver in turn. Understanding this caching behavior +is essential to using `Expression` and `EvaluationContext` correctly, in addition to the +<> discussed previously. + +A parsed `Expression` is designed to be created once and evaluated repeatedly, and doing +so is both supported and encouraged. In particular: + +* A parsed `Expression` may be evaluated against different root objects, and against + different `EvaluationContext` instances of the *same type and with equivalent + configuration* – for example, several `StandardEvaluationContext` instances each + registering the same kind of custom `PropertyAccessor`. Changing the accessors or + resolvers registered with a context between evaluations of the same expression is + atypical and generally not advised, but is expected to work correctly: the registered + state of the *current* context is what is consulted, not a snapshot taken during an + earlier evaluation. +* A parsed `Expression` must *not* be evaluated first against a context with one set of + security implications and later against a context with different, typically more + restrictive, security implications – for example, first against a + `StandardEvaluationContext` and later against a `SimpleEvaluationContext`. Doing so is + analogous to executing a database query on behalf of an administrator, caching the + resulting administrator-privileged execution plan, and then reusing that cached plan for + a lower-privileged user while expecting the lower-privileged user's restrictions to + apply: cached state from the first, more permissive evaluation may be reused during the + second, and the second context's restrictions cannot be reliably enforced as a result. + If the same expression string must be evaluated under contexts with different security + implications, parse it into *distinct* `Expression` instances, one per context. + +[WARNING] +==== +Reusing a single parsed `Expression` across `EvaluationContext` instances with different +security implications is not a supported usage pattern and must be avoided, regardless of +which `EvaluationContext` implementations are involved. +==== + [[expressions-type-conversion]] === Type Conversion diff --git a/spring-expression/src/main/java/org/springframework/expression/ConstructorExecutor.java b/spring-expression/src/main/java/org/springframework/expression/ConstructorExecutor.java index ec5a8c7d71b..07959d51c4a 100644 --- a/spring-expression/src/main/java/org/springframework/expression/ConstructorExecutor.java +++ b/spring-expression/src/main/java/org/springframework/expression/ConstructorExecutor.java @@ -33,6 +33,12 @@ import org.jspecify.annotations.Nullable; * {@link AccessException} which signals to the infrastructure to go back to the * resolvers to ask for a new one. * + *

A cached {@code ConstructorExecutor} is valid only for the + * {@link EvaluationContext} configuration — in particular, the registered + * {@link ConstructorResolver ConstructorResolvers} — under which it was resolved. + * See {@link Expression} for the resulting contract on reusing a parsed expression + * across different {@code EvaluationContext} instances. + * * @author Andy Clement * @author Sam Brannen * @since 3.0 diff --git a/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java index 3e807bdac53..e6406a111be 100644 --- a/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java @@ -56,6 +56,18 @@ import org.jspecify.annotations.Nullable; * those reachable objects expose operations that would be dangerous if invoked * by an expression from an untrusted source. * + *

An {@code EvaluationContext} is designed to be built once and reused across + * many evaluations, potentially of many different {@link Expression} instances. A + * single {@link Expression}, in turn, may cache accessor and executor state resolved + * against a particular {@code EvaluationContext} configuration. Reusing an + * {@code Expression} across {@code EvaluationContext} instances of the same type and + * with equivalent configuration is supported; reusing an {@code Expression} across + * contexts with different security implications — for example, first against a + * {@code StandardEvaluationContext} and later against a + * {@code SimpleEvaluationContext} — is not, since cached state from the more + * permissive evaluation may be reused during the more restrictive one. See + * {@link Expression} for details. + * * @author Andy Clement * @author Juergen Hoeller * @author Sam Brannen diff --git a/spring-expression/src/main/java/org/springframework/expression/Expression.java b/spring-expression/src/main/java/org/springframework/expression/Expression.java index c6cb98baa9d..b9a8ce10971 100644 --- a/spring-expression/src/main/java/org/springframework/expression/Expression.java +++ b/spring-expression/src/main/java/org/springframework/expression/Expression.java @@ -27,6 +27,23 @@ import org.springframework.core.convert.TypeDescriptor; * *

Provides a common abstraction for expression evaluation. * + *

An {@code Expression} is intended to be parsed once and evaluated repeatedly, + * potentially against different root objects and different {@link EvaluationContext} + * instances. For performance, an {@code Expression} implementation may internally + * cache the specific accessor or executor — for example, a + * {@code PropertyAccessor}, {@code IndexAccessor}, {@code MethodExecutor}, or + * {@code ConstructorExecutor} — that satisfied a previous evaluation, in order + * to avoid repeatedly asking every candidate registered with the context. Reusing a + * parsed {@code Expression} across {@code EvaluationContext} instances of the same + * type and with equivalent configuration is supported; however, reusing a parsed + * {@code Expression} across contexts with different security implications — for + * example, first against a {@code StandardEvaluationContext} and later against a + * {@code SimpleEvaluationContext} — is not supported, since cached state from a + * more permissive evaluation may be reused during a subsequent, more restrictive + * evaluation. If the same expression string must be evaluated under contexts with + * different security implications, parse it into distinct {@code Expression} + * instances, one per context. + * * @author Keith Donald * @author Andy Clement * @author Juergen Hoeller diff --git a/spring-expression/src/main/java/org/springframework/expression/IndexAccessor.java b/spring-expression/src/main/java/org/springframework/expression/IndexAccessor.java index da6c0252fe3..a0db10d051a 100644 --- a/spring-expression/src/main/java/org/springframework/expression/IndexAccessor.java +++ b/spring-expression/src/main/java/org/springframework/expression/IndexAccessor.java @@ -30,6 +30,17 @@ import org.jspecify.annotations.Nullable; * {@linkplain #getSpecificTargetClasses() target classes} for which it should be * called. See {@link TargetedAccessor} for details. * + *

SpEL infrastructure may cache an {@code IndexAccessor} instance on a per-node + * basis once it has successfully served a read or write for a given index and target + * type, in order to avoid repeatedly invoking {@link #canRead}/{@link #canWrite} on + * every registered accessor for subsequent evaluations of that node. Before reusing a + * cached instance, SpEL confirms that it is still registered with the current + * {@link EvaluationContext}. Consequently, {@link #canRead}, {@link #canWrite}, + * {@link #read}, and {@link #write} should behave consistently for a given + * {@code (context, target type, index)} combination for as long as the accessor remains + * registered with a context, since a change in behavior may not be observed until the + * accessor is re-selected from scratch. + * * @author Jackmiking Lee * @author Sam Brannen * @since 6.2 diff --git a/spring-expression/src/main/java/org/springframework/expression/MethodExecutor.java b/spring-expression/src/main/java/org/springframework/expression/MethodExecutor.java index 7c804dfbf8f..2a3919864cb 100644 --- a/spring-expression/src/main/java/org/springframework/expression/MethodExecutor.java +++ b/spring-expression/src/main/java/org/springframework/expression/MethodExecutor.java @@ -32,6 +32,12 @@ import org.jspecify.annotations.Nullable; * {@link AccessException} which signals to the infrastructure to go back to the * resolvers to ask for a new one. * + *

A cached {@code MethodExecutor} is valid only for the {@link EvaluationContext} + * configuration — in particular, the registered {@link MethodResolver + * MethodResolvers} — under which it was resolved. See {@link Expression} for the + * resulting contract on reusing a parsed expression across different + * {@code EvaluationContext} instances. + * * @author Andy Clement * @author Sam Brannen * @since 3.0 diff --git a/spring-expression/src/main/java/org/springframework/expression/PropertyAccessor.java b/spring-expression/src/main/java/org/springframework/expression/PropertyAccessor.java index c23252b31b4..aacbcd90f3a 100644 --- a/spring-expression/src/main/java/org/springframework/expression/PropertyAccessor.java +++ b/spring-expression/src/main/java/org/springframework/expression/PropertyAccessor.java @@ -30,6 +30,17 @@ import org.jspecify.annotations.Nullable; * {@linkplain #getSpecificTargetClasses() target classes} for which it should be * called. See {@link TargetedAccessor} for details. * + *

SpEL infrastructure may cache a {@code PropertyAccessor} instance on a per-node + * basis once it has successfully served a read or write for a given property name and + * target type, in order to avoid repeatedly invoking {@link #canRead}/{@link #canWrite} + * on every registered accessor for subsequent evaluations of that node. Before reusing + * a cached instance, SpEL confirms that it is still registered with the current + * {@link EvaluationContext}. Consequently, {@link #canRead}, {@link #canWrite}, + * {@link #read}, and {@link #write} should behave consistently for a given + * {@code (context, target type, name)} combination for as long as the accessor remains + * registered with a context, since a change in behavior may not be observed until the + * accessor is re-selected from scratch. + * * @author Andy Clement * @since 3.0 * @see TargetedAccessor diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelExpression.java b/spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelExpression.java index 4506b507b55..87133da0779 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelExpression.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelExpression.java @@ -43,6 +43,18 @@ import org.springframework.util.Assert; * specified context. During expression evaluation the context may be asked to resolve * references to types, beans, properties, and methods. * + *

The individual nodes of the parsed AST may cache the specific + * {@link org.springframework.expression.PropertyAccessor PropertyAccessor}, + * {@link org.springframework.expression.IndexAccessor IndexAccessor}, + * {@link org.springframework.expression.MethodExecutor MethodExecutor}, or + * {@link org.springframework.expression.ConstructorExecutor ConstructorExecutor} that + * satisfied a previous evaluation of that node. On a subsequent evaluation, the current + * {@code EvaluationContext} is consulted to confirm that the cached accessor or executor + * (or, in some cases, the resolver that produced it) is still applicable before it is + * reused; if it is not, resolution is performed again from scratch. See {@link Expression} + * for the resulting contract on reusing a {@code SpelExpression} across different + * {@code EvaluationContext} instances. + * * @author Andy Clement * @author Juergen Hoeller * @author Sam Brannen diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/SimpleEvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/SimpleEvaluationContext.java index 1e6931daa4c..e7023eabf0f 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/SimpleEvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/SimpleEvaluationContext.java @@ -105,6 +105,16 @@ import org.springframework.expression.spel.SpelMessage; * untrusted source. For details on what qualifies as a "trusted" source, see * {@link StandardEvaluationContext}. * + *

Because a parsed {@code Expression} may cache accessor and executor state + * resolved against a particular {@code EvaluationContext} configuration, a parsed + * {@code Expression} must never be evaluated first against a + * {@code StandardEvaluationContext} (or any other, less restrictive, + * {@code EvaluationContext}) and later against a {@code SimpleEvaluationContext}. If the + * same expression string must be evaluated under both kinds of contexts, parse it into + * two distinct {@code Expression} instances instead. See + * {@link org.springframework.expression.Expression Expression} for further details on + * this lifecycle contract. + * * @author Rossen Stoyanchev * @author Juergen Hoeller * @author Sam Brannen diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java index 01155813b3e..469ce92df03 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java @@ -80,6 +80,15 @@ import org.springframework.util.Assert; * regardless of the {@code EvaluationContext} implementation in use; see * {@link SimpleEvaluationContext} for further details. * + *

Because a parsed {@code Expression} may cache accessor and executor state + * resolved against a particular {@code EvaluationContext} configuration, a parsed + * {@code Expression} must never be evaluated first against a + * {@code StandardEvaluationContext} and later against a {@code SimpleEvaluationContext} + * (or any other, more restrictive {@code EvaluationContext}). If the same expression + * string must be evaluated under both kinds of contexts, parse it into two distinct + * {@code Expression} instances instead. See {@link org.springframework.expression.Expression + * Expression} for further details on this lifecycle contract. + * * @author Andy Clement * @author Juergen Hoeller * @author Sam Brannen