Disable SpEL expression compilation by default in SimpleEvaluationContext

Prior to this commit, SpEL expression compilation could be silently
activated in a SimpleEvaluationContext via the
`spring.expression.compiler.mode` Spring/system property or
SpelParserConfiguration. Once an expression is compiled, the evaluation
guards enforced during interpreted evaluation are no longer applied,
which is at odds with the restricted intent of SimpleEvaluationContext.

To address that, this commit introduces a mechanism analogous to
isAssignmentEnabled() which disables compilation by default in
SimpleEvaluationContext. Specifically:

- A new isCompilationSupported() default method has been introduced in
  the EvaluationContext API, which returns true by default.

- SimpleEvaluationContext overrides isCompilationSupported() to return
  false by default. However, compilation can be opted into explicitly
  via the new withCompilationSupported() method in the
  SimpleEvaluationContext.Builder.

- SpelExpression.checkCompile() now consults isCompilationSupported()
  before triggering new compilation, ensuring that evaluation within an
  EvaluationContext never produces a compiled form of the expression if
  the context's isCompilationSupported() method returns false.

- All eight getValue() variants in SpelExpression now consult
  isCompilationSupported() before executing a compiled expression,
  ensuring that a compiled expression produced via a different
  EvaluationContext is not silently reused if the caller inadvertently
  switches to an EvaluationContext that does not support compilation.

Closes gh-37035
This commit is contained in:
Sam Brannen
2026-08-14 09:11:50 +02:00
committed by Brian Clozel
parent baae93f20a
commit 0d08f8dfaf
6 changed files with 299 additions and 31 deletions
@@ -21,14 +21,18 @@ import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.core.Ordered;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.SpelCompilerMode;
import org.springframework.expression.spel.SpelParserConfiguration;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.InstanceOfAssertFactories.BOOLEAN;
import static org.springframework.expression.spel.standard.SpelExpressionTestUtils.assertIsCompiled;
import static org.springframework.expression.spel.standard.SpelExpressionTestUtils.assertIsNotCompiled;
import static org.springframework.expression.spel.standard.SpelExpressionTestUtils.getInterpretedCount;
/**
* Tests for the {@link SpelCompiler}.
@@ -50,6 +54,7 @@ class SpelCompilerTests {
// Evaluate the expression multiple times to ensure that it gets compiled.
IntStream.rangeClosed(1, 5).forEach(i -> assertThat(expression.getValue(component)).isEqualTo(42));
assertIsCompiled(expression);
}
@Test // gh-25706
@@ -77,6 +82,162 @@ class SpelCompilerTests {
assertThat(expression.getValue(context)).asInstanceOf(BOOLEAN).isTrue();
}
@Test
void simpleEvaluationContextBlocksCompilationByDefault() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable,
// so any non-compilation is attributable solely to the context's policy.
Expression expression = parser.parseExpression("order");
SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
assertThat(context.isCompilationSupported()).isFalse();
// Evaluate the expression multiple times to ensure that it stays in interpreted mode,
// effectively overriding SpelCompilerMode.IMMEDIATE.
OrderedComponent component = new OrderedComponent();
IntStream.rangeClosed(1, 5).forEach(i -> assertThat(expression.getValue(context, component)).isEqualTo(42));
assertIsNotCompiled(expression);
}
@Test
void simpleEvaluationContextAllowsCompilationWhenSupported() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable.
Expression expression = parser.parseExpression("order");
SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding()
.withCompilationSupported()
.build();
assertThat(context.isCompilationSupported()).isTrue();
// Two evaluations are enough for IMMEDIATE mode to compile.
OrderedComponent component = new OrderedComponent();
IntStream.rangeClosed(1, 2).forEach(i -> assertThat(expression.getValue(context, component)).isEqualTo(42));
assertIsCompiled(expression);
}
@Test
void simpleEvaluationContextIgnoresPrecompiledExpressionByDefault() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable.
Expression expression = parser.parseExpression("order");
EvaluationContext standardContext = new StandardEvaluationContext();
assertThat(standardContext.isCompilationSupported()).isTrue();
OrderedComponent component = new OrderedComponent();
IntStream.rangeClosed(1, 2).forEach(i -> assertThat(expression.getValue(standardContext, component)).isEqualTo(42));
assertIsCompiled(expression);
// Switch to a SimpleEvaluationContext without opting into compilation — should
// fall back to interpreted evaluation even though compiledAst is non-null.
EvaluationContext simpleContext = SimpleEvaluationContext.forReadOnlyDataBinding().build();
assertThat(simpleContext.isCompilationSupported()).isFalse();
// Record interpretedCount before the simpleContext evaluation.
// checkCompile() — which increments interpretedCount as its very first action —
// is only reachable from the interpreted path. If the compiled path were taken
// instead, interpretedCount would not change.
int interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue(simpleContext, component)).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
// compiledAst is still set: the compiled expression was not cleared, rather merely ignored.
assertIsCompiled(expression);
}
/**
* Verify that the four implicit {@link EvaluationContext} {@code getValue()} variants
* in {@link SpelExpression} honor a {@link SimpleEvaluationContext} set as the default
* context: compilation must be blocked even under {@link SpelCompilerMode#IMMEDIATE}.
*/
@Test
void simpleEvaluationContextSetAsDefaultBlocksCompilationForImplicitContextVariants() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable,
// so any non-compilation is attributable solely to the context's policy.
SpelExpression expression = parser.parseRaw("order");
OrderedComponent component = new OrderedComponent();
SimpleEvaluationContext simpleContext = SimpleEvaluationContext.forReadOnlyDataBinding()
.withRootObject(component)
.build();
assertThat(simpleContext.isCompilationSupported()).isFalse();
expression.setEvaluationContext(simpleContext);
// Evaluate the expression multiple times using all four implicit context
// variants to ensure that they stay in interpreted mode.
for (int i = 0; i < 5; i++) {
assertThat(expression.getValue()).isEqualTo(42);
assertIsNotCompiled(expression);
assertThat(expression.getValue(Integer.class)).isEqualTo(42);
assertIsNotCompiled(expression);
assertThat(expression.getValue(component)).isEqualTo(42);
assertIsNotCompiled(expression);
assertThat(expression.getValue(component, Integer.class)).isEqualTo(42);
assertIsNotCompiled(expression);
}
}
/**
* Verify that the four implicit {@link EvaluationContext} {@code getValue()} variants
* in {@link SpelExpression} ignore a previously compiled expression when the default
* context is a {@link SimpleEvaluationContext} (where {@code isCompilationSupported()}
* returns {@code false}).
*/
@Test
void simpleEvaluationContextSetAsDefaultIgnoresPrecompiledExpressionForImplicitContextVariants() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
SpelExpressionParser parser = new SpelExpressionParser(config);
// "order" resides in the public Ordered interface and is therefore compilable.
SpelExpression expression = parser.parseRaw("order");
// Compile the expression via StandardEvaluationContext.
StandardEvaluationContext standardContext = new StandardEvaluationContext();
assertThat(standardContext.isCompilationSupported()).isTrue();
OrderedComponent component = new OrderedComponent();
IntStream.rangeClosed(1, 2).forEach(i ->
assertThat(expression.getValue(standardContext, component, Integer.class)).isEqualTo(42));
assertIsCompiled(expression);
// Switch to a SimpleEvaluationContext set as the default context — the precompiled
// expression should be ignored for all four implicit context getValue() variants.
SimpleEvaluationContext simpleContext = SimpleEvaluationContext.forReadOnlyDataBinding()
.withRootObject(component)
.build();
assertThat(simpleContext.isCompilationSupported()).isFalse();
expression.setEvaluationContext(simpleContext);
// Record interpretedCount before the simpleContext evaluations.
// checkCompile() — which increments interpretedCount as its very first action —
// is only reachable from the interpreted path. If the compiled path were taken
// instead, interpretedCount would not change.
int interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue()).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue(Integer.class)).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue(component)).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
interpretedCountBefore = getInterpretedCount(expression);
assertThat(expression.getValue(component, Integer.class)).isEqualTo(42);
assertThat(getInterpretedCount(expression)).isEqualTo(interpretedCountBefore + 1);
// compiledAst is still set: the compiled expression was not cleared, rather merely ignored.
assertIsCompiled(expression);
}
@Test // gh-28043
void changingRegisteredVariableTypeDoesNotResultInFailureInMixedMode() {
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.MIXED, null);
@@ -17,6 +17,7 @@
package org.springframework.expression.spel.standard;
import java.lang.reflect.Field;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.expression.Expression;
@@ -26,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests utilities for {@link SpelExpression}.
*
* @author Stephane Nicoll
* @author Sam Brannen
*/
public abstract class SpelExpressionTestUtils {
@@ -41,4 +43,33 @@ public abstract class SpelExpressionTestUtils {
}
}
public static void assertIsNotCompiled(Expression expression) {
try {
Field field = SpelExpression.class.getDeclaredField("compiledAst");
field.setAccessible(true);
Object object = field.get(expression);
assertThat(object).isNull();
}
catch (Exception ex) {
throw new AssertionError(ex.getMessage(), ex);
}
}
/**
* Return the current interpreted evaluation count for the given expression.
* <p>This counter is incremented exclusively by the interpreted evaluation path
* (inside {@code checkCompile()}), so it serves as a reliable witness for
* distinguishing interpreted from compiled evaluations in tests.
*/
public static int getInterpretedCount(Expression expression) {
try {
Field field = SpelExpression.class.getDeclaredField("interpretedCount");
field.setAccessible(true);
return ((AtomicInteger) field.get(expression)).get();
}
catch (Exception ex) {
throw new AssertionError(ex.getMessage(), ex);
}
}
}
@@ -53,6 +53,24 @@ class SimpleEvaluationContextTests {
private final Model model = new Model();
@Test
void compilationNotSupportedByDefault() {
assertThat(SimpleEvaluationContext.forReadOnlyDataBinding().build().isCompilationSupported()).isFalse();
assertThat(SimpleEvaluationContext.forReadWriteDataBinding().build().isCompilationSupported()).isFalse();
assertThat(SimpleEvaluationContext.forPropertyAccessors(DataBindingPropertyAccessor.forReadOnlyAccess())
.build().isCompilationSupported()).isFalse();
}
@Test
void compilationSupportedViaBuilder() {
assertThat(SimpleEvaluationContext.forReadOnlyDataBinding()
.withCompilationSupported().build().isCompilationSupported()).isTrue();
assertThat(SimpleEvaluationContext.forReadWriteDataBinding()
.withCompilationSupported().build().isCompilationSupported()).isTrue();
assertThat(SimpleEvaluationContext.forPropertyAccessors(DataBindingPropertyAccessor.forReadOnlyAccess())
.withCompilationSupported().build().isCompilationSupported()).isTrue();
}
@Test
void forReadWriteDataBinding() {
SimpleEvaluationContext context = SimpleEvaluationContext.forReadWriteDataBinding()