Support generated ViewModel defaults and stored property access
This commit is contained in:
@@ -503,3 +503,7 @@ These are the `IllegalArgumentException` messages a document can produce. The vi
|
|||||||
| `No component found for type:` | the container was asked for a controller, component, or converter it did not scan |
|
| `No component found for type:` | the container was asked for a controller, component, or converter it did not scan |
|
||||||
|
|
||||||
A conversion failure does not fail the load. It records a message, as [Converter](#converter) describes.
|
A conversion failure does not fail the load. It records a message, as [Converter](#converter) describes.
|
||||||
|
|
||||||
|
Generated Java ViewModels can share the existing runtime storage and identity registry; see
|
||||||
|
[the generated ViewModel runtime contract](docs/generated-viewmodels.md) for stored-property
|
||||||
|
access, declarative service defaults, compatible overrides and module-path registration.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Generated ViewModel runtime contract
|
||||||
|
|
||||||
|
A generator may extend `ViewModel` with typed constructors and accessors. Accessors call the protected final `storedProperty(name)` method, which returns the existing Attribute property and never invokes a reflective accessor. Unknown stored Attributes fail explicitly. Property storage, write-back, lazy nested Models, collection contents and Messages remain owned by `ViewModel`.
|
||||||
|
|
||||||
|
Modules contribute public `ViewModelRegistry` marker services annotated with repeatable `ViewModelRegistration` declarations. Each declaration names the configured Model, generated base and selected default class. Registries are inspected through `ServiceLoader.Provider.type()` without constructing them; their annotations are also available to non-initializing designer discovery. Classpath applications package `META-INF/services/de.neitzel.fx.view.ViewModelRegistry`; named modules declare `provides` and export/open generated ViewModel packages as appropriate.
|
||||||
|
|
||||||
|
`ViewModelDefaults.select` checks duplicate/conflicting defaults, finds the most-specific applicable Model declaration, and requires an explicit override to extend the corresponding generated base. No declaration preserves generic/default replacement behavior. A compatible explicit request takes precedence before creation. Existing instances are reused for a requested superclass/interface that they satisfy; incompatible requests fail. Nested lazy loading uses the same registry and identity table as direct loading. Failed subclass construction removes its partially registered instance.
|
||||||
|
|
||||||
|
Generated Java source and compiler integration live in NeitzelFXDesigner. This library API is a development snapshot, not a published release. Runtime regressions cover stored identity/write-back, cycles, null children, Messages, compatible/incompatible requests, most-specific defaults, provider non-execution, conflicting contributions and constructor failure cleanup. Existing ungenerated Model and Component tests remain required.
|
||||||
@@ -169,6 +169,13 @@ public class ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns a stored Attribute only, without invoking a generated or custom accessor. */
|
||||||
|
protected final Property<?> storedProperty(String name) {
|
||||||
|
Property<?> property = properties.get(name);
|
||||||
|
if (property == null) throw new IllegalArgumentException("No stored Attribute: " + name);
|
||||||
|
return property;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the JavaFX property for an Attribute name.
|
* Returns the JavaFX property for an Attribute name.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Resolves declarative services without constructing providers or Models. */
|
||||||
|
public final class ViewModelDefaults {
|
||||||
|
private ViewModelDefaults() {}
|
||||||
|
|
||||||
|
public static Class<?> select(Class<?> model, Class<?> explicit, ClassLoader loader) {
|
||||||
|
var entries = new HashMap<Class<?>, ViewModelRegistration>();
|
||||||
|
var origins = new HashMap<Class<?>, String>();
|
||||||
|
for (var provider : ServiceLoader.load(ViewModelRegistry.class, loader).stream().toList()) {
|
||||||
|
Class<?> type = provider.type();
|
||||||
|
for (var entry : type.getAnnotationsByType(ViewModelRegistration.class)) {
|
||||||
|
if (!entry.generated().isAssignableFrom(entry.defaultType())) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Default must extend generated ViewModel: " + type.getName());
|
||||||
|
}
|
||||||
|
var old = entries.putIfAbsent(entry.model(), entry);
|
||||||
|
if (old != null
|
||||||
|
&& (old.generated() != entry.generated()
|
||||||
|
|| old.defaultType() != entry.defaultType())) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Conflicting ViewModel defaults for "
|
||||||
|
+ entry.model().getName()
|
||||||
|
+ " in "
|
||||||
|
+ origins.get(entry.model())
|
||||||
|
+ " and "
|
||||||
|
+ type.getName());
|
||||||
|
}
|
||||||
|
origins.putIfAbsent(entry.model(), type.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var candidates =
|
||||||
|
entries.values().stream().filter(e -> e.model().isAssignableFrom(model)).toList();
|
||||||
|
var best =
|
||||||
|
candidates.stream()
|
||||||
|
.filter(
|
||||||
|
e ->
|
||||||
|
candidates.stream()
|
||||||
|
.noneMatch(
|
||||||
|
other ->
|
||||||
|
other != e
|
||||||
|
&& e.model()
|
||||||
|
.isAssignableFrom(
|
||||||
|
other
|
||||||
|
.model())))
|
||||||
|
.toList();
|
||||||
|
if (best.size() > 1)
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Ambiguous ViewModel defaults for " + model.getName());
|
||||||
|
var chosen = best.isEmpty() ? null : best.getFirst();
|
||||||
|
if (explicit != null && explicit != ViewModel.class) {
|
||||||
|
if (chosen != null && !chosen.generated().isAssignableFrom(explicit)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Explicit ViewModel "
|
||||||
|
+ explicit.getName()
|
||||||
|
+ " must extend "
|
||||||
|
+ chosen.generated().getName());
|
||||||
|
}
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
return chosen == null ? ViewModel.class : chosen.defaultType();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
/** A compiled, non-executing default declaration contributed by a registry service. */
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
@Repeatable(ViewModelRegistrations.class)
|
||||||
|
public @interface ViewModelRegistration {
|
||||||
|
Class<?> model();
|
||||||
|
|
||||||
|
Class<? extends ViewModel> generated();
|
||||||
|
|
||||||
|
Class<? extends ViewModel> defaultType();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
/** Container for generated registry declarations. */
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface ViewModelRegistrations {
|
||||||
|
ViewModelRegistration[] value();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker service for declarative generated ViewModel registrations. Providers are never
|
||||||
|
* instantiated.
|
||||||
|
*/
|
||||||
|
public interface ViewModelRegistry {}
|
||||||
@@ -47,20 +47,19 @@ final class ViewModels {
|
|||||||
* @return the ViewModel for that instance, or the replacement
|
* @return the ViewModel for that instance, or the replacement
|
||||||
*/
|
*/
|
||||||
static Object of(Object model, Class<?> viewModelClass) {
|
static Object of(Object model, Class<?> viewModelClass) {
|
||||||
|
Class<?> selected = ViewModelDefaults.select(model.getClass(), viewModelClass, model.getClass().getClassLoader());
|
||||||
Object existing = BY_INSTANCE.get(model);
|
Object existing = BY_INSTANCE.get(model);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (viewModelClass != null && viewModelClass != ViewModel.class
|
if (viewModelClass != null && viewModelClass != ViewModel.class
|
||||||
&& existing.getClass() != viewModelClass) {
|
&& !selected.isInstance(existing)) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"ViewModel is " + existing.getClass().getName()
|
"ViewModel is " + existing.getClass().getName()
|
||||||
+ " but the View names " + viewModelClass.getName());
|
+ " but the View names " + viewModelClass.getName());
|
||||||
}
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
if (viewModelClass == null || viewModelClass == ViewModel.class) {
|
if (selected == ViewModel.class) return new ViewModel(model);
|
||||||
return new ViewModel(model);
|
return construct(model, selected);
|
||||||
}
|
|
||||||
return construct(model, viewModelClass);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,6 +100,7 @@ final class ViewModels {
|
|||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
} catch (InvocationTargetException failure) {
|
} catch (InvocationTargetException failure) {
|
||||||
|
BY_INSTANCE.remove(model);
|
||||||
if (failure.getCause() instanceof RuntimeException runtime) {
|
if (failure.getCause() instanceof RuntimeException runtime) {
|
||||||
throw runtime;
|
throw runtime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import javafx.beans.property.*;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
public class GeneratedViewModelTest {
|
||||||
|
public static class Node {
|
||||||
|
private String name = "sample";
|
||||||
|
private Node child;
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String n) {
|
||||||
|
name = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Node getChild() {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChild(Node n) {
|
||||||
|
child = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Special extends Node {}
|
||||||
|
|
||||||
|
public static class Generated extends ViewModel {
|
||||||
|
public Generated(Node n) {
|
||||||
|
super(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StringProperty nameProperty() {
|
||||||
|
return (StringProperty) storedProperty("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Property<Generated> childProperty() {
|
||||||
|
return (Property<Generated>) (Property<?>) storedProperty("child");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Property<?> missingProperty() {
|
||||||
|
return storedProperty("missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Custom extends Generated {
|
||||||
|
public Custom(Node n) {
|
||||||
|
super(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Other extends Generated {
|
||||||
|
public Other(Node n) {
|
||||||
|
super(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Specific extends Generated {
|
||||||
|
public Specific(Special n) {
|
||||||
|
super(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewModelRegistration(
|
||||||
|
model = Node.class,
|
||||||
|
generated = Generated.class,
|
||||||
|
defaultType = Custom.class)
|
||||||
|
@ViewModelRegistration(
|
||||||
|
model = Special.class,
|
||||||
|
generated = Specific.class,
|
||||||
|
defaultType = Specific.class)
|
||||||
|
public static class Registry implements ViewModelRegistry {
|
||||||
|
public Registry() {
|
||||||
|
throw new AssertionError("Provider executed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storedPropertiesWriteBackAndShareCyclesAndMessages() {
|
||||||
|
Node n = new Node();
|
||||||
|
n.setChild(n);
|
||||||
|
var vm = (Custom) ViewModels.of(n);
|
||||||
|
assertSame(vm.nameProperty(), vm.property("name"));
|
||||||
|
vm.nameProperty().set("changed");
|
||||||
|
assertEquals("changed", n.getName());
|
||||||
|
assertSame(vm, vm.childProperty().getValue());
|
||||||
|
assertSame(vm, ViewModels.of(n, Generated.class));
|
||||||
|
vm.addMessage(new Message(Message.Severity.INFO, "notice", "name"));
|
||||||
|
assertEquals("notice", vm.property("latestMessage").getValue());
|
||||||
|
assertThrows(IllegalArgumentException.class, vm::missingProperty);
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> ViewModels.of(n, Other.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void explicitAndMostSpecificDefaultsAndNullChildren() {
|
||||||
|
Node n = new Node();
|
||||||
|
var custom = (Other) ViewModels.of(n, Other.class);
|
||||||
|
assertNull(custom.childProperty().getValue());
|
||||||
|
assertSame(custom, ViewModels.of(n));
|
||||||
|
assertInstanceOf(Specific.class, ViewModels.of(new Special()));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> ViewModels.of(new Node(), String.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewModelRegistration(
|
||||||
|
model = Node.class,
|
||||||
|
generated = Generated.class,
|
||||||
|
defaultType = Other.class)
|
||||||
|
public static class Conflict implements ViewModelRegistry {}
|
||||||
|
|
||||||
|
public static class Failing extends Generated {
|
||||||
|
public Failing(Node n) {
|
||||||
|
super(n);
|
||||||
|
throw new IllegalStateException("failed constructor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conflictingServicesIdentifyBothOrigins() throws Exception {
|
||||||
|
var folder = java.nio.file.Files.createTempDirectory("viewmodel-services-");
|
||||||
|
var service = folder.resolve("META-INF/services/de.neitzel.fx.view.ViewModelRegistry");
|
||||||
|
java.nio.file.Files.createDirectories(service.getParent());
|
||||||
|
java.nio.file.Files.writeString(service, Conflict.class.getName());
|
||||||
|
try (var loader =
|
||||||
|
new java.net.URLClassLoader(
|
||||||
|
new java.net.URL[] {folder.toUri().toURL()}, getClass().getClassLoader())) {
|
||||||
|
var error =
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> ViewModelDefaults.select(Node.class, null, loader));
|
||||||
|
assertTrue(error.getMessage().contains(Registry.class.getName()));
|
||||||
|
assertTrue(error.getMessage().contains(Conflict.class.getName()));
|
||||||
|
} finally {
|
||||||
|
try (var paths = java.nio.file.Files.walk(folder)) {
|
||||||
|
for (var p : paths.sorted(java.util.Comparator.reverseOrder()).toList())
|
||||||
|
java.nio.file.Files.delete(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedConstructionDoesNotRetainPartialInstance() {
|
||||||
|
Node n = new Node();
|
||||||
|
assertThrows(IllegalStateException.class, () -> ViewModels.of(n, Failing.class));
|
||||||
|
assertInstanceOf(Custom.class, ViewModels.of(n));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
de.neitzel.fx.view.GeneratedViewModelTest$Registry
|
||||||
Reference in New Issue
Block a user