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 0c393a86dbe6..5bb6c46e0d65 100644 --- a/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -132,4 +132,18 @@ default TypedValue assignVariable(String name, Supplier valueSupplie @Nullable Object lookupVariable(String name); + /** + * Determine if assignment is enabled within expressions evaluated by this evaluation + * context. + *

If this method returns {@code false}, the assignment ({@code =}), increment + * ({@code ++}), and decrement ({@code --}) operators are disabled. + *

By default, this method returns {@code true}. Concrete implementations may override + * this default method to disable assignment. + * @return {@code true} if assignment is enabled; {@code false} otherwise + * @since 5.3.38 + */ + default boolean isAssignmentEnabled() { + return true; + } + } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Assign.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Assign.java index 55e5d2e4ff08..1b47ead1607f 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Assign.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Assign.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,8 @@ import org.springframework.expression.EvaluationException; import org.springframework.expression.TypedValue; import org.springframework.expression.spel.ExpressionState; +import org.springframework.expression.spel.SpelEvaluationException; +import org.springframework.expression.spel.SpelMessage; /** * Represents assignment. An alternative to calling {@code setValue} @@ -39,6 +41,9 @@ public Assign(int startPos, int endPos, SpelNodeImpl... operands) { @Override public TypedValue getValueInternal(ExpressionState state) throws EvaluationException { + if (!state.getEvaluationContext().isAssignmentEnabled()) { + throw new SpelEvaluationException(getStartPosition(), SpelMessage.NOT_ASSIGNABLE, toStringAST()); + } return this.children[0].setValueInternal(state, () -> this.children[1].getValueInternal(state)); } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpDec.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpDec.java index d61e8d641062..ce15fdc6072b 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpDec.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpDec.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,6 +51,10 @@ public OpDec(int startPos, int endPos, boolean postfix, SpelNodeImpl... operands @Override public TypedValue getValueInternal(ExpressionState state) throws EvaluationException { + if (!state.getEvaluationContext().isAssignmentEnabled()) { + throw new SpelEvaluationException(getStartPosition(), SpelMessage.OPERAND_NOT_DECREMENTABLE, toStringAST()); + } + SpelNodeImpl operand = getLeftOperand(); // The operand is going to be read and then assigned to, we don't want to evaluate it twice. diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpInc.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpInc.java index f6dc184c0f5e..59640a1858a8 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpInc.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpInc.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,6 +51,10 @@ public OpInc(int startPos, int endPos, boolean postfix, SpelNodeImpl... operands @Override public TypedValue getValueInternal(ExpressionState state) throws EvaluationException { + if (!state.getEvaluationContext().isAssignmentEnabled()) { + throw new SpelEvaluationException(getStartPosition(), SpelMessage.OPERAND_NOT_INCREMENTABLE, toStringAST()); + } + SpelNodeImpl operand = getLeftOperand(); ValueRef valueRef = operand.getValueRef(state); @@ -104,7 +108,7 @@ else if (op1 instanceof Byte) { } } - // set the name value + // set the new value try { valueRef.setValue(newValue.getValue()); } 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 1168c9c91a26..8e0f11be50f4 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,25 +51,25 @@ * SpEL language syntax, e.g. excluding references to Java types, constructors, * and bean references. * - *

When creating a {@code SimpleEvaluationContext} you need to choose the - * level of support that you need for property access in SpEL expressions: + *

When creating a {@code SimpleEvaluationContext} you need to choose the level of + * support that you need for data binding in SpEL expressions: *

* - *

Conveniently, {@link SimpleEvaluationContext#forReadOnlyDataBinding()} - * enables read access to properties via {@link DataBindingPropertyAccessor}; - * same for {@link SimpleEvaluationContext#forReadWriteDataBinding()} when - * write access is needed as well. Alternatively, configure custom accessors - * via {@link SimpleEvaluationContext#forPropertyAccessors}, and potentially - * activate method resolution and/or a type converter through the builder. + *

Conveniently, {@link SimpleEvaluationContext#forReadOnlyDataBinding()} enables + * read-only access to properties via {@link DataBindingPropertyAccessor}. Similarly, + * {@link SimpleEvaluationContext#forReadWriteDataBinding()} enables read and write access + * to properties. Alternatively, configure custom accessors via + * {@link SimpleEvaluationContext#forPropertyAccessors} and potentially activate method + * resolution and/or a type converter through the builder. * *

Note that {@code SimpleEvaluationContext} is typically not configured * with a default root object. Instead it is meant to be created once and - * used repeatedly through {@code getValue} calls on a pre-compiled + * used repeatedly through {@code getValue} calls on a predefined * {@link org.springframework.expression.Expression} with both an * {@code EvaluationContext} and a root object as arguments: * {@link org.springframework.expression.Expression#getValue(EvaluationContext, Object)}. @@ -81,9 +81,9 @@ * @author Juergen Hoeller * @author Sam Brannen * @since 4.3.15 - * @see #forPropertyAccessors * @see #forReadOnlyDataBinding() * @see #forReadWriteDataBinding() + * @see #forPropertyAccessors * @see StandardEvaluationContext * @see StandardTypeConverter * @see DataBindingPropertyAccessor @@ -109,14 +109,17 @@ public final class SimpleEvaluationContext implements EvaluationContext { private final Map variables = new HashMap<>(); + private final boolean assignmentEnabled; + private SimpleEvaluationContext(List accessors, List resolvers, - @Nullable TypeConverter converter, @Nullable TypedValue rootObject) { + @Nullable TypeConverter converter, @Nullable TypedValue rootObject, boolean assignmentEnabled) { this.propertyAccessors = accessors; this.methodResolvers = resolvers; this.typeConverter = (converter != null ? converter : new StandardTypeConverter()); this.rootObject = (rootObject != null ? rootObject : TypedValue.NULL); + this.assignmentEnabled = assignmentEnabled; } @@ -224,15 +227,33 @@ public Object lookupVariable(String name) { return this.variables.get(name); } + /** + * Determine if assignment is enabled within expressions evaluated by this evaluation + * context. + *

If this method returns {@code false}, the assignment ({@code =}), increment + * ({@code ++}), and decrement ({@code --}) operators are disabled. + * @return {@code true} if assignment is enabled; {@code false} otherwise + * @since 5.3.38 + * @see #forPropertyAccessors(PropertyAccessor...) + * @see #forReadOnlyDataBinding() + * @see #forReadWriteDataBinding() + */ + @Override + public boolean isAssignmentEnabled() { + return this.assignmentEnabled; + } /** * Create a {@code SimpleEvaluationContext} for the specified {@link PropertyAccessor} * delegates: typically a custom {@code PropertyAccessor} specific to a use case * (e.g. attribute resolution in a custom data structure), potentially combined with * a {@link DataBindingPropertyAccessor} if property dereferences are needed as well. + *

Assignment is enabled within expressions evaluated by the context created via + * this factory method. * @param accessors the accessor delegates to use * @see DataBindingPropertyAccessor#forReadOnlyAccess() * @see DataBindingPropertyAccessor#forReadWriteAccess() + * @see #isAssignmentEnabled() */ public static Builder forPropertyAccessors(PropertyAccessor... accessors) { for (PropertyAccessor accessor : accessors) { @@ -241,34 +262,40 @@ public static Builder forPropertyAccessors(PropertyAccessor... accessors) { "ReflectivePropertyAccessor. Consider using DataBindingPropertyAccessor or a custom subclass."); } } - return new Builder(accessors); + return new Builder(true, accessors); } /** * Create a {@code SimpleEvaluationContext} for read-only access to * public properties via {@link DataBindingPropertyAccessor}. + *

Assignment is disabled within expressions evaluated by the context created via + * this factory method. * @see DataBindingPropertyAccessor#forReadOnlyAccess() * @see #forPropertyAccessors + * @see #isAssignmentEnabled() */ public static Builder forReadOnlyDataBinding() { - return new Builder(DataBindingPropertyAccessor.forReadOnlyAccess()); + return new Builder(false, DataBindingPropertyAccessor.forReadOnlyAccess()); } /** * Create a {@code SimpleEvaluationContext} for read-write access to * public properties via {@link DataBindingPropertyAccessor}. + *

Assignment is enabled within expressions evaluated by the context created via + * this factory method. * @see DataBindingPropertyAccessor#forReadWriteAccess() * @see #forPropertyAccessors + * @see #isAssignmentEnabled() */ public static Builder forReadWriteDataBinding() { - return new Builder(DataBindingPropertyAccessor.forReadWriteAccess()); + return new Builder(true, DataBindingPropertyAccessor.forReadWriteAccess()); } /** * Builder for {@code SimpleEvaluationContext}. */ - public static class Builder { + public static final class Builder { private final List accessors; @@ -280,10 +307,15 @@ public static class Builder { @Nullable private TypedValue rootObject; - public Builder(PropertyAccessor... accessors) { + private final boolean assignmentEnabled; + + + private Builder(boolean assignmentEnabled, PropertyAccessor... accessors) { + this.assignmentEnabled = assignmentEnabled; this.accessors = Arrays.asList(accessors); } + /** * Register the specified {@link MethodResolver} delegates for * a combination of property access and method resolution. @@ -362,7 +394,8 @@ public Builder withTypedRootObject(Object rootObject, TypeDescriptor typeDescrip } public SimpleEvaluationContext build() { - return new SimpleEvaluationContext(this.accessors, this.resolvers, this.typeConverter, this.rootObject); + return new SimpleEvaluationContext(this.accessors, this.resolvers, this.typeConverter, this.rootObject, + this.assignmentEnabled); } } diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/CompilableMapAccessor.java b/spring-expression/src/test/java/org/springframework/expression/spel/CompilableMapAccessor.java new file mode 100644 index 000000000000..0d065f5bb298 --- /dev/null +++ b/spring-expression/src/test/java/org/springframework/expression/spel/CompilableMapAccessor.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.expression.spel; + +import java.util.Map; + +import org.springframework.asm.MethodVisitor; +import org.springframework.expression.AccessException; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.TypedValue; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * This is a local COPY of {@link org.springframework.context.expression.MapAccessor}. + * + * @author Juergen Hoeller + * @author Andy Clement + * @since 4.1 + */ +public class CompilableMapAccessor implements CompilablePropertyAccessor { + + @Override + public Class[] getSpecificTargetClasses() { + return new Class[] {Map.class}; + } + + @Override + public boolean canRead(EvaluationContext context, @Nullable Object target, String name) throws AccessException { + return (target instanceof Map map && map.containsKey(name)); + } + + @Override + public TypedValue read(EvaluationContext context, @Nullable Object target, String name) throws AccessException { + Assert.state(target instanceof Map, "Target must be of type Map"); + Map map = (Map) target; + Object value = map.get(name); + if (value == null && !map.containsKey(name)) { + throw new MapAccessException(name); + } + return new TypedValue(value); + } + + @Override + public boolean canWrite(EvaluationContext context, @Nullable Object target, String name) throws AccessException { + return true; + } + + @Override + @SuppressWarnings("unchecked") + public void write(EvaluationContext context, @Nullable Object target, String name, @Nullable Object newValue) + throws AccessException { + + Assert.state(target instanceof Map, "Target must be a Map"); + Map map = (Map) target; + map.put(name, newValue); + } + + @Override + public boolean isCompilable() { + return true; + } + + @Override + public Class getPropertyType() { + return Object.class; + } + + @Override + public void generateCode(String propertyName, MethodVisitor mv, CodeFlow cf) { + String descriptor = cf.lastDescriptor(); + if (descriptor == null || !descriptor.equals("Ljava/util/Map")) { + if (descriptor == null) { + cf.loadTarget(mv); + } + CodeFlow.insertCheckCast(mv, "Ljava/util/Map"); + } + mv.visitLdcInsn(propertyName); + mv.visitMethodInsn(INVOKEINTERFACE, "java/util/Map", "get","(Ljava/lang/Object;)Ljava/lang/Object;",true); + } + + + /** + * Exception thrown from {@code read} in order to reset a cached + * PropertyAccessor, allowing other accessors to have a try. + */ + @SuppressWarnings("serial") + private static class MapAccessException extends AccessException { + + private final String key; + + public MapAccessException(String key) { + super(""); + this.key = key; + } + + @Override + public String getMessage() { + return "Map does not contain a value for key '" + this.key + "'"; + } + } + +} diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/PropertyAccessTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/PropertyAccessTests.java index 69f67c32528e..680c0dd63c8c 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/PropertyAccessTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/PropertyAccessTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -188,11 +188,11 @@ void propertyReadOnly() { assertThatSpelEvaluationException() .isThrownBy(() -> parser.parseExpression("name='p3'").getValue(context, target)) - .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE); + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.NOT_ASSIGNABLE); assertThatSpelEvaluationException() .isThrownBy(() -> parser.parseExpression("['name']='p4'").getValue(context, target)) - .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE); + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.NOT_ASSIGNABLE); } @Test @@ -207,7 +207,7 @@ void propertyReadOnlyWithRecordStyle() { assertThatSpelEvaluationException() .isThrownBy(() -> parser.parseExpression("name='p3'").getValue(context, target2)) - .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE); + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.NOT_ASSIGNABLE); } @Test diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java index 6667cc0cc0ce..bf5ae88dbe6b 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java @@ -5946,84 +5946,6 @@ public void generateCode(String propertyName, MethodVisitor mv, CodeFlow cf) { } - static class CompilableMapAccessor implements CompilablePropertyAccessor { - - @Override - public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException { - Map map = (Map) target; - return map.containsKey(name); - } - - @Override - public TypedValue read(EvaluationContext context, Object target, String name) throws AccessException { - Map map = (Map) target; - Object value = map.get(name); - if (value == null && !map.containsKey(name)) { - throw new MapAccessException(name); - } - return new TypedValue(value); - } - - @Override - public boolean canWrite(EvaluationContext context, Object target, String name) throws AccessException { - return true; - } - - @Override - @SuppressWarnings("unchecked") - public void write(EvaluationContext context, Object target, String name, Object newValue) throws AccessException { - Map map = (Map) target; - map.put(name, newValue); - } - - @Override - public Class[] getSpecificTargetClasses() { - return new Class[] {Map.class}; - } - - @Override - public boolean isCompilable() { - return true; - } - - @Override - public Class getPropertyType() { - return Object.class; - } - - @Override - public void generateCode(String propertyName, MethodVisitor mv, CodeFlow cf) { - String descriptor = cf.lastDescriptor(); - if (descriptor == null) { - cf.loadTarget(mv); - } - mv.visitLdcInsn(propertyName); - mv.visitMethodInsn(INVOKEINTERFACE, "java/util/Map", "get","(Ljava/lang/Object;)Ljava/lang/Object;",true); - } - } - - - /** - * Exception thrown from {@code read} in order to reset a cached - * PropertyAccessor, allowing other accessors to have a try. - */ - @SuppressWarnings("serial") - private static class MapAccessException extends AccessException { - - private final String key; - - public MapAccessException(String key) { - super(null); - this.key = key; - } - - @Override - public String getMessage() { - return "Map does not contain a value for key '" + this.key + "'"; - } - } - - public static class Greeter { public String getWorld() { diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java new file mode 100644 index 000000000000..7ac2132883c3 --- /dev/null +++ b/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java @@ -0,0 +1,477 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.expression.spel.support; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.ThrowableTypeAssert; +import org.junit.jupiter.api.Test; + +import org.springframework.expression.Expression; +import org.springframework.expression.spel.CompilableMapAccessor; +import org.springframework.expression.spel.SpelEvaluationException; +import org.springframework.expression.spel.SpelMessage; +import org.springframework.expression.spel.standard.SpelExpressionParser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.entry; + +/** + * Tests for {@link SimpleEvaluationContext}. + * + *

Some of the use cases in this test class are duplicated elsewhere within the test + * suite; however, we include them here to consistently focus on related features in this + * test class. + * + * @author Sam Brannen + */ +class SimpleEvaluationContextTests { + + private final SpelExpressionParser parser = new SpelExpressionParser(); + + private final Model model = new Model(); + + + @Test + void forReadWriteDataBinding() { + SimpleEvaluationContext context = SimpleEvaluationContext.forReadWriteDataBinding().build(); + + assertReadWriteMode(context); + } + + @Test + void forReadOnlyDataBinding() { + SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + + assertCommonReadOnlyModeBehavior(context); + + // WRITE -- via assignment operator + + // Variable + assertAssignmentDisabled(context, "#myVar = 'rejected'"); + + // Property + assertAssignmentDisabled(context, "name = 'rejected'"); + assertIncrementDisabled(context, "count++"); + assertIncrementDisabled(context, "++count"); + assertDecrementDisabled(context, "count--"); + assertDecrementDisabled(context, "--count"); + + // Array Index + assertAssignmentDisabled(context, "array[0] = 'rejected'"); + assertIncrementDisabled(context, "numbers[0]++"); + assertIncrementDisabled(context, "++numbers[0]"); + assertDecrementDisabled(context, "numbers[0]--"); + assertDecrementDisabled(context, "--numbers[0]"); + + // List Index + assertAssignmentDisabled(context, "list[0] = 'rejected'"); + + // Map Index -- key as String + assertAssignmentDisabled(context, "map['red'] = 'rejected'"); + + // Map Index -- key as pseudo property name + assertAssignmentDisabled(context, "map[yellow] = 'rejected'"); + + // String Index + assertAssignmentDisabled(context, "name[0] = 'rejected'"); + + // Object Index + assertAssignmentDisabled(context, "['name'] = 'rejected'"); + } + + @Test + void forPropertyAccessorsInReadWriteMode() { + SimpleEvaluationContext context = SimpleEvaluationContext + .forPropertyAccessors(new CompilableMapAccessor(), DataBindingPropertyAccessor.forReadWriteAccess()) + .build(); + + assertReadWriteMode(context); + + // Map -- with key as property name supported by CompilableMapAccessor + + Expression expression; + expression = parser.parseExpression("map.yellow"); + expression.setValue(context, model, "pineapple"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("pineapple"); + + expression = parser.parseExpression("map.yellow = 'banana'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + expression = parser.parseExpression("map.yellow"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + } + + /** + * We call this "mixed" read-only mode, because write access via PropertyAccessors is + * disabled, but write access via the Indexer is not disabled. + */ + @Test + void forPropertyAccessorsInMixedReadOnlyMode() { + SimpleEvaluationContext context = SimpleEvaluationContext + .forPropertyAccessors(new CompilableMapAccessor(), DataBindingPropertyAccessor.forReadOnlyAccess()) + .build(); + + assertCommonReadOnlyModeBehavior(context); + + // Map -- with key as property name supported by CompilableMapAccessor + + Expression expression; + expression = parser.parseExpression("map.yellow"); + expression.setValue(context, model, "pineapple"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("pineapple"); + + expression = parser.parseExpression("map.yellow = 'banana'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + expression = parser.parseExpression("map.yellow"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + + // WRITE -- via assignment operator + + // Variable + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("#myVar = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.VARIABLE_ASSIGNMENT_NOT_SUPPORTED)); + + // Property + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("name = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE)); + + // Array Index + parser.parseExpression("array[0]").setValue(context, model, "foo"); + assertThat(model.array).containsExactly("foo"); + + // List Index + parser.parseExpression("list[0]").setValue(context, model, "cat"); + assertThat(model.list).containsExactly("cat"); + + // Map Index -- key as String + parser.parseExpression("map['red']").setValue(context, model, "cherry"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "banana")); + + // Map Index -- key as pseudo property name + parser.parseExpression("map[yellow]").setValue(context, model, "lemon"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "lemon")); + + // String Index + // The Indexer does not support writes when indexing into a String. + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("name[0] = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE)); + + // Object Index + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("['name'] = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE)); + + // WRITE -- via increment and decrement operators + + assertIncrementAndDecrementWritesForIndexedStructures(context); + } + + + private void assertReadWriteMode(SimpleEvaluationContext context) { + // Variables can always be set programmatically within an EvaluationContext. + context.setVariable("myVar", "enigma"); + + // WRITE -- via setValue() + + // Property + parser.parseExpression("name").setValue(context, model, "test"); + assertThat(model.name).isEqualTo("test"); + parser.parseExpression("count").setValue(context, model, 42); + assertThat(model.count).isEqualTo(42); + + // Array Index + parser.parseExpression("array[0]").setValue(context, model, "foo"); + assertThat(model.array).containsExactly("foo"); + + // List Index + parser.parseExpression("list[0]").setValue(context, model, "cat"); + assertThat(model.list).containsExactly("cat"); + + // Map Index -- key as String + parser.parseExpression("map['red']").setValue(context, model, "cherry"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "replace me")); + + // Map Index -- key as pseudo property name + parser.parseExpression("map[yellow]").setValue(context, model, "lemon"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "lemon")); + + // READ + assertReadAccess(context); + + // WRITE -- via assignment operator + + // Variable assignment is always disabled in a SimpleEvaluationContext. + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("#myVar = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.VARIABLE_ASSIGNMENT_NOT_SUPPORTED)); + + Expression expression; + + // Property + expression = parser.parseExpression("name = 'changed'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("changed"); + expression = parser.parseExpression("name"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("changed"); + + // Array Index + expression = parser.parseExpression("array[0] = 'bar'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("bar"); + expression = parser.parseExpression("array[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("bar"); + + // List Index + expression = parser.parseExpression("list[0] = 'dog'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("dog"); + expression = parser.parseExpression("list[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("dog"); + + // Map Index -- key as String + expression = parser.parseExpression("map['red'] = 'strawberry'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("strawberry"); + expression = parser.parseExpression("map['red']"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("strawberry"); + + // Map Index -- key as pseudo property name + expression = parser.parseExpression("map[yellow] = 'banana'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + expression = parser.parseExpression("map[yellow]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + + // String Index + // The Indexer does not support writes when indexing into a String. + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("name[0] = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE)); + + // Object Index + expression = parser.parseExpression("['name'] = 'new name'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("new name"); + expression = parser.parseExpression("['name']"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("new name"); + + // WRITE -- via increment and decrement operators + + assertIncrementAndDecrementWritesForProperties(context); + assertIncrementAndDecrementWritesForIndexedStructures(context); + } + + private void assertCommonReadOnlyModeBehavior(SimpleEvaluationContext context) { + // Variables can always be set programmatically within an EvaluationContext. + context.setVariable("myVar", "enigma"); + + // WRITE -- via setValue() + + // Note: forReadOnlyDataBinding() disables programmatic writes via setValue() for + // properties but allows programmatic writes via setValue() for indexed structures. + + // Property + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("name").setValue(context, model, "test")) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE)); + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("count").setValue(context, model, 42)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE)); + + // Array Index + parser.parseExpression("array[0]").setValue(context, model, "foo"); + assertThat(model.array).containsExactly("foo"); + + // List Index + parser.parseExpression("list[0]").setValue(context, model, "cat"); + assertThat(model.list).containsExactly("cat"); + + // Map Index -- key as String + parser.parseExpression("map['red']").setValue(context, model, "cherry"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "replace me")); + + // Map Index -- key as pseudo property name + parser.parseExpression("map[yellow]").setValue(context, model, "lemon"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "lemon")); + + // Since the setValue() attempts for "name" and "count" failed above, we have to set + // them directly for assertReadAccess(). + model.name = "test"; + model.count = 42; + + // READ + assertReadAccess(context); + } + + private void assertReadAccess(SimpleEvaluationContext context) { + Expression expression; + + // Variable + expression = parser.parseExpression("#myVar"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("enigma"); + + // Property + expression = parser.parseExpression("name"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("test"); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(42); + + // Array Index + expression = parser.parseExpression("array[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("foo"); + + // List Index + expression = parser.parseExpression("list[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("cat"); + + // Map Index -- key as String + expression = parser.parseExpression("map['red']"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("cherry"); + + // Map Index -- key as pseudo property name + expression = parser.parseExpression("map[yellow]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("lemon"); + + // String Index + expression = parser.parseExpression("name[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("t"); + + // Object Index + expression = parser.parseExpression("['name']"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("test"); + } + + private void assertIncrementAndDecrementWritesForProperties(SimpleEvaluationContext context) { + Expression expression; + expression = parser.parseExpression("count++"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(42); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(43); + + expression = parser.parseExpression("++count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(44); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(44); + + expression = parser.parseExpression("count--"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(44); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(43); + + expression = parser.parseExpression("--count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(42); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(42); + } + + private void assertIncrementAndDecrementWritesForIndexedStructures(SimpleEvaluationContext context) { + Expression expression; + expression = parser.parseExpression("numbers[0]++"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(99); + expression = parser.parseExpression("numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(100); + + expression = parser.parseExpression("++numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(101); + expression = parser.parseExpression("numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(101); + + expression = parser.parseExpression("numbers[0]--"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(101); + expression = parser.parseExpression("numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(100); + + expression = parser.parseExpression("--numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(99); + expression = parser.parseExpression("numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(99); + } + + private ThrowableTypeAssert assertThatSpelEvaluationException() { + return assertThatExceptionOfType(SpelEvaluationException.class); + } + + private void assertAssignmentDisabled(SimpleEvaluationContext context, String expression) { + assertEvaluationException(context, expression, SpelMessage.NOT_ASSIGNABLE); + } + + private void assertIncrementDisabled(SimpleEvaluationContext context, String expression) { + assertEvaluationException(context, expression, SpelMessage.OPERAND_NOT_INCREMENTABLE); + } + + private void assertDecrementDisabled(SimpleEvaluationContext context, String expression) { + assertEvaluationException(context, expression, SpelMessage.OPERAND_NOT_DECREMENTABLE); + } + + private void assertEvaluationException(SimpleEvaluationContext context, String expression, SpelMessage spelMessage) { + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression(expression).getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(spelMessage)); + } + + + static class Model { + + private String name = "replace me"; + private int count = 0; + private final String[] array = {"replace me"}; + private final int[] numbers = {99}; + private final List list = new ArrayList<>(); + private final Map map = new HashMap<>(); + + Model() { + this.list.add("replace me"); + this.map.put("red", "replace me"); + this.map.put("yellow", "replace me"); + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public int getCount() { + return this.count; + } + + public void setCount(int count) { + this.count = count; + } + + public String[] getArray() { + return this.array; + } + + public int[] getNumbers() { + return this.numbers; + } + + public List getList() { + return this.list; + } + + public Map getMap() { + return this.map; + } + + } + +}