diff --git a/docs/src/main/asciidoc/qute-reference.adoc b/docs/src/main/asciidoc/qute-reference.adoc index bdc22a6d9..1446fd78f 100644 --- a/docs/src/main/asciidoc/qute-reference.adoc +++ b/docs/src/main/asciidoc/qute-reference.adoc @@ -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 diff --git a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java index 8ae002131..27989177e 100644 --- a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java +++ b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java @@ -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 extensionMethods) { IndexView index = beanArchiveIndex.getIndex(); + Map methods = new HashMap<>(); + Map 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 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 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 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()); diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/ReflectionResolverTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/ReflectionResolverTest.java index e26a6bf8d..0a6c17ed0 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/ReflectionResolverTest.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/ReflectionResolverTest.java @@ -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 diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/TemplateExtensionMethodsTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/TemplateExtensionMethodsTest.java new file mode 100644 index 000000000..55e99c70c --- /dev/null +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/TemplateExtensionMethodsTest.java @@ -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; + } + } + +} diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/EvaluatorImpl.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/EvaluatorImpl.java index f1704a4ed..80052ee67 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/EvaluatorImpl.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/EvaluatorImpl.java @@ -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(); + } + } } diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ReflectionValueResolver.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ReflectionValueResolver.java index c32e27374..b025a09ba 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ReflectionValueResolver.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ReflectionValueResolver.java @@ -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 resolve(EvalContext context) { - Object base = context.getBase(); MemberKey key = MemberKey.newInstance(base, context.getName()); MemberWrapper wrapper = memberCache.computeIfAbsent(key, ReflectionValueResolver::findWrapper).orElse(null); diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/TemplateExtension.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/TemplateExtension.java index 55c3cb2ee..234b5a96d 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/TemplateExtension.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/TemplateExtension.java @@ -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. *

- * 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: + *

    + *
  • must be static,
  • + *
  • must not return {@code void},
  • + *
  • must accept at least one parameter.
  • + *

    + * The class of the first parameter is used to match the base object. *

    * 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; * */ @Retention(RUNTIME) -@Target(METHOD) +@Target({ METHOD, TYPE }) public @interface TemplateExtension { static final String ANY = "*"; diff --git a/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/ExtensionMethodGenerator.java b/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/ExtensionMethodGenerator.java index fbf1551a4..9948a4ce4 100644 --- a/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/ExtensionMethodGenerator.java +++ b/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/ExtensionMethodGenerator.java @@ -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 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)); } diff --git a/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/ValueResolverGenerator.java b/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/ValueResolverGenerator.java index 4f2903b8b..bfc7a0f8a 100644 --- a/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/ValueResolverGenerator.java +++ b/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/ValueResolverGenerator.java @@ -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); }