Do not reuse cached ConstructorExecutor without ConstructorResolvers

Prior to this commit, a SpEL ConstructorReference could reuse a cached
ConstructorExecutor even when the current EvaluationContext no longer
had any registered ConstructorResolvers, leading to inconsistent
behavior if the EvaluationContext changes between evaluations of the
same expression.

This commit aligns ConstructorReference with the analogous logic in
PropertyOrFieldReference by discarding the cached ConstructorExecutor
whenever there are no ConstructorResolvers registered in the current
EvaluationContext, ensuring that constructor resolution consistently
fails with a CONSTRUCTOR_NOT_FOUND exception in that scenario.

Closes gh-36985
This commit is contained in:
Sam Brannen
2026-07-25 16:05:23 +03:00
parent e255ccca7d
commit 4f086322d0
2 changed files with 64 additions and 29 deletions
@@ -19,6 +19,7 @@ package org.springframework.expression.spel;
import java.util.ArrayList;
import java.util.List;
import org.assertj.core.api.ThrowableTypeAssert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -32,6 +33,7 @@ import org.springframework.expression.spel.testresources.PlaceOfBirth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests invocation of constructors.
@@ -142,6 +144,28 @@ class ConstructorInvocationTests extends AbstractExpressionTests {
assertThat(ctx.getConstructorResolvers()).hasSize(2);
}
@Test // gh-36985
void cachedConstructorExecutorIsNotUsedWithoutRegisteredConstructorResolvers() {
((StandardTypeLocator) super.context.getTypeLocator()).registerImport(Fruit.class.getPackageName());
// reflective constructor accessor is the only one by default
assertThat(super.context.getConstructorResolvers()).hasSize(1);
String expression = "new Fruit('apple', T(java.awt.Color).RED, 'red').name";
Expression expr = parser.parseExpression(expression);
assertThat(expr.getValue(super.context)).isEqualTo("apple");
// Ensure the same expression can be evaluated again.
assertThat(expr.getValue(super.context)).isEqualTo("apple");
super.context.setConstructorResolvers(List.of());
assertThat(super.context.getConstructorResolvers()).isEmpty();
// Evaluation of the same expression should no longer work.
assertThatSpelEvaluationException()
.isThrownBy(() -> expr.getValue(super.context))
.extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.CONSTRUCTOR_NOT_FOUND);
}
@Test
void varargsConstructors() {
((StandardTypeLocator) super.context.getTypeLocator()).registerImport(Fruit.class.getPackageName());
@@ -184,6 +208,10 @@ class ConstructorInvocationTests extends AbstractExpressionTests {
evaluate("new String(3.0d)", "3.0", String.class);
}
private ThrowableTypeAssert<SpelEvaluationException> assertThatSpelEvaluationException() {
return assertThatExceptionOfType(SpelEvaluationException.class);
}
@SuppressWarnings("serial")
static class TestException extends Exception {