Skip to content

Commit

Permalink
Introduce ReflectiveIndexAccessor in SpEL
Browse files Browse the repository at this point in the history
  • Loading branch information
sbrannen committed Apr 29, 2024
1 parent fc3ddda commit 31587e5
Show file tree
Hide file tree
Showing 3 changed files with 423 additions and 144 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
/*
* 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.lang.reflect.Method;
import java.lang.reflect.Modifier;

import org.springframework.asm.MethodVisitor;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.IndexAccessor;
import org.springframework.expression.TypedValue;
import org.springframework.expression.spel.CodeFlow;
import org.springframework.expression.spel.CompilableIndexAccessor;
import org.springframework.expression.spel.SpelNode;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;

/**
* A flexible {@link org.springframework.expression.IndexAccessor IndexAccessor}
* that uses reflection to read from and optionally write to an indexed structure
* of a target instance.
*
* <p>An index can be accessed through a read-method (when being read) or a
* write-method (when being written). The relationship between the read-method
* and write-method is based on a convention that applies to typical implementations
* for indexed structures. See the example below for details.
*
* <p>{@code ReflectiveIndexAccessor} also implements {@link CompilableIndexAccessor}
* in order to support compilation to bytecode for read access. Compilation
* support is enabled by default, but it can be disabled via the
* {@link #ReflectiveIndexAccessor(Class, Class, String, String, boolean)}
* constructor. For example, if the configured read-method is not {@code public}
* and cannot be invoked via a {@code public} declaring type, compilation support
* should be disabled.
*
* <h3>Example</h3>
*
* <p>The {@code FruitMap} class (the {@code targetType}) represents an indexed
* structure that is indexed via the {@code Color} enum (the {@code indexType}).
* The name of the read-method is {@code "getFruit"}, and that method returns
* a {@code String} (the {@code indexedValueType}). The name of the write-method
* is {@code "setFruit"}, and that method accepts a {@code Color} enum (the
* {@code indexType}) and a {@code String} (the {@code indexedValueType} which
* must match the return type of the read-method).
*
* <p>We can create a read-only {@code IndexAccessor} for {@code FruitMap} via
* {@code new ReflectiveIndexAccessor(FruitMap.class, Color.class, "getFruit")}.
* With that accessor registered and a {@code FruitMap} registered as a variable
* named {@code #fruitMap}, the SpEL expression {@code #fruitMap[T(example.Color).RED]}
* will evaluate to {@code "cherry"}.
*
* <p>Similarly, we can create a read-write {@code IndexAccessor} for
* {@code FruitMap} via
* {@code new ReflectiveIndexAccessor(FruitMap.class, Color.class, "getFruit", "setFruit")}.
* With that accessor registered and a {@code FruitMap} registered as a variable
* named {@code #fruitMap}, the SpEL expression
* {@code #fruitMap[T(example.Color).RED] = 'strawberry'} can be used to change
* the fruit mapping for the color red from {@code "cherry"} to {@code "strawberry"}.
*
* <pre class="code">
* package example;
*
* public enum Color {
* RED, ORANGE, YELLOW
* }</pre>
*
* <pre class="code">
* public class FruitMap {
*
* private final Map&lt;Color, String&gt; map = new HashMap&lt;&gt;();
*
* public FruitMap() {
* this.map.put(Color.RED, "cherry");
* this.map.put(Color.ORANGE, "orange");
* this.map.put(Color.YELLOW, "banana");
* }
*
* public String getFruit(Color color) {
* return this.map.get(color);
* }
* public void setFruit(Color color, String fruit) {
* this.map.put(color, fruit);
* }
* }</pre>
*
* @author Sam Brannen
* @since 6.2
* @see IndexAccessor
* @see CompilableIndexAccessor
* @see StandardEvaluationContext
* @see SimpleEvaluationContext
*/
public class ReflectiveIndexAccessor implements CompilableIndexAccessor {

private final Class<?> targetType;

private final Class<?> indexType;

private final Method readMethod;

private final Method readMethodToInvoke;

@Nullable
private final Method writeMethodToInvoke;

private final boolean compilable;

@Nullable
private final Class<?> publicDeclaringClass;


/**
* Construct a new {@code ReflectiveIndexAccessor} for read-only access with
* compilation support enabled.
* <p>See {@linkplain ReflectiveIndexAccessor class-level documentation} for
* further details and an example.
* @param targetType the type of indexed structure which serves as the target
* of index operations
* @param indexType the type of index used to read from the indexed structure
* @param readMethodName the name of the method used to read from the indexed
* structure
* @see #ReflectiveIndexAccessor(Class, Class, String, String)
*/
public ReflectiveIndexAccessor(Class<?> targetType, Class<?> indexType, String readMethodName) {
this(targetType, indexType, readMethodName, null);
}

/**
* Construct a new {@code ReflectiveIndexAccessor} for read-write access with
* compilation support enabled.
* <p>See {@linkplain ReflectiveIndexAccessor class-level documentation} for
* further details and an example.
* @param targetType the type of indexed structure which serves as the target
* of index operations
* @param indexType the type of index used to read from or write to the indexed
* structure
* @param readMethodName the name of the method used to read from the indexed
* structure
* @param writeMethodName the name of the method used to write to the indexed
* structure, or {@code null} if writing is not supported
* @see #ReflectiveIndexAccessor(Class, Class, String, String, boolean)
*/
public ReflectiveIndexAccessor(Class<?> targetType, Class<?> indexType, String readMethodName,
@Nullable String writeMethodName) {

this(targetType, indexType, readMethodName, writeMethodName, true);
}

/**
* Construct a new {@code ReflectiveIndexAccessor} for read-write access with
* configurable support for compilation.
* <p>See {@linkplain ReflectiveIndexAccessor class-level documentation} for
* further details and an example.
* @param targetType the type of indexed structure which serves as the target
* of index operations
* @param indexType the type of index used to read from or write to the indexed
* structure
* @param readMethodName the name of the method used to read from the indexed
* structure
* @param writeMethodName the name of the method used to write to the indexed
* structure, or {@code null} if writing is not supported
* @param compilable {@code true} to support compilation to bytecode,
* {@code false} to disable compilation support
*/
public ReflectiveIndexAccessor(Class<?> targetType, Class<?> indexType, String readMethodName,
@Nullable String writeMethodName, boolean compilable) {

this.targetType = targetType;
this.indexType = indexType;
this.compilable = compilable;

Method readMethod = ReflectionUtils.findMethod(targetType, readMethodName, indexType);
Assert.notNull(readMethod, () -> "Failed to find read-method '%s(%s)' in class '%s'."
.formatted(readMethodName, indexType.getTypeName(), targetType.getTypeName()));
this.readMethod = readMethod;
this.readMethodToInvoke = ClassUtils.getInterfaceMethodIfPossible(this.readMethod, targetType);
ReflectionUtils.makeAccessible(this.readMethodToInvoke);

if (this.compilable) {
Assert.state(Modifier.isPublic(this.readMethod.getModifiers()),
() -> "Read-method must be public to support compilation: " + this.readMethod);
Class<?> publicDeclaringClass = this.readMethodToInvoke.getDeclaringClass();
if (!Modifier.isPublic(publicDeclaringClass.getModifiers())) {
publicDeclaringClass = CodeFlow.findPublicDeclaringClass(this.readMethod);
}
Assert.state(publicDeclaringClass != null && Modifier.isPublic(publicDeclaringClass.getModifiers()),
() -> "Failed to find public declaring class for read-method: " + this.readMethod);
this.publicDeclaringClass = publicDeclaringClass;
}
else {
this.publicDeclaringClass = null;
}

if (writeMethodName != null) {
Class<?> indexedValueType = this.readMethod.getReturnType();
Method writeMethod = ReflectionUtils.findMethod(targetType, writeMethodName, indexType,
indexedValueType);
Assert.notNull(writeMethod, () -> "Failed to find write-method '%s(%s, %s)' in class '%s'."
.formatted(writeMethodName, indexType.getTypeName(), indexedValueType.getTypeName(),
targetType.getTypeName()));
this.writeMethodToInvoke = ClassUtils.getInterfaceMethodIfPossible(writeMethod, targetType);
ReflectionUtils.makeAccessible(this.writeMethodToInvoke);
}
else {
this.writeMethodToInvoke = null;
}
}


/**
* Get the {@code targetType} configured via the constructor.
*/
@Override
public Class<?>[] getSpecificTargetClasses() {
return new Class<?>[] { this.targetType };
}

/**
* Return {@code true} if the supplied {@code target} and {@code index} can
* be assigned to the {@code targetType} and {@code indexType} configured
* via the constructor.
* <p>Considers primitive wrapper classes as assignable to the corresponding
* primitive types.
*/
@Override
public boolean canRead(EvaluationContext context, Object target, Object index) {
return (ClassUtils.isAssignableValue(this.targetType, target) &&
ClassUtils.isAssignableValue(this.indexType, index));
}

/**
* Invoke the configured read-method via reflection and return the result
* wrapped in a {@link TypedValue}.
*/
@Override
public TypedValue read(EvaluationContext context, Object target, Object index) {
Object value = ReflectionUtils.invokeMethod(this.readMethodToInvoke, target, index);
return new TypedValue(value);
}

/**
* Return {@code true} if a write-method has been configured and
* {@link #canRead} returns {@code true} for the same arguments.
*/
@Override
public boolean canWrite(EvaluationContext context, Object target, Object index) {
return (this.writeMethodToInvoke != null && canRead(context, target, index));
}

/**
* Invoke the configured write-method via reflection.
* <p>Should only be invoked if {@link #canWrite} returns {@code true} for the
* same arguments.
*/
@Override
public void write(EvaluationContext context, Object target, Object index, @Nullable Object newValue) {
Assert.state(this.writeMethodToInvoke != null, "Write-method cannot be null");
ReflectionUtils.invokeMethod(this.writeMethodToInvoke, target, index, newValue);
}

/**
* Return {@code true} if this {@code ReflectiveIndexAccessor} supports
* compilation to bytecode for read access.
*/
@Override
public boolean isCompilable() {
return this.compilable;
}

/**
* Get the return type of the configured read-method.
*/
@Override
public Class<?> getIndexedValueType() {
return this.readMethod.getReturnType();
}

@Override
public void generateCode(SpelNode index, MethodVisitor mv, CodeFlow cf) {
// Ensure the current object on the stack is the target type.
String lastDesc = cf.lastDescriptor();
String targetTypeDesc = CodeFlow.toDescriptor(this.targetType);
if (lastDesc == null || !lastDesc.equals(targetTypeDesc)) {
CodeFlow.insertCheckCast(mv, targetTypeDesc);
}

// Push the index onto the stack.
cf.generateCodeForArgument(mv, index, this.indexType);

// Invoke the read-method.
Assert.state(this.publicDeclaringClass != null, "publicDeclaringClass cannot be null");
String classDesc = this.publicDeclaringClass.getName().replace('.', '/');
String methodName = this.readMethod.getName();
String methodDescr = CodeFlow.createSignatureDescriptor(this.readMethod);
boolean isStatic = Modifier.isStatic(this.readMethod.getModifiers());
boolean isInterface = this.publicDeclaringClass.isInterface();
int opcode = (isStatic ? INVOKESTATIC : isInterface ? INVOKEINTERFACE : INVOKEVIRTUAL);
mv.visitMethodInsn(opcode, classDesc, methodName, methodDescr, isInterface);
}

}
50 changes: 50 additions & 0 deletions spring-expression/src/test/java/example/FruitMap.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* 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 example;

import java.util.HashMap;
import java.util.Map;

/**
* Type that can be indexed by the {@link Color} enum (i.e., something other
* than an int, Integer, or String) and whose indexed values are Strings.
*/
public class FruitMap {

private final Map<Color, String> map = new HashMap<>();

public FruitMap() {
this.map.put(Color.RED, "cherry");
this.map.put(Color.ORANGE, "orange");
this.map.put(Color.YELLOW, "banana");
this.map.put(Color.GREEN, "kiwi");
this.map.put(Color.BLUE, "blueberry");
// We don't map PURPLE so that we can test for an unsupported color.
}

public String getFruit(Color color) {
if (!this.map.containsKey(color)) {
throw new IllegalArgumentException("No fruit for color " + color);
}
return this.map.get(color);
}

public void setFruit(Color color, String fruit) {
this.map.put(color, fruit);
}

}

0 comments on commit 31587e5

Please sign in to comment.