Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SpEL support for registered MethodHandles #30045

Merged
merged 1 commit into from
May 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion framework-docs/modules/ROOT/pages/core/expressions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ The expression language supports the following functionality:
* Inline maps
* Ternary operator
* Variables
* User-defined functions
* User-defined functions added to the context
* reflective invocation of `Method`
* various cases of `MethodHandle`
* Collection projection
* Collection selection
* Templated expressions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ topics:
* xref:core/expressions/language-ref/types.adoc[Types]
* xref:core/expressions/language-ref/constructors.adoc[Constructors]
* xref:core/expressions/language-ref/variables.adoc[Variables]
* xref:core/expressions/language-ref/functions.adoc[Functions]
* xref:core/expressions/language-ref/functions.adoc[User-Defined Functions]
* xref:core/expressions/language-ref/bean-references.adoc[Bean References]
* xref:core/expressions/language-ref/operator-ternary.adoc[Ternary Operator (If-Then-Else)]
* xref:core/expressions/language-ref/operator-elvis.adoc[The Elvis Operator]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

You can extend SpEL by registering user-defined functions that can be called within the
expression string. The function is registered through the `EvaluationContext`. The
following example shows how to register a user-defined function:
following example shows how to register a user-defined function to be invoked via reflection
(i.e. a `Method`):

[tabs]
======
Expand Down Expand Up @@ -94,5 +95,97 @@ Kotlin::
----
======

The use of `MethodHandle` is also supported. This enables potentially more efficient use
cases if the `MethodHandle` target and parameters have been fully bound prior to
registration, but partially bound handles are also supported.

Consider the `String#formatted(String, Object...)` instance method, which produces a
message according to a template and a variable number of arguments.

You can register and use the `formatted` method as a `MethodHandle`, as the following
example shows:

[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes",role="primary"]
----
ExpressionParser parser = new SpelExpressionParser();
EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();

MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted",
MethodType.methodType(String.class, Object[].class));
context.setVariable("message", mh);

String message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')")
.getValue(context, String.class);
//returns "Simple message: <Hello World>"
----

Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"]
----
val parser = SpelExpressionParser()
val context = SimpleEvaluationContext.forReadOnlyDataBinding().build()

val mh = MethodHandles.lookup().findVirtual(String::class.java, "formatted",
MethodType.methodType(String::class.java, Array<Any>::class.java))
context.setVariable("message", mh)

val message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')")
.getValue(context, String::class.java)
----
======

As hinted above, binding a `MethodHandle` and registering the bound `MethodHandle` is also
supported. This is likely to be more performant if both the target and all the arguments
are bound. In that case no arguments are necessary in the SpEL expression, as the
following example shows:

[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes",role="primary"]
----
ExpressionParser parser = new SpelExpressionParser();
EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();

String template = "This is a %s message with %s words: <%s>";
Object varargs = new Object[] { "prerecorded", 3, "Oh Hello World!", "ignored" };
MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted",
MethodType.methodType(String.class, Object[].class))
.bindTo(template)
.bindTo(varargs); //here we have to provide arguments in a single array binding
context.setVariable("message", mh);

String message = parser.parseExpression("#message()")
.getValue(context, String.class);
//returns "This is a prerecorded message with 3 words: <Oh Hello World!>"
----

Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"]
----
val parser = SpelExpressionParser()
val context = SimpleEvaluationContext.forReadOnlyDataBinding().build()

val template = "This is a %s message with %s words: <%s>"
val varargs = arrayOf("prerecorded", 3, "Oh Hello World!", "ignored")

val mh = MethodHandles.lookup().findVirtual(String::class.java, "formatted",
MethodType.methodType(String::class.java, Array<Any>::class.java))
.bindTo(template)
.bindTo(varargs) //here we have to provide arguments in a single array binding
context.setVariable("message", mh)

val message = parser.parseExpression("#message()")
.getValue(context, String::class.java)
----
======



Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

package org.springframework.expression.spel.ast;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.StringJoiner;
Expand Down Expand Up @@ -70,7 +72,17 @@ public TypedValue getValueInternal(ExpressionState state) throws EvaluationExcep
if (value == TypedValue.NULL) {
throw new SpelEvaluationException(getStartPosition(), SpelMessage.FUNCTION_NOT_DEFINED, this.name);
}
if (!(value.getValue() instanceof Method function)) {
Object resolvedValue = value.getValue();
if (resolvedValue instanceof MethodHandle methodHandle) {
try {
return executeFunctionBoundMethodHandle(state, methodHandle);
}
catch (SpelEvaluationException ex) {
ex.setPosition(getStartPosition());
throw ex;
}
}
if (!(resolvedValue instanceof Method function)) {
// Possibly a static Java method registered as a function
throw new SpelEvaluationException(
SpelMessage.FUNCTION_REFERENCE_CANNOT_BE_INVOKED, this.name, value.getClass());
Expand Down Expand Up @@ -138,6 +150,78 @@ private TypedValue executeFunctionJLRMethod(ExpressionState state, Method method
}
}

/**
* Execute a function represented as {@code java.lang.invoke.MethodHandle}.
* Method types that take no arguments (fully bound handles or static methods
* with no parameters) can use {@code #invoke()} which is the most efficient.
* Otherwise, {@code #invokeWithArguments)} is used.
* @param state the expression evaluation state
* @param methodHandle the method to invoke
* @return the return value of the invoked Java method
* @throws EvaluationException if there is any problem invoking the method
* @since 6.1.0
*/
private TypedValue executeFunctionBoundMethodHandle(ExpressionState state, MethodHandle methodHandle) throws EvaluationException {
Object[] functionArgs = getArguments(state);
MethodType declaredParams = methodHandle.type();
int spelParamCount = functionArgs.length;
int declaredParamCount = declaredParams.parameterCount();

boolean isSuspectedVarargs = declaredParams.lastParameterType().isArray();

if (spelParamCount < declaredParamCount || (spelParamCount > declaredParamCount
&& !isSuspectedVarargs)) {
//incorrect number, including more arguments and not a vararg
throw new SpelEvaluationException(SpelMessage.INCORRECT_NUMBER_OF_ARGUMENTS_TO_FUNCTION,
functionArgs.length, declaredParamCount);
//perhaps a subset of arguments was provided but the MethodHandle wasn't bound?
}

// simplest case: the MethodHandle is fully bound or represents a static method with no params:
if (declaredParamCount == 0) {
//note we consider MethodHandles not compilable
try {
return new TypedValue(methodHandle.invoke());
}
catch (Throwable ex) {
throw new SpelEvaluationException(getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_FUNCTION_CALL,
this.name, ex.getMessage());
}
finally {
this.exitTypeDescriptor = null;
this.method = null;
}
}

// more complex case, we need to look at conversion and vararg repacking
Integer varArgPosition = null;
if (isSuspectedVarargs) {
varArgPosition = declaredParamCount - 1;
}
TypeConverter converter = state.getEvaluationContext().getTypeConverter();
boolean conversionOccurred = ReflectionHelper.convertAllMethodHandleArguments(converter,
functionArgs, methodHandle, varArgPosition);

if (isSuspectedVarargs && declaredParamCount == 1) {
//we only repack the varargs if it is the ONLY argument
functionArgs = ReflectionHelper.setupArgumentsForVarargsInvocation(
methodHandle.type().parameterArray(), functionArgs);
}

//note we consider MethodHandles not compilable
try {
return new TypedValue(methodHandle.invokeWithArguments(functionArgs));
}
catch (Throwable ex) {
throw new SpelEvaluationException(getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_FUNCTION_CALL,
this.name, ex.getMessage());
}
finally {
this.exitTypeDescriptor = null;
this.method = null;
}
}

@Override
public String toStringAST() {
StringJoiner sj = new StringJoiner(",", "(", ")");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@

package org.springframework.expression.spel.support;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
import java.lang.reflect.Array;
import java.lang.reflect.Executable;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Optional;

import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.expression.EvaluationException;
import org.springframework.expression.TypeConverter;
Expand Down Expand Up @@ -330,6 +333,91 @@ else if (!sourceType.equals(targetType.getElementTypeDescriptor())) {
return conversionOccurred;
}

/**
* Takes an input set of argument values and converts them to the types specified as the
* required parameter types. The arguments are converted 'in-place' in the input array.
* @param converter the type converter to use for attempting conversions
* @param arguments the actual arguments that need conversion
* @param methodHandle the target MethodHandle
* @param varargsPosition the known position of the varargs argument, if any
* ({@code null} if not varargs)
* @return {@code true} if some kind of conversion occurred on an argument
* @throws EvaluationException if a problem occurs during conversion
* @since 6.1.0
*/
public static boolean convertAllMethodHandleArguments(TypeConverter converter, Object[] arguments,
MethodHandle methodHandle, @Nullable Integer varargsPosition) throws EvaluationException {
boolean conversionOccurred = false;
final MethodType methodHandleArgumentTypes = methodHandle.type();
if (varargsPosition == null) {
for (int i = 0; i < arguments.length; i++) {
Class<?> argumentClass = methodHandleArgumentTypes.parameterType(i);
ResolvableType resolvableType = ResolvableType.forClass(argumentClass);
TypeDescriptor targetType = new TypeDescriptor(resolvableType, argumentClass, null);
Copy link
Contributor Author

@simonbasle simonbasle Mar 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here the subtle difference compared to the older convertAllMethodArguments higher in the source file is that we get TypeDescriptor out of a raw Class only (vs a MethodParam obtained via reflection with MethodParameter.forExecutable)


Object argument = arguments[i];
arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), targetType);
conversionOccurred |= (argument != arguments[i]);
}
}
else {
// Convert everything up to the varargs position
for (int i = 0; i < varargsPosition; i++) {
Class<?> argumentClass = methodHandleArgumentTypes.parameterType(i);
ResolvableType resolvableType = ResolvableType.forClass(argumentClass);
TypeDescriptor targetType = new TypeDescriptor(resolvableType, argumentClass, null);

Object argument = arguments[i];
arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), targetType);
conversionOccurred |= (argument != arguments[i]);
}

final Class<?> varArgClass = methodHandleArgumentTypes.lastParameterType().getComponentType();
ResolvableType varArgResolvableType = ResolvableType.forClass(varArgClass);
TypeDescriptor varArgContentType = new TypeDescriptor(varArgResolvableType, varArgClass, null);

// If the target is varargs and there is just one more argument, then convert it here.
if (varargsPosition == arguments.length - 1) {
Object argument = arguments[varargsPosition];
TypeDescriptor sourceType = TypeDescriptor.forObject(argument);
if (argument == null) {
// Perform the equivalent of GenericConversionService.convertNullSource() for a single argument.
if (varArgContentType.getElementTypeDescriptor().getObjectType() == Optional.class) {
arguments[varargsPosition] = Optional.empty();
conversionOccurred = true;
}
}
// If the argument type is equal to the varargs element type, there is no need to
// convert it or wrap it in an array. For example, using StringToArrayConverter to
// convert a String containing a comma would result in the String being split and
// repackaged in an array when it should be used as-is.
else if (!sourceType.equals(varArgContentType.getElementTypeDescriptor())) {
arguments[varargsPosition] = converter.convertValue(argument, sourceType, varArgContentType);
}
// Possible outcomes of the above if-else block:
// 1) the input argument was null, and nothing was done.
// 2) the input argument was null; the varargs element type is Optional; and the argument was converted to Optional.empty().
// 3) the input argument was correct type but not wrapped in an array, and nothing was done.
// 4) the input argument was already compatible (i.e., array of valid type), and nothing was done.
// 5) the input argument was the wrong type and got converted and wrapped in an array.
if (argument != arguments[varargsPosition] &&
!isFirstEntryInArray(argument, arguments[varargsPosition])) {
conversionOccurred = true; // case 5
}
}
// Otherwise, convert remaining arguments to the varargs element type.
else {
Assert.state(varArgContentType != null, "No element type");
for (int i = varargsPosition; i < arguments.length; i++) {
Object argument = arguments[i];
arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), varArgContentType);
conversionOccurred |= (argument != arguments[i]);
}
}
}
return conversionOccurred;
}

/**
* Check if the supplied value is the first entry in the array represented by the possibleArray value.
* @param value the value to check for in the array
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package org.springframework.expression.spel.support;

import java.lang.invoke.MethodHandle;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
Expand Down Expand Up @@ -251,6 +252,10 @@ public void registerFunction(String name, Method method) {
this.variables.put(name, method);
}

public void registerFunction(String name, MethodHandle methodHandle) {
this.variables.put(name, methodHandle);
}

@Override
@Nullable
public Object lookupVariable(String name) {
Expand Down
Loading