Skip to content

Commit

Permalink
feat: @Param method option (#120)
Browse files Browse the repository at this point in the history
* 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 <Clashsoft@users.noreply.github.com>
  • Loading branch information
Clashsoft and Clashsoft authored Jul 17, 2024
1 parent e207f66 commit 39b65c9
Show file tree
Hide file tree
Showing 8 changed files with 74 additions and 78 deletions.
2 changes: 1 addition & 1 deletion ERROR_CODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ❌
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*/
Expand All @@ -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();
}
Expand Down Expand Up @@ -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<String>`,
// 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(<paramNameLiteral>);
// if (param instanceof <writableValue>) {
// instance.<fieldName> = (<fieldType>) param);
// } else {
// instance.<fieldName>.setValue((<writableType>) 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);
}
Expand Down
21 changes: 9 additions & 12 deletions docs/controller/4-parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object>`. If the annotated field is final,
Expand All @@ -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> 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> foo = new SimpleObjectProperty<>();

// This field will be injected with a map of all parameters before the controller is initialized
@ParamsMap
Expand Down
31 changes: 24 additions & 7 deletions docs/tutorial/data-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> color;

@Param("colorBind")
// As this field is final, bind() will be used instead
final ObjectProperty<Color> 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<Color> 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<Color> colorBindBidi = new SimpleObjectProperty<>();
Subscriber subscriber = ...;

@OnInit
void init(@Param("color") ObjectProperty<Color> color) {
subscriber.bindBidirectional(colorBindBidi, color);
}

void onButtonClicked() {
Color newColor = ...;
color.set(newColor);
}

@OnDestroy
void destroy() {
colorBind.unbind();
}
}
```

Expand All @@ -143,8 +161,7 @@ public class MyController {
@OnRender
void createSubs() {
ObjectProperty<Color> color = new SimpleObjectProperty(Color.WHITE);
ObjectProperty<Color> 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);
});
Expand Down
12 changes: 12 additions & 0 deletions framework/src/main/java/org/fulib/fx/annotation/param/Param.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Object>) 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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object>.
4003=Method '%s' annotated with @ParamsMap in class '%s' must have exactly one parameter of type Map<String, Object>.
4004=Parameter '%s' annotated with @ParamsMap in method '%s' in class '%s' is not of type Map<String, Object>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> onInitParamsMap;
Expand Down

0 comments on commit 39b65c9

Please sign in to comment.