Support generated ViewModel defaults and stored property access

This commit is contained in:
2026-11-13 19:51:19 +01:00
parent 2d83fb467c
commit 90e08b7835
10 changed files with 275 additions and 5 deletions
+4
View File
@@ -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.
+9
View File
@@ -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.
* <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
*/
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;
}
@@ -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