diff --git a/spring-expression/spring-expression.gradle b/spring-expression/spring-expression.gradle index 4b31d5b6f5..d141d5309a 100644 --- a/spring-expression/spring-expression.gradle +++ b/spring-expression/spring-expression.gradle @@ -8,4 +8,5 @@ dependencies { testImplementation(testFixtures(project(":spring-core"))) testImplementation("org.jetbrains.kotlin:kotlin-reflect") testImplementation("org.jetbrains.kotlin:kotlin-stdlib") + testImplementation("com.fasterxml.jackson.core:jackson-databind") } 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 9dd23361cd..2746352d1a 100644 --- a/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java @@ -16,6 +16,7 @@ package org.springframework.expression; +import java.util.Collections; import java.util.List; import java.util.function.Supplier; @@ -56,6 +57,16 @@ public interface EvaluationContext { */ List getPropertyAccessors(); + /** + * Return a list of index accessors that will be asked in turn to access or + * set an indexed value. + *

The default implementation returns an empty list. + * @since 6.2 + */ + default List getIndexAccessors() { + return Collections.emptyList(); + } + /** * Return a list of resolvers that will be asked in turn to locate a constructor. */ diff --git a/spring-expression/src/main/java/org/springframework/expression/IndexAccessor.java b/spring-expression/src/main/java/org/springframework/expression/IndexAccessor.java new file mode 100644 index 0000000000..e1719f54b5 --- /dev/null +++ b/spring-expression/src/main/java/org/springframework/expression/IndexAccessor.java @@ -0,0 +1,110 @@ +/* + * 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; + +import org.springframework.lang.Nullable; + +/** + * An index accessor is able to read from (and possibly write to) an indexed + * structure of an object. + * + *

This interface places no restrictions on what constitutes an indexed + * structure. Implementors are therefore free to access indexed values any way + * they deem appropriate. + * + *

An index accessor can optionally specify an array of target classes for + * which it should be called. However, if it returns {@code null} or an empty + * array from {@link #getSpecificTargetClasses()}, it will be called for all + * indexing operations and given a chance to determine if it can read from or + * write to the indexed structure. + * + *

Index accessors are considered to be ordered, and each will be called in + * turn. The only rule that affects the call order is that any index accessor + * which specifies explicit support for the target class via + * {@link #getSpecificTargetClasses()} will be called first, before other + * generic index accessors. + * + * @author Jackmiking Lee + * @author Sam Brannen + * @since 6.2 + * @see PropertyAccessor + */ +public interface IndexAccessor extends TargetedAccessor { + + /** + * Get the set of classes for which this index accessor should be called. + *

Returning {@code null} or an empty array indicates this is a generic + * index accessor that can be called in an attempt to access an index on any + * type. + * @return an array of classes that this index accessor is suitable for + * (or {@code null} or an empty array if a generic index accessor) + */ + @Override + @Nullable + Class[] getSpecificTargetClasses(); + + /** + * Determine if this index accessor is able to read a specified index on a + * specified target object. + * @param context the evaluation context in which the access is being attempted + * @param target the target object upon which the index is being accessed + * @param index the index being accessed + * @return {@code true} if this index accessor is able to read the index + * @throws AccessException if there is any problem determining whether the + * index can be read + */ + boolean canRead(EvaluationContext context, Object target, Object index) throws AccessException; + + /** + * Read an index from a specified target object. + *

Should only be invoked if {@link #canRead} returns {@code true} for the + * same arguments. + * @param context the evaluation context in which the access is being attempted + * @param target the target object upon which the index is being accessed + * @param index the index being accessed + * @return a TypedValue object wrapping the index value read and a type + * descriptor for the value + * @throws AccessException if there is any problem reading the index + */ + TypedValue read(EvaluationContext context, Object target, Object index) throws AccessException; + + /** + * Determine if this index accessor is able to write to a specified index on + * a specified target object. + * @param context the evaluation context in which the access is being attempted + * @param target the target object upon which the index is being accessed + * @param index the index being accessed + * @return {@code true} if this index accessor is able to write to the index + * @throws AccessException if there is any problem determining whether the + * index can be written to + */ + boolean canWrite(EvaluationContext context, Object target, Object index) throws AccessException; + + /** + * Write to an index on a specified target object. + *

Should only be invoked if {@link #canWrite} returns {@code true} for the + * same arguments. + * @param context the evaluation context in which the access is being attempted + * @param target the target object upon which the index is being accessed + * @param index the index being accessed + * @param newValue the new value for the index + * @throws AccessException if there is any problem writing to the index + */ + void write(EvaluationContext context, Object target, Object index, @Nullable Object newValue) + throws AccessException; + +} diff --git a/spring-expression/src/main/java/org/springframework/expression/PropertyAccessor.java b/spring-expression/src/main/java/org/springframework/expression/PropertyAccessor.java index c063750b1b..a439046be2 100644 --- a/spring-expression/src/main/java/org/springframework/expression/PropertyAccessor.java +++ b/spring-expression/src/main/java/org/springframework/expression/PropertyAccessor.java @@ -34,21 +34,24 @@ import org.springframework.lang.Nullable; *

Property accessors are considered to be ordered, and each will be called in * turn. The only rule that affects the call order is that any property accessor * which specifies explicit support for the target class via - * {@link #getSpecificTargetClasses()} will be called first, before the general + * {@link #getSpecificTargetClasses()} will be called first, before the generic * property accessors. * * @author Andy Clement * @since 3.0 + * @see IndexAccessor */ -public interface PropertyAccessor { +public interface PropertyAccessor extends TargetedAccessor { /** - * Return an array of classes for which this property accessor should be called. - *

Returning {@code null} indicates this is a general property accessor that - * can be called in an attempt to access a property on any type. + * Get the set of classes for which this property accessor should be called. + *

Returning {@code null} or an empty array indicates this is a generic + * property accessor that can be called in an attempt to access a property on + * any type. * @return an array of classes that this property accessor is suitable for - * (or {@code null} if a general property accessor) + * (or {@code null} if a generic property accessor) */ + @Override @Nullable Class[] getSpecificTargetClasses(); diff --git a/spring-expression/src/main/java/org/springframework/expression/TargetedAccessor.java b/spring-expression/src/main/java/org/springframework/expression/TargetedAccessor.java new file mode 100644 index 0000000000..88b91055f5 --- /dev/null +++ b/spring-expression/src/main/java/org/springframework/expression/TargetedAccessor.java @@ -0,0 +1,56 @@ +/* + * 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; + +import org.springframework.lang.Nullable; + +/** + * Strategy for types that access elements of specific target classes. + * + *

This interface places no restrictions on what constitutes an element. + * + *

A targeted accessor can specify a set of target classes for which it should + * be called. However, if it returns {@code null} or an empty array from + * {@link #getSpecificTargetClasses()}, it will typically be called for all + * access operations and given a chance to determine if it supports a concrete + * access attempt. + * + *

Targeted accessors are considered to be ordered, and each will be called + * in turn. The only rule that affects the call order is that any accessor which + * specifies explicit support for a given target class via + * {@link #getSpecificTargetClasses()} will be called first, before other generic + * accessors that do not specify explicit support for the given target class. + * + * @author Sam Brannen + * @since 6.2 + * @see PropertyAccessor + * @see IndexAccessor + */ +public interface TargetedAccessor { + + /** + * Get the set of classes for which this accessor should be called. + *

Returning {@code null} or an empty array indicates this is a generic + * accessor that can be called in an attempt to access an element on any + * type. + * @return an array of classes that this accessor is suitable for + * (or {@code null} or an empty array if a generic accessor) + */ + @Nullable + Class[] getSpecificTargetClasses(); + +} diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/SpelMessage.java b/spring-expression/src/main/java/org/springframework/expression/spel/SpelMessage.java index 2356b6011c..0ed9f8586b 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/SpelMessage.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/SpelMessage.java @@ -291,7 +291,16 @@ public enum SpelMessage { /** @since 6.0.13 */ NEGATIVE_REPEATED_TEXT_COUNT(Kind.ERROR, 1081, - "Repeat count ''{0}'' must not be negative"); + "Repeat count ''{0}'' must not be negative"), + + /** @since 6.2 */ + EXCEPTION_DURING_INDEX_READ(Kind.ERROR, 1082, + "A problem occurred while attempting to read index ''{0}'' in ''{1}''"), + + /** @since 6.2 */ + EXCEPTION_DURING_INDEX_WRITE(Kind.ERROR, 1083, + "A problem occurred while attempting to write index ''{0}'' in ''{1}''"); + private final Kind kind; diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/AstUtils.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/AstUtils.java index e4e710f7d6..665f1b6ee1 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/AstUtils.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/AstUtils.java @@ -17,9 +17,11 @@ package org.springframework.expression.spel.ast; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import org.springframework.expression.PropertyAccessor; +import org.springframework.expression.TargetedAccessor; import org.springframework.lang.Nullable; import org.springframework.util.ObjectUtils; @@ -27,16 +29,76 @@ import org.springframework.util.ObjectUtils; * Utility methods for use in the AST classes. * * @author Andy Clement + * @author Sam Brannen * @since 3.0.2 */ public abstract class AstUtils { + /** + * Determine the set of accessors that should be used to try to access an + * element on the specified target type. + *

The accessors are considered to be in an ordered list; however, in the + * returned list any accessors that are exact matches for the input target + * type (as opposed to 'generic' accessors that could work for any type) are + * placed at the start of the list. In addition, if there are specific + * accessors that exactly name the class in question and accessors that name + * a specific class which is a supertype of the class in question, the latter + * are put at the end of the specific accessors set and will be tried after + * exactly matching accessors but before generic accessors. + * @param targetType the type upon which element access is being attempted + * @param accessors the list of element accessors to process + * @return a list of accessors that should be tried in order to access the + * element on the specified target type, or an empty list if no suitable + * accessor could be found + * @since 6.2 + */ + public static List getAccessorsToTry( + @Nullable Class targetType, List accessors) { + + if (accessors.isEmpty()) { + return Collections.emptyList(); + } + + List exactMatches = new ArrayList<>(); + List inexactMatches = new ArrayList<>(); + List genericMatches = new ArrayList<>(); + for (T accessor : accessors) { + Class[] targets = accessor.getSpecificTargetClasses(); + if (ObjectUtils.isEmpty(targets)) { + // generic accessor that says it can be used for any type + genericMatches.add(accessor); + } + else if (targetType != null) { + for (Class clazz : targets) { + if (clazz == targetType) { + exactMatches.add(accessor); + } + else if (clazz.isAssignableFrom(targetType)) { + inexactMatches.add(accessor); + } + } + } + } + + int size = exactMatches.size() + inexactMatches.size() + genericMatches.size(); + if (size == 0) { + return Collections.emptyList(); + } + else { + List result = new ArrayList<>(size); + result.addAll(exactMatches); + result.addAll(inexactMatches); + result.addAll(genericMatches); + return result; + } + } + /** * Determine the set of property accessors that should be used to try to * access a property on the specified target type. *

The accessors are considered to be in an ordered list; however, in the * returned list any accessors that are exact matches for the input target - * type (as opposed to 'general' accessors that could work for any type) are + * type (as opposed to 'generic' accessors that could work for any type) are * placed at the start of the list. In addition, if there are specific * accessors that exactly name the class in question and accessors that name * a specific class which is a supertype of the class in question, the latter @@ -44,36 +106,15 @@ public abstract class AstUtils { * exactly matching accessors but before generic accessors. * @param targetType the type upon which property access is being attempted * @param propertyAccessors the list of property accessors to process - * @return a list of accessors that should be tried in order to access the property + * @return a list of accessors that should be tried in order to access the + * property on the specified target type, or an empty list if no suitable + * accessor could be found + * @see #getAccessorsToTry(Class, List) */ public static List getPropertyAccessorsToTry( @Nullable Class targetType, List propertyAccessors) { - List specificAccessors = new ArrayList<>(); - List generalAccessors = new ArrayList<>(); - for (PropertyAccessor accessor : propertyAccessors) { - Class[] targets = accessor.getSpecificTargetClasses(); - if (ObjectUtils.isEmpty(targets)) { - // generic accessor that says it can be used for any type - generalAccessors.add(accessor); - } - else if (targetType != null) { - for (Class clazz : targets) { - if (clazz == targetType) { - // add exact matches to the specificAccessors list - specificAccessors.add(accessor); - } - else if (clazz.isAssignableFrom(targetType)) { - // add supertype matches to the front of the generalAccessors list - generalAccessors.add(0, accessor); - } - } - } - } - List accessors = new ArrayList<>(specificAccessors.size() + generalAccessors.size()); - accessors.addAll(specificAccessors); - accessors.addAll(generalAccessors); - return accessors; + return getAccessorsToTry(targetType, propertyAccessors); } } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java index 0e888782af..16be5d19ad 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java @@ -28,6 +28,7 @@ import org.springframework.core.convert.TypeDescriptor; import org.springframework.expression.AccessException; import org.springframework.expression.EvaluationContext; import org.springframework.expression.EvaluationException; +import org.springframework.expression.IndexAccessor; import org.springframework.expression.PropertyAccessor; import org.springframework.expression.TypeConverter; import org.springframework.expression.TypedValue; @@ -73,7 +74,24 @@ import org.springframework.util.ReflectionUtils; */ public class Indexer extends SpelNodeImpl { - private enum IndexedType {ARRAY, LIST, MAP, STRING, OBJECT} + private enum IndexedType {ARRAY, LIST, MAP, STRING, OBJECT, CUSTOM} + + private enum AccessMode { + + READ(true, false), + + WRITE(false, true), + + READ_WRITE(true, true); + + private final boolean supportsReads; + private final boolean supportsWrites; + + private AccessMode(boolean supportsReads, boolean supportsWrites) { + this.supportsReads = supportsReads; + this.supportsWrites = supportsWrites; + } + } private final boolean nullSafe; @@ -87,33 +105,81 @@ public class Indexer extends SpelNodeImpl { @Nullable private volatile String arrayTypeDescriptor; - // These fields are used when the indexer is being used as a property read accessor. - // If the name and target type match these cached values then the cachedReadAccessor - // is used to read the property. If they do not match, the correct accessor is - // discovered and then cached for later use. + // These fields are used when the Indexer is being used as PropertyAccessor + // for reading. If the name and target type match these cached values, then + // the cachedPropertyReadAccessor is used to read the property. If they do + // not match, a suitable accessor is discovered and then cached for later use. @Nullable - private String cachedReadName; + private String cachedPropertyReadName; @Nullable - private Class cachedReadTargetType; + private Class cachedPropertyReadTargetType; @Nullable - private PropertyAccessor cachedReadAccessor; + private PropertyAccessor cachedPropertyReadAccessor; - // These fields are used when the indexer is being used as a property write accessor. - // If the name and target type match these cached values then the cachedWriteAccessor - // is used to write the property. If they do not match, the correct accessor is - // discovered and then cached for later use. + // These fields are used when the Indexer is being used as a PropertyAccessor + // for writing. If the name and target type match these cached values, then + // the cachedPropertyWriteAccessor is used to write the property. If they do + // not match, a suitable accessor is discovered and then cached for later use. @Nullable - private String cachedWriteName; + private String cachedPropertyWriteName; @Nullable - private Class cachedWriteTargetType; + private Class cachedPropertyWriteTargetType; @Nullable - private PropertyAccessor cachedWriteAccessor; + private PropertyAccessor cachedPropertyWriteAccessor; + + // These fields are used when the Indexer is being used as an IndexAccessor + // for reading. If the index value and target type match these cached values, + // then the cachedIndexReadAccessor is used to read the index. If they do not + // match, a suitable accessor is discovered and then cached for later use. + + /** + * The index value: the value inside the square brackets, such as the + * Integer 0 in [0], the String "name" in ['name'], etc. + */ + @Nullable + private Object cachedIndexReadIndex; + + /** + * The target type on which the index is being read. + */ + @Nullable + private Class cachedIndexReadTargetType; + + /** + * Cached {@link IndexAccessor} for reading. + */ + @Nullable + private IndexAccessor cachedIndexReadAccessor; + + // These fields are used when the Indexer is being used as an IndexAccessor + // for writing. If the name and target type match these cached values, then + // the cachedIndexWriteAccessor is used to read the property. If they do not + // match, a suitable accessor is discovered and then cached for later use. + + /** + * The index value: the value inside the square brackets, such as the + * Integer 0 in [0], the String "name" in ['name'], etc. + */ + @Nullable + private Object cachedIndexWriteIndex; + + /** + * The target type on which the index is being written. + */ + @Nullable + private Class cachedIndexWriteTargetType; + + /** + * Cached {@link IndexAccessor} for writing. + */ + @Nullable + private IndexAccessor cachedIndexWriteAccessor; /** @@ -149,7 +215,7 @@ public class Indexer extends SpelNodeImpl { @Override public TypedValue getValueInternal(ExpressionState state) throws EvaluationException { - return getValueRef(state).getValue(); + return getValueRef(state, AccessMode.READ).getValue(); } @Override @@ -157,18 +223,21 @@ public class Indexer extends SpelNodeImpl { throws EvaluationException { TypedValue typedValue = valueSupplier.get(); - getValueRef(state).setValue(typedValue.getValue()); + getValueRef(state, AccessMode.WRITE).setValue(typedValue.getValue()); return typedValue; } @Override public boolean isWritable(ExpressionState expressionState) throws SpelEvaluationException { - return true; + return getValueRef(expressionState, AccessMode.WRITE).isWritable(); } - @Override protected ValueRef getValueRef(ExpressionState state) throws EvaluationException { + return getValueRef(state, AccessMode.READ_WRITE); + } + + private ValueRef getValueRef(ExpressionState state, AccessMode accessMode) throws EvaluationException { TypedValue context = state.getActiveContextObject(); Object target = context.getValue(); @@ -239,14 +308,47 @@ public class Indexer extends SpelNodeImpl { } } - // Try and treat the index value as a property of the context object + // Try to treat the index value as a property of the context object. TypeDescriptor valueType = indexValue.getTypeDescriptor(); if (valueType != null && String.class == valueType.getType()) { this.indexedType = IndexedType.OBJECT; - return new PropertyIndexingValueRef( + return new PropertyAccessorValueRef( target, (String) index, state.getEvaluationContext(), targetDescriptor); } + EvaluationContext evalContext = state.getEvaluationContext(); + List accessorsToTry = getIndexAccessorsToTry(target, evalContext.getIndexAccessors()); + if (accessMode.supportsReads) { + try { + for (IndexAccessor indexAccessor : accessorsToTry) { + if (indexAccessor.canRead(evalContext, target, index)) { + this.indexedType = IndexedType.CUSTOM; + return new IndexAccessorValueRef(target, index, evalContext, targetDescriptor); + } + } + } + catch (Exception ex) { + throw new SpelEvaluationException( + getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_INDEX_READ, + index, target.getClass().getTypeName()); + } + } + if (accessMode.supportsWrites) { + try { + for (IndexAccessor indexAccessor : accessorsToTry) { + if (indexAccessor.canWrite(evalContext, target, index)) { + this.indexedType = IndexedType.CUSTOM; + return new IndexAccessorValueRef(target, index, evalContext, targetDescriptor); + } + } + } + catch (Exception ex) { + throw new SpelEvaluationException( + getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_INDEX_WRITE, + index, target.getClass().getTypeName()); + } + } + throw new SpelEvaluationException( getStartPosition(), SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE, targetDescriptor); } @@ -267,8 +369,8 @@ public class Indexer extends SpelNodeImpl { // If the string name is changing, the accessor is clearly going to change. // So compilation is only possible if the index expression is a StringLiteral. return (index instanceof StringLiteral && - this.cachedReadAccessor instanceof CompilablePropertyAccessor compilablePropertyAccessor && - compilablePropertyAccessor.isCompilable()); + this.cachedPropertyReadAccessor instanceof CompilablePropertyAccessor cpa && + cpa.isCompilable()); } return false; } @@ -347,7 +449,8 @@ public class Indexer extends SpelNodeImpl { throw new IllegalStateException( "Index expression must be a StringLiteral, but was: " + index.getClass().getName()); } - CompilablePropertyAccessor compilablePropertyAccessor = (CompilablePropertyAccessor) this.cachedReadAccessor; + CompilablePropertyAccessor compilablePropertyAccessor = + (CompilablePropertyAccessor) this.cachedPropertyReadAccessor; Assert.state(compilablePropertyAccessor != null, "No cached read accessor"); String propertyName = (String) stringLiteral.getLiteralValue().getValue(); Assert.state(propertyName != null, "No property name"); @@ -386,6 +489,50 @@ public class Indexer extends SpelNodeImpl { } } + private void updatePropertyReadState(@Nullable PropertyAccessor propertyAccessor, @Nullable String name, + @Nullable Class targetType) { + this.cachedPropertyReadAccessor = propertyAccessor; + this.cachedPropertyReadName = name; + this.cachedPropertyReadTargetType = targetType; + } + + private void updatePropertyWriteState(@Nullable PropertyAccessor propertyAccessor, @Nullable String name, + @Nullable Class targetType) { + this.cachedPropertyWriteAccessor = propertyAccessor; + this.cachedPropertyWriteName = name; + this.cachedPropertyWriteTargetType = targetType; + } + + private void updateIndexReadState(@Nullable IndexAccessor indexAccessor, @Nullable Object index, + @Nullable Class targetType) { + this.cachedIndexReadAccessor = indexAccessor; + this.cachedIndexReadIndex = index; + this.cachedIndexReadTargetType = targetType; + } + + private void updateIndexWriteState(@Nullable IndexAccessor indexAccessor, @Nullable Object index, + @Nullable Class targetType) { + this.cachedIndexWriteAccessor = indexAccessor; + this.cachedIndexWriteIndex = index; + this.cachedIndexWriteTargetType = targetType; + } + + /** + * Determine the set of index accessors that should be used to try to access + * an index on the specified context object. + *

Delegates to {@link AstUtils#getAccessorsToTry(Class, List)}. + * @param targetObject the object upon which index access is being attempted + * @param indexAccessors the list of index accessors to process + * @return a list of accessors that should be tried in order to access the + * index, or an empty list if no suitable accessor could be found + */ + private static List getIndexAccessorsToTry( + @Nullable Object targetObject, List indexAccessors) { + + Class targetType = (targetObject != null ? targetObject.getClass() : null); + return AstUtils.getAccessorsToTry(targetType, indexAccessors); + } + private class ArrayIndexingValueRef implements ValueRef { @@ -604,7 +751,7 @@ public class Indexer extends SpelNodeImpl { } - private class PropertyIndexingValueRef implements ValueRef { + private class PropertyAccessorValueRef implements ValueRef { private final Object targetObject; @@ -614,7 +761,7 @@ public class Indexer extends SpelNodeImpl { private final TypeDescriptor targetObjectTypeDescriptor; - public PropertyIndexingValueRef(Object targetObject, String value, + public PropertyAccessorValueRef(Object targetObject, String value, EvaluationContext evaluationContext, TypeDescriptor targetObjectTypeDescriptor) { this.targetObject = targetObject; @@ -624,29 +771,27 @@ public class Indexer extends SpelNodeImpl { } @Override - @SuppressWarnings("NullAway") public TypedValue getValue() { - Class targetObjectRuntimeClass = getObjectClass(this.targetObject); + Class targetType = getObjectClass(this.targetObject); try { - if (Indexer.this.cachedReadName != null && Indexer.this.cachedReadName.equals(this.name) && - Indexer.this.cachedReadTargetType != null && - Indexer.this.cachedReadTargetType.equals(targetObjectRuntimeClass)) { + String cachedPropertyReadName = Indexer.this.cachedPropertyReadName; + Class cachedPropertyReadTargetType = Indexer.this.cachedPropertyReadTargetType; + if (cachedPropertyReadName != null && cachedPropertyReadName.equals(this.name) && + cachedPropertyReadTargetType != null && cachedPropertyReadTargetType.equals(targetType)) { // It is OK to use the cached accessor - PropertyAccessor accessor = Indexer.this.cachedReadAccessor; - Assert.state(accessor != null, "No cached read accessor"); + PropertyAccessor accessor = Indexer.this.cachedPropertyReadAccessor; + Assert.state(accessor != null, "No cached PropertyAccessor for reading"); return accessor.read(this.evaluationContext, this.targetObject, this.name); } List accessorsToTry = AstUtils.getPropertyAccessorsToTry( - targetObjectRuntimeClass, this.evaluationContext.getPropertyAccessors()); + targetType, this.evaluationContext.getPropertyAccessors()); for (PropertyAccessor accessor : accessorsToTry) { if (accessor.canRead(this.evaluationContext, this.targetObject, this.name)) { if (accessor instanceof ReflectivePropertyAccessor reflectivePropertyAccessor) { accessor = reflectivePropertyAccessor.createOptimalAccessor( this.evaluationContext, this.targetObject, this.name); } - Indexer.this.cachedReadAccessor = accessor; - Indexer.this.cachedReadName = this.name; - Indexer.this.cachedReadTargetType = targetObjectRuntimeClass; + updatePropertyReadState(accessor, this.name, targetType); if (accessor instanceof CompilablePropertyAccessor compilablePropertyAccessor) { setExitTypeDescriptor(CodeFlow.toDescriptor(compilablePropertyAccessor.getPropertyType())); } @@ -663,26 +808,24 @@ public class Indexer extends SpelNodeImpl { } @Override - @SuppressWarnings("NullAway") public void setValue(@Nullable Object newValue) { - Class contextObjectClass = getObjectClass(this.targetObject); + Class targetType = getObjectClass(this.targetObject); try { - if (Indexer.this.cachedWriteName != null && Indexer.this.cachedWriteName.equals(this.name) && - Indexer.this.cachedWriteTargetType != null && - Indexer.this.cachedWriteTargetType.equals(contextObjectClass)) { + String cachedPropertyWriteName = Indexer.this.cachedPropertyWriteName; + Class cachedPropertyWriteTargetType = Indexer.this.cachedPropertyWriteTargetType; + if (cachedPropertyWriteName != null && cachedPropertyWriteName.equals(this.name) && + cachedPropertyWriteTargetType != null && cachedPropertyWriteTargetType.equals(targetType)) { // It is OK to use the cached accessor - PropertyAccessor accessor = Indexer.this.cachedWriteAccessor; - Assert.state(accessor != null, "No cached write accessor"); + PropertyAccessor accessor = Indexer.this.cachedPropertyWriteAccessor; + Assert.state(accessor != null, "No cached PropertyAccessor for writing"); accessor.write(this.evaluationContext, this.targetObject, this.name, newValue); return; } List accessorsToTry = AstUtils.getPropertyAccessorsToTry( - contextObjectClass, this.evaluationContext.getPropertyAccessors()); + targetType, this.evaluationContext.getPropertyAccessors()); for (PropertyAccessor accessor : accessorsToTry) { if (accessor.canWrite(this.evaluationContext, this.targetObject, this.name)) { - Indexer.this.cachedWriteName = this.name; - Indexer.this.cachedWriteTargetType = contextObjectClass; - Indexer.this.cachedWriteAccessor = accessor; + updatePropertyWriteState(accessor, this.name, targetType); accessor.write(this.evaluationContext, this.targetObject, this.name, newValue); return; } @@ -842,4 +985,132 @@ public class Indexer extends SpelNodeImpl { } } + + private class IndexAccessorValueRef implements ValueRef { + + private final Object target; + + private final Object index; + + private final EvaluationContext evaluationContext; + + private final TypeDescriptor typeDescriptor; + + + IndexAccessorValueRef(Object target, Object index, EvaluationContext evaluationContext, + TypeDescriptor typeDescriptor) { + + this.target = target; + this.index = index; + this.evaluationContext = evaluationContext; + this.typeDescriptor = typeDescriptor; + } + + + @Override + public TypedValue getValue() { + Class targetType = getObjectClass(this.target); + Exception exception = null; + try { + Object cachedIndexReadIndex = Indexer.this.cachedIndexReadIndex; + Class cachedIndexReadTargetType = Indexer.this.cachedIndexReadTargetType; + // Is it OK to use the cached IndexAccessor? + if (cachedIndexReadIndex != null && cachedIndexReadIndex.equals(this.index) && + cachedIndexReadTargetType != null && cachedIndexReadTargetType.equals(targetType)) { + IndexAccessor accessor = Indexer.this.cachedIndexReadAccessor; + Assert.state(accessor != null, "No cached IndexAccessor for reading"); + if (this.evaluationContext.getIndexAccessors().contains(accessor)) { + try { + return accessor.read(this.evaluationContext, this.target, this.index); + } + catch (Exception ex) { + // This is OK: it may have gone stale due to a class change. + // So, we track the exception and try to find a new accessor + // before giving up... + exception = ex; + } + } + // If the above code block did not use a cached accessor, + // we need to reset our cached state. + updateIndexReadState(null, null, null); + } + List accessorsToTry = + getIndexAccessorsToTry(this.target, this.evaluationContext.getIndexAccessors()); + for (IndexAccessor indexAccessor : accessorsToTry) { + if (indexAccessor.canRead(this.evaluationContext, this.target, this.index)) { + updateIndexReadState(indexAccessor, this.index, targetType); + return indexAccessor.read(this.evaluationContext, this.target, this.index); + } + } + } + catch (Exception ex) { + exception = ex; + } + + if (exception != null) { + throw new SpelEvaluationException( + getStartPosition(), exception, SpelMessage.EXCEPTION_DURING_INDEX_READ, this.index, + this.typeDescriptor.toString()); + } + throw new SpelEvaluationException(getStartPosition(), + SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE, this.typeDescriptor.toString()); + } + + @Override + public void setValue(@Nullable Object newValue) { + Class targetType = getObjectClass(this.target); + Exception exception = null; + try { + Object cachedIndexWriteIndex = Indexer.this.cachedIndexWriteIndex; + Class cachedIndexWriteTargetType = Indexer.this.cachedIndexWriteTargetType; + // Is it OK to use the cached IndexAccessor? + if (cachedIndexWriteIndex != null && cachedIndexWriteIndex.equals(this.index) && + cachedIndexWriteTargetType != null && cachedIndexWriteTargetType.equals(targetType)) { + IndexAccessor accessor = Indexer.this.cachedIndexWriteAccessor; + Assert.state(accessor != null, "No cached IndexAccessor for writing"); + if (this.evaluationContext.getIndexAccessors().contains(accessor)) { + try { + accessor.write(this.evaluationContext, this.target, this.index, newValue); + return; + } + catch (Exception ex) { + // This is OK: it may have gone stale due to a class change. + // So, we track the exception and try to find a new accessor + // before giving up... + exception = ex; + } + } + // If the above code block did not use a cached accessor, + // we need to reset our cached state. + updateIndexWriteState(null, null, null); + } + List accessorsToTry = + getIndexAccessorsToTry(this.target, this.evaluationContext.getIndexAccessors()); + for (IndexAccessor indexAccessor : accessorsToTry) { + if (indexAccessor.canWrite(this.evaluationContext, this.target, this.index)) { + updateIndexWriteState(indexAccessor, this.index, targetType); + indexAccessor.write(this.evaluationContext, this.target, this.index, newValue); + return; + } + } + } + catch (Exception ex) { + exception = ex; + } + + if (exception != null) { + throw new SpelEvaluationException( + getStartPosition(), exception, SpelMessage.EXCEPTION_DURING_INDEX_WRITE, this.index, + this.typeDescriptor.toString()); + } + throw new SpelEvaluationException(getStartPosition(), + SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE, this.typeDescriptor.toString()); + } + + @Override + public boolean isWritable() { + return true; + } + } + } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java index 864395a887..5d196b7e93 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java @@ -27,6 +27,7 @@ import org.springframework.core.convert.TypeDescriptor; import org.springframework.expression.BeanResolver; import org.springframework.expression.ConstructorResolver; import org.springframework.expression.EvaluationContext; +import org.springframework.expression.IndexAccessor; import org.springframework.expression.MethodFilter; import org.springframework.expression.MethodResolver; import org.springframework.expression.OperatorOverloader; @@ -83,6 +84,9 @@ public class StandardEvaluationContext implements EvaluationContext { @Nullable private volatile List propertyAccessors; + @Nullable + private volatile List indexAccessors; + @Nullable private volatile List constructorResolvers; @@ -155,6 +159,56 @@ public class StandardEvaluationContext implements EvaluationContext { return initPropertyAccessors().remove(accessor); } + /** + * Set the list of index accessors to use in this evaluation context. + *

Replaces any previously configured index accessors. + * @since 6.2 + * @see #getIndexAccessors() + * @see #addIndexAccessor(IndexAccessor) + * @see #removeIndexAccessor(IndexAccessor) + */ + public void setIndexAccessors(List indexAccessors) { + this.indexAccessors = indexAccessors; + } + + /** + * Get the list of index accessors configured in this evaluation context. + * @since 6.2 + * @see #setIndexAccessors(List) + * @see #addIndexAccessor(IndexAccessor) + * @see #removeIndexAccessor(IndexAccessor) + */ + @Override + public List getIndexAccessors() { + return initIndexAccessors(); + } + + /** + * Add the supplied index accessor to this evaluation context. + * @param indexAccessor the index accessor to add + * @since 6.2 + * @see #getIndexAccessors() + * @see #setIndexAccessors(List) + * @see #removeIndexAccessor(IndexAccessor) + */ + public void addIndexAccessor(IndexAccessor indexAccessor) { + initIndexAccessors().add(indexAccessor); + } + + /** + * Remove the supplied index accessor from this evaluation context. + * @param indexAccessor the index accessor to remove + * @return {@code true} if the index accessor was removed, {@code false} if + * the index accessor was not configured in this evaluation context + * @since 6.2 + * @see #getIndexAccessors() + * @see #setIndexAccessors(List) + * @see #addIndexAccessor(IndexAccessor) + */ + public boolean removeIndexAccessor(IndexAccessor indexAccessor) { + return initIndexAccessors().remove(indexAccessor); + } + public void setConstructorResolvers(List constructorResolvers) { this.constructorResolvers = constructorResolvers; } @@ -384,6 +438,7 @@ public class StandardEvaluationContext implements EvaluationContext { evaluationContext.setConstructorResolvers(new ArrayList<>(getConstructorResolvers())); evaluationContext.setMethodResolvers(new ArrayList<>(getMethodResolvers())); evaluationContext.setPropertyAccessors(new ArrayList<>(getPropertyAccessors())); + evaluationContext.setIndexAccessors(new ArrayList<>(getIndexAccessors())); evaluationContext.setTypeLocator(getTypeLocator()); evaluationContext.setTypeConverter(getTypeConverter()); @@ -404,6 +459,15 @@ public class StandardEvaluationContext implements EvaluationContext { return accessors; } + private List initIndexAccessors() { + List accessors = this.indexAccessors; + if (accessors == null) { + accessors = new ArrayList<>(5); + this.indexAccessors = accessors; + } + return accessors; + } + private List initConstructorResolvers() { List resolvers = this.constructorResolvers; if (resolvers == null) { diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/IndexingTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/IndexingTests.java index ab424b3b53..452a61bada 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/IndexingTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/IndexingTests.java @@ -28,19 +28,36 @@ import java.util.List; import java.util.Map; import java.util.Set; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.TextNode; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; +import org.springframework.expression.IndexAccessor; import org.springframework.expression.PropertyAccessor; import org.springframework.expression.TypedValue; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.expression.spel.testresources.Person; +import org.springframework.lang.Nullable; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.doThrow; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.springframework.expression.spel.SpelMessage.EXCEPTION_DURING_INDEX_READ; +import static org.springframework.expression.spel.SpelMessage.EXCEPTION_DURING_INDEX_WRITE; import static org.springframework.expression.spel.SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE; import static org.springframework.expression.spel.SpelMessage.UNABLE_TO_GROW_COLLECTION_UNKNOWN_ELEMENT_TYPE; @@ -449,6 +466,226 @@ class IndexingTests { } + @Nested + class IndexAccessorTests { // gh-26478 + + @Test + void addingAndRemovingIndexAccessors() { + ObjectMapper objectMapper = new ObjectMapper(); + IndexAccessor accessor1 = new JacksonArrayNodeIndexAccessor(objectMapper); + IndexAccessor accessor2 = new JacksonArrayNodeIndexAccessor(objectMapper); + + StandardEvaluationContext context = new StandardEvaluationContext(); + List indexAccessors = context.getIndexAccessors(); + assertThat(indexAccessors).isEmpty(); + + context.addIndexAccessor(accessor1); + assertThat(indexAccessors).containsExactly(accessor1); + + context.addIndexAccessor(accessor2); + assertThat(indexAccessors).containsExactly(accessor1, accessor2); + + List copy = new ArrayList<>(indexAccessors); + assertThat(context.removeIndexAccessor(accessor1)).isTrue(); + assertThat(context.removeIndexAccessor(accessor1)).isFalse(); + assertThat(indexAccessors).containsExactly(accessor2); + + context.setIndexAccessors(copy); + assertThat(context.getIndexAccessors()).containsExactly(accessor1, accessor2); + } + + @Test + void noSuitableIndexAccessorResultsInException() { + StandardEvaluationContext context = new StandardEvaluationContext(); + assertThat(context.getIndexAccessors()).isEmpty(); + + SpelExpressionParser parser = new SpelExpressionParser(); + Expression expr = parser.parseExpression("[0]"); + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> expr.getValue(context, this)) + .withMessageEndingWith("Indexing into type '%s' is not supported", getClass().getName()) + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(INDEXING_NOT_SUPPORTED_FOR_TYPE); + } + + @Test + void canReadThrowsException() throws Exception { + StandardEvaluationContext context = new StandardEvaluationContext(); + RuntimeException exception = new RuntimeException("Boom!"); + + IndexAccessor mock = mock(); + given(mock.getSpecificTargetClasses()).willReturn(null); + given(mock.canRead(any(), eq(this), any())).willThrow(exception); + context.addIndexAccessor(mock); + + SpelExpressionParser parser = new SpelExpressionParser(); + Expression expr = parser.parseExpression("[0]"); + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> expr.getValue(context, this)) + .withMessageEndingWith("A problem occurred while attempting to read index '%d' in '%s'", + 0, getClass().getName()) + .withCause(exception) + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(EXCEPTION_DURING_INDEX_READ); + + verify(mock, times(1)).getSpecificTargetClasses(); + verify(mock, times(1)).canRead(any(), any(), any()); + verifyNoMoreInteractions(mock); + } + + @Test + void readThrowsException() throws Exception { + StandardEvaluationContext context = new StandardEvaluationContext(); + RuntimeException exception = new RuntimeException("Boom!"); + + IndexAccessor mock = mock(); + given(mock.getSpecificTargetClasses()).willReturn(null); + given(mock.canRead(any(), eq(this), any())).willReturn(true); + given(mock.read(any(), eq(this), any())).willThrow(exception); + context.addIndexAccessor(mock); + + SpelExpressionParser parser = new SpelExpressionParser(); + Expression expr = parser.parseExpression("[0]"); + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> expr.getValue(context, this)) + .withMessageEndingWith("A problem occurred while attempting to read index '%d' in '%s'", + 0, getClass().getName()) + .withCause(exception) + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(EXCEPTION_DURING_INDEX_READ); + + verify(mock, times(2)).getSpecificTargetClasses(); + verify(mock, times(2)).canRead(any(), any(), any()); + verify(mock, times(1)).read(any(), any(), any()); + verifyNoMoreInteractions(mock); + } + + @Test + void canWriteThrowsException() throws Exception { + StandardEvaluationContext context = new StandardEvaluationContext(); + RuntimeException exception = new RuntimeException("Boom!"); + + IndexAccessor mock = mock(); + given(mock.getSpecificTargetClasses()).willReturn(null); + given(mock.canWrite(eq(context), eq(this), eq(0))).willThrow(exception); + context.addIndexAccessor(mock); + + SpelExpressionParser parser = new SpelExpressionParser(); + Expression expr = parser.parseExpression("[0]"); + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> expr.setValue(context, this, 999)) + .withMessageEndingWith("A problem occurred while attempting to write index '%d' in '%s'", + 0, getClass().getName()) + .withCause(exception) + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(EXCEPTION_DURING_INDEX_WRITE); + + verify(mock, times(1)).getSpecificTargetClasses(); + verify(mock, times(1)).canWrite(any(), any(), any()); + verifyNoMoreInteractions(mock); + } + + @Test + void writeThrowsException() throws Exception { + StandardEvaluationContext context = new StandardEvaluationContext(); + RuntimeException exception = new RuntimeException("Boom!"); + + IndexAccessor mock = mock(); + given(mock.getSpecificTargetClasses()).willReturn(null); + given(mock.canWrite(eq(context), eq(this), eq(0))).willReturn(true); + doThrow(exception).when(mock).write(any(), any(), any(), any()); + context.addIndexAccessor(mock); + + SpelExpressionParser parser = new SpelExpressionParser(); + Expression expr = parser.parseExpression("[0]"); + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> expr.setValue(context, this, 999)) + .withMessageEndingWith("A problem occurred while attempting to write index '%d' in '%s'", + 0, getClass().getName()) + .withCause(exception) + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(EXCEPTION_DURING_INDEX_WRITE); + + verify(mock, times(2)).getSpecificTargetClasses(); + verify(mock, times(2)).canWrite(any(), any(), any()); + verify(mock, times(1)).write(any(), any(), any(), any()); + verifyNoMoreInteractions(mock); + } + + @Test + void readAndWriteIndex() { + StandardEvaluationContext context = new StandardEvaluationContext(); + + ObjectMapper objectMapper = new ObjectMapper(); + context.addIndexAccessor(new JacksonArrayNodeIndexAccessor(objectMapper)); + + TextNode node0 = new TextNode("node0"); + TextNode node1 = new TextNode("node1"); + ArrayNode arrayNode = objectMapper.createArrayNode(); + arrayNode.addAll(List.of(node0, node1)); + + SpelExpressionParser parser = new SpelExpressionParser(); + Expression expr = parser.parseExpression("[0]"); + assertThat(expr.getValue(context, arrayNode)).isSameAs(node0); + + TextNode nodeX = new TextNode("nodeX"); + expr.setValue(context, arrayNode, nodeX); + // We use isEqualTo() instead of isSameAs(), since ObjectMapper.convertValue() + // converts the supplied TextNode to an equivalent JsonNode. + assertThat(expr.getValue(context, arrayNode)).isEqualTo(nodeX); + + NullNode nullNode = NullNode.getInstance(); + expr.setValue(context, arrayNode, nullNode); + assertThat(expr.getValue(context, arrayNode)).isSameAs(nullNode); + + expr = parser.parseExpression("[1]"); + assertThat(expr.getValue(context, arrayNode)).isSameAs(node1); + + expr = parser.parseExpression("[-1]"); + // Jackson's ArrayNode returns null for a non-existent index instead + // of throwing an ArrayIndexOutOfBoundsException or similar. + assertThat(expr.getValue(context, arrayNode)).isNull(); + } + + + /** + * {@link IndexAccessor} that knows how to read and write indexes in a + * Jackson {@link ArrayNode}. + */ + private static class JacksonArrayNodeIndexAccessor implements IndexAccessor { + + private final ObjectMapper objectMapper; + + JacksonArrayNodeIndexAccessor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public Class[] getSpecificTargetClasses() { + return new Class[] { ArrayNode.class }; + } + + @Override + public boolean canRead(EvaluationContext context, Object target, Object index) { + return (target instanceof ArrayNode && index instanceof Integer); + } + + @Override + public TypedValue read(EvaluationContext context, Object target, Object index) { + ArrayNode arrayNode = (ArrayNode) target; + Integer intIndex = (Integer) index; + return new TypedValue(arrayNode.get(intIndex)); + } + + @Override + public boolean canWrite(EvaluationContext context, Object target, Object index) { + return canRead(context, target, index); + } + + @Override + public void write(EvaluationContext context, Object target, Object index, @Nullable Object newValue) { + ArrayNode arrayNode = (ArrayNode) target; + Integer intIndex = (Integer) index; + arrayNode.set(intIndex, this.objectMapper.convertValue(newValue, JsonNode.class)); + } + } + } + @Target({ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME)