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 1d091efa87..0fb89de103 100644 --- a/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java @@ -153,4 +153,18 @@ public interface EvaluationContext { @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 55e5d2e4ff..1b47ead160 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 @@ package org.springframework.expression.spel.ast; 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 class Assign extends SpelNodeImpl { @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 5152ff6139..0d7c120425 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 @@ -53,6 +53,10 @@ public class OpDec extends Operator { @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 89aa7a7390..077ef942c4 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 @@ -53,6 +53,10 @@ public class OpInc extends Operator { @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); @@ -106,7 +110,7 @@ public class OpInc extends Operator { } } - // 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 f5836244f8..5f0b6f10d8 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 @@ -51,25 +51,25 @@ import org.springframework.lang.Nullable; * 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)}.
@@ -89,9 +89,9 @@ import org.springframework.lang.Nullable;
* @author Juergen Hoeller
* @author Sam Brannen
* @since 4.3.15
- * @see #forPropertyAccessors
* @see #forReadOnlyDataBinding()
* @see #forReadWriteDataBinding()
+ * @see #forPropertyAccessors
* @see StandardEvaluationContext
* @see StandardTypeConverter
* @see DataBindingPropertyAccessor
@@ -120,15 +120,19 @@ public final class SimpleEvaluationContext implements EvaluationContext {
private final Map 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) {
@@ -274,27 +296,33 @@ public final class SimpleEvaluationContext implements EvaluationContext {
"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());
}
@@ -315,8 +343,11 @@ public final class SimpleEvaluationContext implements EvaluationContext {
@Nullable
private TypedValue rootObject;
+ private final boolean assignmentEnabled;
- private Builder(PropertyAccessor... accessors) {
+
+ private Builder(boolean assignmentEnabled, PropertyAccessor... accessors) {
+ this.assignmentEnabled = assignmentEnabled;
this.propertyAccessors = Arrays.asList(accessors);
}
@@ -410,8 +441,9 @@ public final class SimpleEvaluationContext implements EvaluationContext {
public SimpleEvaluationContext build() {
return new SimpleEvaluationContext(this.propertyAccessors, this.indexAccessors,
- this.resolvers, this.typeConverter, this.rootObject);
+ 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
index f96d023e54..59371b2671 100644
--- a/spring-expression/src/test/java/org/springframework/expression/spel/CompilableMapAccessor.java
+++ b/spring-expression/src/test/java/org/springframework/expression/spel/CompilableMapAccessor.java
@@ -32,7 +32,7 @@ import org.springframework.util.Assert;
* @author Andy Clement
* @since 4.1
*/
-class CompilableMapAccessor implements CompilablePropertyAccessor {
+public class CompilableMapAccessor implements CompilablePropertyAccessor {
private final boolean allowWrite;
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 d0ef7f889f..d763088b7c 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
@@ -188,11 +188,11 @@ class PropertyAccessTests extends AbstractExpressionTests {
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 @@ class PropertyAccessTests extends AbstractExpressionTests {
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/support/SimpleEvaluationContextTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java
new file mode 100644
index 0000000000..7ac2132883
--- /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