Merge pull request #6444 from mkouba/issue-6438

Qute - support @TemplateExtension declared on a class
This commit is contained in:
Martin Kouba
2020-01-08 17:11:25 +01:00
committed by GitHub
9 changed files with 248 additions and 55 deletions

View File

@@ -538,10 +538,25 @@ NOTE: A value resolver is also generated for all types used in parameter declara
[[template_extension_methods]]
=== Template Extension Methods
A value resolver is automatically generated for a template extension method annotated with `@TemplateExtension`.
The method must be static, must not return `void` and must accept at least one parameter.
The class of the first parameter is used to match the base object and the method name is used to match the property name.
Extension methods can be used to extend the data classes with new functionality.
For example, it is possible to add "computed properties" and "virtual methods".
A value resolver is automatically generated for a method annotated with `@TemplateExtension`.
If declared on a class a value resolver is generated for every non-private method declared on the class.
Methods that do not meet the following requirements are ignored.
A template extension method:
* must be static,
* must not return `void`,
* must accept at least one parameter.
The class of the first parameter is always used to match the base object.
The method name is used to match the property name by default.
However, it is possible to specify the matching name with `TemplateExtension#matchName()`.
NOTE: A special constant - `ANY` - may be used to specify that the extension method matches any name. In that case, the method must declare at least two parameters and the second parameter must be a string.
.Extension Method Example
[source,java]
----
package org.acme;
@@ -555,25 +570,48 @@ class Item {
}
}
@TemplateExtension
class MyExtensions {
@TemplateExtension
static BigDecimal discountedPrice(Item item) { <1>
return item.getPrice().multiply(new BigDecimal("0.9"));
}
}
----
<1> The method matches `Item.class` and `discountedPrice` property name.
<1> This method matches an expression with base object of the type `Item.class` and the `discountedPrice` property name.
This template extension method makes it possible to render the following template:
[source,html]
----
{#each items} <1>
{it.discountedPrice}
{/each}
{item.discountedPrice} <1>
----
<1> `items` is resolved to a list of `org.acme.Item` instances.
<1> `item` is resolved to an instance of `org.acme.Item`.
==== Method Parameters
An extension method may accept multiple parameters.
The first parameter is always used to pass the base object, ie. `org.acme.Item` in the previous example.
Other parameters are resolved when rendering the template and passed to the extension method.
.Multiple Parameters Example
[source,java]
----
@TemplateExtension
class MyExtensions {
static BigDecimal scale(BigDecimal val, int scale, RoundingMode mode) { <1>
return val.setScale(scale, mode);
}
}
----
<1> This method matches an expression with base object of the type `BigDecimal.class`, with the `scale` virtual method name and two virtual method parameters.
[source,html]
----
{item.discountedPrice.scale(2,mode)} <1>
----
<1> `item.discountedPrice` is resolved to an instance of `BigDecimal`.
=== @TemplateData

View File

@@ -98,6 +98,8 @@ public class QuteProcessor {
static final DotName MAP = DotName.createSimple(Map.class.getName());
static final DotName MAP_ENTRY = DotName.createSimple(Entry.class.getName());
private static final String MATCH_NAME = "matchName";
@BuildStep
FeatureBuildItem feature() {
return new FeatureBuildItem(FeatureBuildItem.QUTE);
@@ -255,23 +257,51 @@ public class QuteProcessor {
BuildProducer<TemplateExtensionMethodBuildItem> extensionMethods) {
IndexView index = beanArchiveIndex.getIndex();
Map<MethodInfo, AnnotationInstance> methods = new HashMap<>();
Map<ClassInfo, AnnotationInstance> classes = new HashMap<>();
for (AnnotationInstance templateExtension : index.getAnnotations(ExtensionMethodGenerator.TEMPLATE_EXTENSION)) {
if (templateExtension.target().kind() == Kind.METHOD) {
MethodInfo method = templateExtension.target().asMethod();
ExtensionMethodGenerator.validate(method);
String matchName = null;
AnnotationValue matchNameValue = templateExtension.value("matchName");
if (matchNameValue != null) {
matchName = matchNameValue.asString();
}
if (matchName == null) {
matchName = method.name();
}
extensionMethods.produce(new TemplateExtensionMethodBuildItem(method, matchName,
index.getClassByName(method.parameters().get(0).name())));
methods.put(templateExtension.target().asMethod(), templateExtension);
} else if (templateExtension.target().kind() == Kind.CLASS) {
classes.put(templateExtension.target().asClass(), templateExtension);
}
}
for (Entry<MethodInfo, AnnotationInstance> entry : methods.entrySet()) {
MethodInfo method = entry.getKey();
ExtensionMethodGenerator.validate(method);
produceExtensionMethod(index, extensionMethods, method, entry.getValue());
LOGGER.debugf("Found template extension method %s declared on %s", method,
method.declaringClass().name());
}
for (Entry<ClassInfo, AnnotationInstance> entry : classes.entrySet()) {
ClassInfo clazz = entry.getKey();
for (MethodInfo method : clazz.methods()) {
if (!Modifier.isStatic(method.flags()) || method.returnType().kind() == org.jboss.jandex.Type.Kind.VOID
|| method.parameters().isEmpty() || Modifier.isPrivate(method.flags()) || methods.containsKey(method)) {
continue;
}
produceExtensionMethod(index, extensionMethods, method, entry.getValue());
LOGGER.debugf("Found template extension method %s declared on %s", method,
method.declaringClass().name());
}
}
}
private void produceExtensionMethod(IndexView index, BuildProducer<TemplateExtensionMethodBuildItem> extensionMethods,
MethodInfo method, AnnotationInstance extensionAnnotation) {
String matchName = null;
AnnotationValue matchNameValue = extensionAnnotation.value(MATCH_NAME);
if (matchNameValue != null) {
matchName = matchNameValue.asString();
}
if (matchName == null) {
matchName = method.name();
}
extensionMethods.produce(new TemplateExtensionMethodBuildItem(method, matchName,
index.getClassByName(method.parameters().get(0).name())));
}
@BuildStep
@@ -411,6 +441,9 @@ public class QuteProcessor {
idx = name.lastIndexOf(ValueResolverGenerator.SUFFIX);
}
String className = name.substring(0, idx).replace("/", ".");
if (className.contains(ValueResolverGenerator.NESTED_SEPARATOR)) {
className = className.replace(ValueResolverGenerator.NESTED_SEPARATOR, "$");
}
boolean appClass = appClassPredicate.test(className);
LOGGER.debugf("Writing %s [appClass=%s]", name, appClass);
generatedClass.produce(new GeneratedClassBuildItem(appClass, name, data));
@@ -451,7 +484,7 @@ public class QuteProcessor {
ExtensionMethodGenerator extensionMethodGenerator = new ExtensionMethodGenerator(classOutput);
for (TemplateExtensionMethodBuildItem templateExtension : templateExtensionMethods) {
extensionMethodGenerator.generate(templateExtension.getMethod());
extensionMethodGenerator.generate(templateExtension.getMethod(), templateExtension.getMatchName());
}
generatedTypes.addAll(extensionMethodGenerator.getGeneratedTypes());

View File

@@ -19,8 +19,6 @@ public class ReflectionResolverTest {
static final QuarkusUnitTest config = new QuarkusUnitTest()
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
.addClasses(HelloReflect.class)
// Make sure we do not detect the template data
.addAsResource(new StringAsset("quarkus.qute.detect-template-data=false"), "application.properties")
.addAsResource(new StringAsset("{age}:{ping}:{noMatch}"), "templates/reflect.txt"));
@Inject

View File

@@ -0,0 +1,90 @@
package io.quarkus.qute.deployment;
import static io.quarkus.qute.TemplateExtension.ANY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import java.math.RoundingMode;
import javax.inject.Inject;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import io.quarkus.qute.Engine;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateExtension;
import io.quarkus.test.QuarkusUnitTest;
public class TemplateExtensionMethodsTest {
@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
.addClasses(Foo.class, Extensions.class)
.addAsResource(new StringAsset("{foo.name.toLower} {foo.name.ignored} {foo.callMe(1)} {foo.baz}"),
"templates/foo.txt")
.addAsResource(new StringAsset("{baz.setScale(2,roundingMode)}"),
"templates/baz.txt")
.addAsResource(new StringAsset("{anyInt.foo('bing')}"),
"templates/any.txt"));
@Inject
Template foo;
@Inject
Engine engine;
@Test
public void testTemplateExtensions() {
assertEquals("fantomas NOT_FOUND 11 baz",
foo.data("foo", new Foo("Fantomas", 10l)).render());
}
@Test
public void testMethodParameters() {
assertEquals("123.46",
engine.getTemplate("baz.txt").data("roundingMode", RoundingMode.HALF_UP).data("baz", new BigDecimal("123.4563"))
.render());
}
@Test
public void testMatchAnyWithParameter() {
assertEquals("10=bing",
engine.getTemplate("any.txt").data("anyInt", 10).render());
}
@TemplateExtension
public static class Extensions {
String ignored(String val) {
return val.toLowerCase();
}
static String toLower(String val) {
return val.toLowerCase();
}
static Long callMe(Foo foo, Integer val) {
return foo.age + val;
}
@TemplateExtension(matchName = "baz")
static String override(Foo foo) {
return "baz";
}
static BigDecimal setScale(BigDecimal val, int scale, RoundingMode mode) {
return val.setScale(scale, mode);
}
@TemplateExtension(matchName = ANY)
static String any(Integer val, String name, String info) {
return val + "=" + info;
}
}
}

View File

@@ -85,6 +85,7 @@ class EvaluatorImpl implements Evaluator {
new EvalContextImpl(false, parent.getData(), evalContext.name, parent),
this.resolvers.iterator());
}
LOGGER.tracef("Unable to resolve %s", evalContext);
return Results.NOT_FOUND;
}
ValueResolver resolver = resolvers.next();
@@ -148,6 +149,14 @@ class EvaluatorImpl implements Evaluator {
return resolutionContext.evaluate(expression);
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("EvalContextImpl [tryParent=").append(tryParent).append(", base=").append(base).append(", name=")
.append(name).append(", params=").append(params).append("]");
return builder.toString();
}
}
}

View File

@@ -30,12 +30,16 @@ public class ReflectionValueResolver implements ValueResolver {
@Override
public boolean appliesTo(EvalContext context) {
return context.getBase() != null;
Object base = context.getBase();
if (base == null) {
return false;
}
return memberCache.computeIfAbsent(MemberKey.newInstance(base, context.getName()), ReflectionValueResolver::findWrapper)
.isPresent();
}
@Override
public CompletionStage<Object> resolve(EvalContext context) {
Object base = context.getBase();
MemberKey key = MemberKey.newInstance(base, context.getName());
MemberWrapper wrapper = memberCache.computeIfAbsent(key, ReflectionValueResolver::findWrapper).orElse(null);

View File

@@ -1,16 +1,24 @@
package io.quarkus.qute;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
/**
* A value resolver is automatically generated for a template extension method.
* A value resolver is automatically generated for a method annotated with this annotation. If declared on a class a value
* resolver is generated for every non-private static method declared on the class. Methods that do not meet the following
* requirements are ignored.
* <p>
* The method must be static, must not return {@code void} and must accept at least one parameter. The class of the first
* parameter is used to match the base object.
* A template extension method:
* <ul>
* <li>must be static,</li>
* <li>must not return {@code void},</li>
* <li>must accept at least one parameter.</li>
* <p>
* The class of the first parameter is used to match the base object.
* <p>
* By default, the method name is used to match the property name. However, it is possible to specify the matching name with
* {@link #matchName()}. A special constant - {@link #ANY} - may be used to specify that the extension method matches any name.
@@ -25,7 +33,7 @@ import java.lang.annotation.Target;
* </pre>
*/
@Retention(RUNTIME)
@Target(METHOD)
@Target({ METHOD, TYPE })
public @interface TemplateExtension {
static final String ANY = "*";

View File

@@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
@@ -32,6 +33,7 @@ import org.jboss.jandex.AnnotationValue;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.DotName;
import org.jboss.jandex.MethodInfo;
import org.jboss.jandex.Type;
import org.jboss.jandex.Type.Kind;
public class ExtensionMethodGenerator {
@@ -65,19 +67,21 @@ public class ExtensionMethodGenerator {
}
}
public void generate(MethodInfo method) {
public void generate(MethodInfo method, String matchName) {
// Validate the method first
validate(method);
String matchName = null;
AnnotationInstance extensionAnnotation = method.annotation(TEMPLATE_EXTENSION);
if (extensionAnnotation != null) {
AnnotationValue matchNameValue = extensionAnnotation.value("matchName");
if (matchNameValue != null) {
matchName = matchNameValue.asString();
if (matchName == null) {
AnnotationInstance extensionAnnotation = method.annotation(TEMPLATE_EXTENSION);
if (extensionAnnotation != null) {
AnnotationValue matchNameValue = extensionAnnotation.value("matchName");
if (matchNameValue != null) {
matchName = matchNameValue.asString();
}
}
}
if (matchName == null) {
matchName = method.name();
} else if (matchName.equals(TemplateExtension.ANY)) {
@@ -92,7 +96,8 @@ public class ExtensionMethodGenerator {
ClassInfo declaringClass = method.declaringClass();
String baseName;
if (declaringClass.enclosingClass() != null) {
baseName = simpleName(declaringClass.enclosingClass()) + "_" + simpleName(declaringClass);
baseName = simpleName(declaringClass.enclosingClass()) + ValueResolverGenerator.NESTED_SEPARATOR
+ simpleName(declaringClass);
} else {
baseName = simpleName(declaringClass);
}
@@ -105,12 +110,19 @@ public class ExtensionMethodGenerator {
ClassCreator valueResolver = ClassCreator.builder().classOutput(classOutput).className(generatedName)
.interfaces(ValueResolver.class).build();
implementGetPriority(valueResolver);
implementAppliesTo(valueResolver, method, matchName);
implementResolve(valueResolver, declaringClass, method, matchName);
valueResolver.close();
}
private void implementGetPriority(ClassCreator valueResolver) {
MethodCreator getPriority = valueResolver.getMethodCreator("getPriority", int.class)
.setModifiers(ACC_PUBLIC);
getPriority.returnValue(getPriority.load(5));
}
private void implementResolve(ClassCreator valueResolver, ClassInfo declaringClass, MethodInfo method, String matchName) {
MethodCreator resolve = valueResolver.getMethodCreator("resolve", CompletionStage.class, EvalContext.class)
.setModifiers(ACC_PUBLIC);
@@ -135,12 +147,14 @@ public class ExtensionMethodGenerator {
} else {
ret = resolve
.newInstance(MethodDescriptor.ofConstructor(CompletableFuture.class));
int realParamSize = paramSize - (matchAny ? 2 : 1);
// Evaluate params first
ResultHandle name = resolve.invokeInterfaceMethod(Descriptors.GET_NAME, evalContext);
ResultHandle params = resolve.invokeInterfaceMethod(Descriptors.GET_PARAMS, evalContext);
ResultHandle resultsArray = resolve.newArray(CompletableFuture.class,
resolve.load(paramSize - 1));
for (int i = 0; i < (paramSize - 1); i++) {
resolve.load(realParamSize));
for (int i = 0; i < realParamSize; i++) {
ResultHandle evalResult = resolve.invokeInterfaceMethod(
Descriptors.EVALUATE, evalContext,
resolve.invokeInterfaceMethod(Descriptors.LIST_GET, params,
@@ -159,7 +173,7 @@ public class ExtensionMethodGenerator {
AssignableResultHandle whenName = null;
if (matchAny) {
whenName = whenComplete.createVariable(String.class);
whenComplete.assign(whenName, resolve.invokeInterfaceMethod(Descriptors.GET_NAME, evalContext));
whenComplete.assign(whenName, name);
}
AssignableResultHandle whenRet = whenComplete.createVariable(CompletableFuture.class);
whenComplete.assign(whenRet, ret);
@@ -177,7 +191,7 @@ public class ExtensionMethodGenerator {
args[1] = whenName;
shift++;
}
for (int i = 0; i < (paramSize - 1); i++) {
for (int i = 0; i < realParamSize; i++) {
ResultHandle paramResult = success.readArrayValue(whenResults, i);
args[i + shift] = success.invokeVirtualMethod(Descriptors.COMPLETABLE_FUTURE_GET, paramResult);
}
@@ -202,6 +216,7 @@ public class ExtensionMethodGenerator {
MethodCreator appliesTo = valueResolver.getMethodCreator("appliesTo", boolean.class, EvalContext.class)
.setModifiers(ACC_PUBLIC);
List<Type> parameters = method.parameters();
boolean matchAny = matchName.equals(TemplateExtension.ANY);
ResultHandle evalContext = appliesTo.getMethodParam(0);
ResultHandle base = appliesTo.invokeInterfaceMethod(Descriptors.GET_BASE, evalContext);
@@ -211,7 +226,7 @@ public class ExtensionMethodGenerator {
// Test base object class
ResultHandle baseClass = appliesTo.invokeVirtualMethod(Descriptors.GET_CLASS, base);
ResultHandle testClass = appliesTo.loadClass(method.parameters().get(0).name().toString());
ResultHandle testClass = appliesTo.loadClass(parameters.get(0).name().toString());
ResultHandle baseClassTest = appliesTo.invokeVirtualMethod(Descriptors.IS_ASSIGNABLE_FROM, testClass,
baseClass);
BytecodeCreator baseNotAssignable = appliesTo.ifNonZero(baseClassTest).falseBranch();
@@ -225,17 +240,14 @@ public class ExtensionMethodGenerator {
nameNotMatched.returnValue(nameNotMatched.load(false));
}
int paramSize = method.parameters().size();
if (paramSize > 1) {
// Test number of parameters
ResultHandle params = appliesTo.invokeInterfaceMethod(Descriptors.GET_PARAMS, evalContext);
ResultHandle paramsCount = appliesTo.invokeInterfaceMethod(Descriptors.COLLECTION_SIZE, params);
BranchResult paramsTest = appliesTo
.ifNonZero(appliesTo.invokeStaticMethod(Descriptors.INTEGER_COMPARE,
appliesTo.load(paramSize - (matchAny ? 2 : 1)), paramsCount));
BytecodeCreator paramsNotMatching = paramsTest.trueBranch();
paramsNotMatching.returnValue(paramsNotMatching.load(false));
}
// Test number of parameters
ResultHandle params = appliesTo.invokeInterfaceMethod(Descriptors.GET_PARAMS, evalContext);
ResultHandle paramsCount = appliesTo.invokeInterfaceMethod(Descriptors.COLLECTION_SIZE, params);
BranchResult paramsTest = appliesTo
.ifNonZero(appliesTo.invokeStaticMethod(Descriptors.INTEGER_COMPARE,
appliesTo.load(parameters.size() - (matchAny ? 2 : 1)), paramsCount));
BytecodeCreator paramsNotMatching = paramsTest.trueBranch();
paramsNotMatching.returnValue(paramsNotMatching.load(false));
appliesTo.returnValue(appliesTo.load(true));
}

View File

@@ -63,6 +63,7 @@ public class ValueResolverGenerator {
private static final DotName OBJECT = DotName.createSimple(Object.class.getName());
public static final String SUFFIX = "_ValueResolver";
public static final String NESTED_SEPARATOR = "$_";
private static final Logger LOGGER = Logger.getLogger(ValueResolverGenerator.class);
@@ -112,7 +113,7 @@ public class ValueResolverGenerator {
String baseName;
if (clazz.enclosingClass() != null) {
baseName = simpleName(clazz.enclosingClass()) + "_" + simpleName(clazz);
baseName = simpleName(clazz.enclosingClass()) + NESTED_SEPARATOR + simpleName(clazz);
} else {
baseName = simpleName(clazz);
}