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 |
|
||||
|
||||
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.
|
||||
* <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
|
||||
Reference in New Issue
Block a user