Affects: 6.0.x


Many categories of basic SpelExpression are not compiled into a performant form due to a pattern of not acknowledging a Method can be called from superclass or interface. Best described with two simple failing tests for two scenarios. I have raised the issue as this feels like something resolvable.

ITEM1 MethodReference.isCompilable

   SpelExpressionParser parser = new SpelExpressionParser();
   StandardEvaluationContext context = new StandardEvaluationContext();

    @Test
    public void basic_expressions_are_compiled() {
        SpelExpression exp = (SpelExpression)parser.parseExpression("{2021, 2022}.contains(year)");
        assert exp.getValue(context, LocalDate.parse("2022-01-19")) == true;
        assert exp.compileExpression(); // FAIL - not compiled
    }

In this case it is the contains ... org.springframework.expression.spel.ast.MethodReference#isCompilable ... checks concrete class and fails because it is not public... public boolean java.util.Collections$UnmodifiableCollection.contains(java.lang.Object) ... even though MethodExecutor is already aware that the 'methodToInvoke' will be against an interface public abstract boolean java.util.Collection.contains(java.lang.Object)

If the execution is on the interface, the check must be too.

Note: Similar expressions on properties (not methods) compile just fine (e.g. {2021, 2022}.size)

ITEM2 OptimalPropertyAccessor.isCompilable

However OptimalPropertyAccessor doesn't seem to understand overridden methods of parent classes (not interfaces which works fine as above).

    public static class Y { public int getI() {return 1;} }
    private static class Z extends Y { public int getI() {return 2;} }

    @Test
    public void basic_expressions_are_compiled() {       
        SpelExpression exp = (SpelExpression)parser.parseExpression("i");
        Y y = new Z();
        assert exp.getValue(context, y) == 2;
        assert exp.compileExpression(); // FAIL - not compiled
    }

This is because OptimalPropertyAccessor.isCompilable() relies on Method.getDeclaringClass() which has this behaviour

public boolean isCompilable() {
    return (Modifier.isPublic(this.member.getModifiers()) &&
            Modifier.isPublic(this.member.getDeclaringClass().getModifiers()));
}

Comment From: sbrannen

Hi @drekbour,

Congratulations on submitting your first issue for the Spring Framework! 👍

And thanks for the detailed write-up.

It turns out that I recently had similar questions about our mixed use of ClassUtils.getInterfaceMethodIfPossible() and ReflectiveMethodExecutor.discoverPublicDeclaringClass() in SpEL, and I was wondering if we shouldn't rather use some sort of combination of the two.

As you pointed out, we may need to revisit MethodReference.isCompilable() and OptimalPropertyAccessor.isCompilable() as well.

In any case, we'll investigate our options.

Cheers,

Sam

Comment From: sbrannen

To simplify matters for whoever investigates this further, I've combined the two provided test cases into a single test class.

package example;

import java.lang.reflect.Modifier;
import java.util.Collections;
import java.util.List;

import org.junit.jupiter.api.Test;

import org.springframework.expression.Expression;
import org.springframework.expression.spel.ast.InlineList;
import org.springframework.expression.spel.standard.SpelCompiler;
import org.springframework.expression.spel.standard.SpelExpression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

import static org.assertj.core.api.Assertions.assertThat;

class SpelPrivateTypesTests {

    private final SpelExpressionParser parser = new SpelExpressionParser();

    private final StandardEvaluationContext context = new StandardEvaluationContext();

    private Expression expression;


    /**
     * Note that {@link InlineList} creates a list and wraps it via
     * {@link Collections#unmodifiableList(List)}, whose concrete type is private.
     */
    @Test
    void privateSubclassOverridesMethodInPublicInterface() {
        expression = parser.parseExpression("{2021, 2022}");
        List<?> inlineList = expression.getValue(List.class);
        assertThat(Modifier.isPublic(inlineList.getClass().getModifiers())).isFalse();

        expression = parser.parseExpression("{2021, 2022}.contains(2022)");
        Boolean result = expression.getValue(context, Boolean.class);
        assertThat(result).isTrue();

        assertCanCompile(expression);
        result = expression.getValue(context, Boolean.class);
        assertThat(result).isTrue();
    }

    @Test
    void privateSubclassOverridesMethodInPublicSuperclass() {
        expression = parser.parseExpression("number");
        PublicSuperclass privateSubclass = new PrivateSubclass();
        Integer result = expression.getValue(context, privateSubclass, Integer.class);
        assertThat(result).isEqualTo(2);

        assertCanCompile(expression);
        result = expression.getValue(context, privateSubclass, Integer.class);
        assertThat(result).isEqualTo(2);
    }


    private static void assertCanCompile(Expression expression) {
        assertThat(SpelCompiler.compile(expression))
                .as(() -> "Expression <%s> should be compilable"
                    .formatted(((SpelExpression) expression).toStringAST()))
                .isTrue();
    }


    public static class PublicSuperclass {

        public int getNumber() {
            return 1;
        }
    }

    private static class PrivateSubclass extends PublicSuperclass {

        @Override
        public int getNumber() {
            return 2;
        }
    }

}

Changing the expression in the first test to new java.util.ArrayList({2021, 2022}).contains(2022) allows it to pass (since ArrayList is a public type).

Making PrivateSubclass public allows the second test to pass.

Comment From: sbrannen

Current work on this issue can be viewed in the following feature branch.

https://github.com/spring-projects/spring-framework/compare/main...sbrannen:spring-framework:issues/gh-29857-SpEL-compilation-public-methods-in-private-subtypes

Comment From: sbrannen

Closed via https://github.com/spring-projects/spring-framework/commit/c79436f832d81484b263498954ce262a1215131a

Comment From: sbrannen

While working on #29847, I noticed that similar issues exist when accessing a property by indexing into an object in Indexer.

In light of that, I am reopening this issue to address those issues as well.

Comment From: sbrannen

Work on this issue has been completed in c79436f832d81484b263498954ce262a1215131a, 65d77624d1864db8b5355024cbbaa925488c295b, and f4c1ad7ae654af1eeb017341f47ae8b4b977d993.

@drekbour and anyone else interested, feel free to try out the changes in upcoming Spring Framework 6.2.0 snapshots.

If you encounter any issues, please report those back here.

Thanks,

Sam


Information on working with snapshot builds can be found here: https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-Artifacts#snapshots