From 39b65c94507cb6b3d933e086d346915487c08f14 Mon Sep 17 00:00:00 2001 From: Adrian Kunz Date: Wed, 17 Jul 2024 13:51:39 +0200 Subject: [PATCH] feat: `@Param` `method` option (#120) * feat(framework): Add @Param method and type * feat(framework): Implement @Param method and type in ReflectionSidecar * feat(processor): Implement @Param method and type in FxClassGenerator * test(processor): Update ParamController * docs: Update parameters and data flow docs * chore: Remove unused import * docs: Clarify bind/unbind in data flow * fix(framework): ReflectionSidecar error cases * docs: Update ERROR_CODES.md --------- Co-authored-by: Clashsoft --- ERROR_CODES.md | 2 +- .../java/org/fulib/fx/FxClassGenerator.java | 63 +++++-------------- docs/controller/4-parameters.md | 21 +++---- docs/tutorial/data-flow.md | 31 ++++++--- .../org/fulib/fx/annotation/param/Param.java | 12 ++++ .../internal/ReflectionSidecar.java | 19 +++--- .../org/fulib/fx/lang/error.properties | 2 +- .../fx/app/controller/ParamController.java | 2 +- 8 files changed, 74 insertions(+), 78 deletions(-) diff --git a/ERROR_CODES.md b/ERROR_CODES.md index ba7c82b9..ea6bab1c 100644 --- a/ERROR_CODES.md +++ b/ERROR_CODES.md @@ -413,7 +413,7 @@ This can happen if one uses `show("../")` whilst already being at the empty rout This error is thrown when the framework fails to put a parameter value into a field. -### 4001: `Couldn't call setter method with parameter '*' for field '*' in class '*'.` +### 4001: `Couldn't call setter method '*' with parameter '*' for field '*' in class '*'.` - Runtime: ✅ - Annotation Processor: ❌ diff --git a/annotation-processor/src/main/java/org/fulib/fx/FxClassGenerator.java b/annotation-processor/src/main/java/org/fulib/fx/FxClassGenerator.java index 202c00aa..e15981b0 100644 --- a/annotation-processor/src/main/java/org/fulib/fx/FxClassGenerator.java +++ b/annotation-processor/src/main/java/org/fulib/fx/FxClassGenerator.java @@ -12,10 +12,7 @@ import org.fulib.fx.util.ControllerUtil; import javax.annotation.processing.ProcessingEnvironment; -import javax.lang.model.element.ExecutableElement; -import javax.lang.model.element.Modifier; -import javax.lang.model.element.TypeElement; -import javax.lang.model.element.VariableElement; +import javax.lang.model.element.*; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.ExecutableType; import javax.lang.model.type.TypeMirror; @@ -30,16 +27,6 @@ public class FxClassGenerator { private final ProcessingEnvironment processingEnv; private final ProcessingHelper helper; - /** - * The (erased) `javafx.beans.value.WritableValue` type. - */ - private final TypeMirror writableValue; - - /** - * The `setValue` method of `javafx.beans.value.WritableValue`. - */ - private final ExecutableElement genericSetValue; - /** * The `javafx.scene.Parent` type. */ @@ -53,17 +40,6 @@ public FxClassGenerator(ProcessingHelper helper, ProcessingEnvironment processin this.helper = helper; this.processingEnv = processingEnv; - final TypeElement writableValue = processingEnv.getElementUtils().getTypeElement("javafx.beans.value.WritableValue"); - this.writableValue = processingEnv.getTypeUtils().erasure(writableValue.asType()); - - genericSetValue = writableValue.getEnclosedElements() - .stream() - .filter(e -> e instanceof ExecutableElement) - .map(e -> (ExecutableElement) e) - .filter(e -> "setValue".equals(e.getSimpleName().toString())) - .findFirst() - .orElseThrow(); - parent = processingEnv.getElementUtils().getTypeElement("javafx.scene.Parent").asType(); pane = processingEnv.getElementUtils().getTypeElement("javafx.scene.layout.Pane").asType(); } @@ -154,29 +130,20 @@ private void generateParametersIntoFields(PrintWriter out, TypeElement component out.printf(" if (params.containsKey(%s)) {%n", paramNameLiteral); // TODO field must be public, package-private or protected -- add a diagnostic if it's private - if (processingEnv.getTypeUtils().isAssignable(field.asType(), writableValue)) { - // We use the `setValue` method to infer the actual type of the field, - // E.g. if the field is a `StringProperty` which extends `WritableValue`, - // we can infer that the actual type is `String`. - final ExecutableType asMemberOf = (ExecutableType) processingEnv.getTypeUtils().asMemberOf((DeclaredType) field.asType(), genericSetValue); - final TypeMirror typeArg = asMemberOf.getParameterTypes().get(0); - final String writableType = typeArg.toString(); - if (field.getModifiers().contains(Modifier.FINAL)) { - out.printf(" instance.%s.setValue((%s) params.get(%s));%n", fieldName, writableType, paramNameLiteral); - } else { - // final Object param = params.get(); - // if (param instanceof ) { - // instance. = () param); - // } else { - // instance..setValue(() param); - // } - out.printf(" final Object param = params.get(%s);%n", paramNameLiteral); - out.printf(" if (param instanceof %s) {%n", writableValue); - out.printf(" instance.%s = (%s) param;%n", fieldName, fieldType); - out.println(" } else {"); - out.printf(" instance.%s.setValue((%s) param);%n", fieldName, writableType); - out.println(" }"); - } + final String methodName = param.method(); + if (methodName != null && !methodName.isEmpty()) { + // this is some hacky way to get the @Param().type(). + // param.type() does not work because we cannot access the Class instance at compile time. + // So we have to read the AnnotationMirror. + final AnnotationMirror paramMirror = field.getAnnotationMirrors().stream() + .filter(a -> "org.fulib.fx.annotation.param.Param".equals(a.getAnnotationType().toString())) + .findFirst().orElseThrow(); + final String methodParamType = paramMirror.getElementValues().values().stream() + .map(Object::toString) + .filter(s -> s.endsWith(".class")) + .map(s -> s.substring(0, s.length() - 6)) // remove ".class" + .findFirst().orElse("Object"); // else case also happens when type is not specified in the annotation (default Object) + out.printf(" instance.%s.%s((%s) params.get(%s));%n", fieldName, methodName, methodParamType, paramNameLiteral); } else { out.printf(" instance.%s = (%s) params.get(%s);%n", fieldName, fieldType, paramNameLiteral); } diff --git a/docs/controller/4-parameters.md b/docs/controller/4-parameters.md index d644e1e9..1402c701 100644 --- a/docs/controller/4-parameters.md +++ b/docs/controller/4-parameters.md @@ -4,15 +4,11 @@ To pass parameters to a controller, an additional argument can be provided to th strings and objects. The strings specify the argument's name and the objects are the value of the argument. For example, `show("/route/to/controller", Map.of("key", value, "key2", value2))` will pass the value `value` to the argument `key`. -To use a passed argument in a field or method, you have to annotate it with `@Param("key")`. The name of the parameter -will be used to match it to the map of parameters passed to the `show()` method. If the annotation is used on a field, -the field will be injected with the value of the parameter before the controller is initialized. If the annotation is used -on a method, the method will be called with the value of the parameter before the controller is initialized. If the -annotation is used on a method parameter of a render/init method, the method will be called with the value of the parameter. - -If `@Param` is used on a field containing a `WriteableValue` (e.g. a `StringProperty`), its value will be set to the -parameter's value if the parameter has the correct type (e.g. a `String` for a `StringProperty`). If the parameter is -a `WritableValue` as well, the logic will be the same as for a normal field. +To use a passed argument in a field or method, you have to annotate it with `@Param("key")`. +The name of the parameter will be used to match it to the map of parameters passed to the `show()` method. +If the annotation is used on a field, the field will be injected with the value of the parameter before the controller is initialized. +If the annotation is used on a method, the method will be called with the value of the parameter before the controller is initialized. +If the annotation is used on a method parameter of a render/init method, the method will be called with the value of the parameter. Instead of accessing the parameters one by one, you can also use the `@ParamsMap` annotation to inject a map of all parameters. This annotation can be used for fields and method parameters of type `Map`. If the annotated field is final, @@ -34,9 +30,10 @@ public class FooController { @Param("baba") private Bar bar; - // The set method of the writable value will be called with the parameter 'fofo' - @Param("fofo") - private ObjectProperty foo = new SimpleObjectProperty<>(); + // The setValue(T) method of the ObjectProperty will be called with the parameter named 'fofo' + // Note that the erased parameter type of ObjectProperty.setValue is Object, so we specify that as the type. + @Param(value = "fofo", method = "setValue", type = Object.class) + private final ObjectProperty foo = new SimpleObjectProperty<>(); // This field will be injected with a map of all parameters before the controller is initialized @ParamsMap diff --git a/docs/tutorial/data-flow.md b/docs/tutorial/data-flow.md index e442a71f..1c6084d5 100644 --- a/docs/tutorial/data-flow.md +++ b/docs/tutorial/data-flow.md @@ -119,18 +119,36 @@ Instead of defining a Runnable or Consumer directly, you can create a Property a ```java @Component public class MyComponent { - - @Param("colorChange") + // option 1: The subcomponent reuses the parent's property instance. + @Param("color") ObjectProperty color; - @Param("colorBind") - // As this field is final, bind() will be used instead - final ObjectProperty otherColor = new SimpleObjectProperty<>(); + // Option 2: The subcomponent uses its own property instance and binds it to the parent's property. + // Writing to this property will NOT update the parent's property. + // We have to unbind using destroy. + @Param(value = "color", method = "bind", type = Object.class) + final ObjectProperty colorBind = new SimpleObjectProperty<>(); + + // Option 3: The subcomponent uses its own property instance and binds it to the parent's property bidirectionally. + // Writing to this property will update the parent's property. + // We have to manually bind using subscriber to allow for unbinding. + final ObjectProperty colorBindBidi = new SimpleObjectProperty<>(); + Subscriber subscriber = ...; + + @OnInit + void init(@Param("color") ObjectProperty color) { + subscriber.bindBidirectional(colorBindBidi, color); + } void onButtonClicked() { Color newColor = ...; color.set(newColor); } + + @OnDestroy + void destroy() { + colorBind.unbind(); + } } ``` @@ -143,8 +161,7 @@ public class MyController { @OnRender void createSubs() { ObjectProperty color = new SimpleObjectProperty(Color.WHITE); - ObjectProperty colorBind = new SimpleObjectProperty(Color.WHITE); - MyComponent component = app.initAndRender(new MyComponent(), Map.of("colorChange", color, "colorBind", colorBind)); + MyComponent component = app.initAndRender(new MyComponent(), Map.of("color", color)); subscriber.listen(color, (observable, oldValue, newValue) -> { // Use subscribers to prevent memory leaks System.out.println(newValue); }); diff --git a/framework/src/main/java/org/fulib/fx/annotation/param/Param.java b/framework/src/main/java/org/fulib/fx/annotation/param/Param.java index b333e06d..c7e723ca 100644 --- a/framework/src/main/java/org/fulib/fx/annotation/param/Param.java +++ b/framework/src/main/java/org/fulib/fx/annotation/param/Param.java @@ -31,4 +31,16 @@ */ String value(); + /** + * The method of the field's class that will be called with the parameter's value. + * Useful for {@code final} fields or {@link javafx.beans.property.Property} fields. + * @return the method name + */ + String method() default ""; + + /** + * When using {@link #method()}, this specifies the type of the first (and only) parameter of that method. + * @return the type of the method parameter + */ + Class type() default Object.class; } diff --git a/framework/src/main/java/org/fulib/fx/controller/internal/ReflectionSidecar.java b/framework/src/main/java/org/fulib/fx/controller/internal/ReflectionSidecar.java index e414c84a..e7670f70 100644 --- a/framework/src/main/java/org/fulib/fx/controller/internal/ReflectionSidecar.java +++ b/framework/src/main/java/org/fulib/fx/controller/internal/ReflectionSidecar.java @@ -1,6 +1,5 @@ package org.fulib.fx.controller.internal; -import javafx.beans.value.WritableValue; import javafx.event.EventHandler; import javafx.event.EventType; import javafx.scene.Node; @@ -146,18 +145,22 @@ private void fillParametersIntoFields(@NotNull Object instance, @NotNull Map<@No Object value = parameters.get(param); Object fieldValue = field.get(instance); - // If the field is a WriteableValue, use the setValue method - if (WritableValue.class.isAssignableFrom(fieldType) && !(value instanceof WritableValue)) { + String method = paramAnnotation.method(); + if (method != null && !method.isEmpty()) { - // We cannot call setValue on a non-existing property + // We cannot call the method on a non-existing property if (fieldValue == null) { - throw new RuntimeException(error(4001).formatted(param, field.getName(), instance.getClass().getName())); + throw new RuntimeException(error(4001).formatted(method, param, field.getName(), instance.getClass().getName())); } + final Class methodParamType = paramAnnotation.type(); try { - ((WritableValue) field.get(instance)).setValue(value); - } catch (ClassCastException e) { - throw new RuntimeException(error(4007).formatted(param, field.getName(), instance.getClass().getName(), fieldType.getName(), value == null ? "null" : value.getClass().getName())); + final Method methodName = field.getType().getMethod(method, methodParamType); + methodName.invoke(fieldValue, value); + } catch (IllegalArgumentException iae) { // parameter types don't match + throw new RuntimeException(error(4007).formatted(param, field.getName(), instance.getClass().getName(), methodParamType.getName(), value == null ? "null" : value.getClass().getName()), iae); + } catch (ReflectiveOperationException roe) { // method not found + throw new RuntimeException(error(4001).formatted(method, param, field.getName(), instance.getClass().getName()), roe); } } diff --git a/framework/src/main/resources/org/fulib/fx/lang/error.properties b/framework/src/main/resources/org/fulib/fx/lang/error.properties index 1fadcf05..9ec2b304 100644 --- a/framework/src/main/resources/org/fulib/fx/lang/error.properties +++ b/framework/src/main/resources/org/fulib/fx/lang/error.properties @@ -37,7 +37,7 @@ # Parameters 4000=Couldn't fill parameter '%s' into field '%s' in class '%s'. -4001=Couldn't call setter method with parameter '%s' for field '%s' in class '%s'. +4001=Couldn't call setter method '%s' with parameter '%s' for field '%s' in class '%s'. 4002=Field '%s' annotated with @ParamsMap in class '%s' is not of type Map. 4003=Method '%s' annotated with @ParamsMap in class '%s' must have exactly one parameter of type Map. 4004=Parameter '%s' annotated with @ParamsMap in method '%s' in class '%s' is not of type Map. diff --git a/framework/src/test/java/org/fulib/fx/app/controller/ParamController.java b/framework/src/test/java/org/fulib/fx/app/controller/ParamController.java index 32f8e6de..82999b3e 100644 --- a/framework/src/test/java/org/fulib/fx/app/controller/ParamController.java +++ b/framework/src/test/java/org/fulib/fx/app/controller/ParamController.java @@ -21,7 +21,7 @@ public class ParamController { private String setterParam; @Param("integer") int fieldParam; - @Param("string") + @Param(value = "string", method = "set", type = String.class) final StringProperty fieldPropertyParam = new SimpleStringProperty(); private Map onInitParamsMap;