From 90e08b7835501f6810f12b845d7a9dacd7749425 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Fri, 13 Nov 2026 19:51:19 +0100 Subject: [PATCH] Support generated ViewModel defaults and stored property access --- fx/README.md | 4 + fx/docs/generated-viewmodels.md | 9 ++ .../java/de/neitzel/fx/view/ViewModel.java | 7 + .../de/neitzel/fx/view/ViewModelDefaults.java | 65 ++++++++ .../fx/view/ViewModelRegistration.java | 15 ++ .../fx/view/ViewModelRegistrations.java | 10 ++ .../de/neitzel/fx/view/ViewModelRegistry.java | 7 + .../java/de/neitzel/fx/view/ViewModels.java | 10 +- .../fx/view/GeneratedViewModelTest.java | 152 ++++++++++++++++++ .../de.neitzel.fx.view.ViewModelRegistry | 1 + 10 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 fx/docs/generated-viewmodels.md create mode 100644 fx/src/main/java/de/neitzel/fx/view/ViewModelDefaults.java create mode 100644 fx/src/main/java/de/neitzel/fx/view/ViewModelRegistration.java create mode 100644 fx/src/main/java/de/neitzel/fx/view/ViewModelRegistrations.java create mode 100644 fx/src/main/java/de/neitzel/fx/view/ViewModelRegistry.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/GeneratedViewModelTest.java create mode 100644 fx/src/test/resources/META-INF/services/de.neitzel.fx.view.ViewModelRegistry diff --git a/fx/README.md b/fx/README.md index 16c1c9b..dc1ecc4 100644 --- a/fx/README.md +++ b/fx/README.md @@ -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 | 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. diff --git a/fx/docs/generated-viewmodels.md b/fx/docs/generated-viewmodels.md new file mode 100644 index 0000000..0a62a7e --- /dev/null +++ b/fx/docs/generated-viewmodels.md @@ -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. diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java index 64950d9..9cf5adc 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java @@ -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. *

diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModelDefaults.java b/fx/src/main/java/de/neitzel/fx/view/ViewModelDefaults.java new file mode 100644 index 0000000..0a89357 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModelDefaults.java @@ -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, ViewModelRegistration>(); + var origins = new HashMap, 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(); + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistration.java b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistration.java new file mode 100644 index 0000000..edd4171 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistration.java @@ -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 generated(); + + Class defaultType(); +} diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistrations.java b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistrations.java new file mode 100644 index 0000000..f0f58f6 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistrations.java @@ -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(); +} diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistry.java b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistry.java new file mode 100644 index 0000000..d805d25 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistry.java @@ -0,0 +1,7 @@ +package de.neitzel.fx.view; + +/** + * Marker service for declarative generated ViewModel registrations. Providers are never + * instantiated. + */ +public interface ViewModelRegistry {} diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModels.java b/fx/src/main/java/de/neitzel/fx/view/ViewModels.java index b7c6b73..e61143f 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewModels.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModels.java @@ -47,20 +47,19 @@ final class ViewModels { * @return the ViewModel for that instance, or the replacement */ static Object of(Object model, Class viewModelClass) { + Class selected = ViewModelDefaults.select(model.getClass(), viewModelClass, model.getClass().getClassLoader()); Object existing = BY_INSTANCE.get(model); if (existing != null) { if (viewModelClass != null && viewModelClass != ViewModel.class - && existing.getClass() != viewModelClass) { + && !selected.isInstance(existing)) { throw new IllegalArgumentException( "ViewModel is " + existing.getClass().getName() + " but the View names " + viewModelClass.getName()); } return existing; } - if (viewModelClass == null || viewModelClass == ViewModel.class) { - return new ViewModel(model); - } - return construct(model, viewModelClass); + if (selected == ViewModel.class) return new ViewModel(model); + return construct(model, selected); } /** @@ -101,6 +100,7 @@ final class ViewModels { } return created; } catch (InvocationTargetException failure) { + BY_INSTANCE.remove(model); if (failure.getCause() instanceof RuntimeException runtime) { throw runtime; } diff --git a/fx/src/test/java/de/neitzel/fx/view/GeneratedViewModelTest.java b/fx/src/test/java/de/neitzel/fx/view/GeneratedViewModelTest.java new file mode 100644 index 0000000..a8af5a3 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/GeneratedViewModelTest.java @@ -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 childProperty() { + return (Property) (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)); + } +} diff --git a/fx/src/test/resources/META-INF/services/de.neitzel.fx.view.ViewModelRegistry b/fx/src/test/resources/META-INF/services/de.neitzel.fx.view.ViewModelRegistry new file mode 100644 index 0000000..d75ee65 --- /dev/null +++ b/fx/src/test/resources/META-INF/services/de.neitzel.fx.view.ViewModelRegistry @@ -0,0 +1 @@ +de.neitzel.fx.view.GeneratedViewModelTest$Registry