Generate versioned Model metadata and consume it in shared binding discovery

This commit is contained in:
2026-11-13 19:08:30 +01:00
parent 9bc4569407
commit adf432b98b
20 changed files with 700 additions and 15 deletions
@@ -10,12 +10,14 @@ import javafx.beans.property.Property;
import javafx.beans.value.ObservableValue;
import org.w3c.dom.*;
/** Signature-only fallback. Never invokes a getter, property accessor, provider or constructor. */
/** Generated-first, non-initializing discovery with shared compiled-signature fallback. */
public final class BindingDiscovery {
private static final String FX = "http://javafx.com/fxml/1";
private final ClassLoader loader;
private final List<String> diagnostics = new ArrayList<>();
private boolean incompleteModel;
private final Map<Class<?>, List<PropertyDescriptor>> beanCache = new HashMap<>();
private final Map<String, String> generatedElements = new HashMap<>();
private ControllerMetadata owner;
private final List<Problem> selectionProblems = new ArrayList<>();
private final Map<String, Class<?>> paths = new TreeMap<>();
@@ -94,22 +96,89 @@ public final class BindingDiscovery {
}
private List<PropertyDescriptor> beans(Class<?> type) throws Exception {
if (beanCache.containsKey(type)) return beanCache.get(type);
var result = modelBeans(type);
beanCache.put(type, result);
return result;
}
private List<PropertyDescriptor> modelBeans(Class<?> type) throws Exception {
if (type == Object.class || type.isInterface()) return List.of();
// Explicit BeanInfo can execute application code; it is not a safe fallback source.
// Custom BeanInfo is executable and may change runtime semantics. Never override it silently.
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
try {
load(c.getName() + "BeanInfo");
incompleteModel = true;
diagnostics.add("Custom BeanInfo requires generated metadata: " + c.getName());
diagnostics.add(
"Custom BeanInfo requires explicit runtime metadata support: " + c.getName());
return List.of();
} catch (ClassNotFoundException absent) {
}
}
return Arrays.stream(
Introspector.getBeanInfo(type, Object.class, Introspector.IGNORE_ALL_BEANINFO)
.getPropertyDescriptors())
.sorted(Comparator.comparing(PropertyDescriptor::getName))
.toList();
var compiled =
Arrays.stream(
Introspector.getBeanInfo(type, Object.class, Introspector.IGNORE_ALL_BEANINFO)
.getPropertyDescriptors())
.sorted(Comparator.comparing(PropertyDescriptor::getName))
.toList();
String resource = de.neitzel.fxdesigner.protocol.ModelMetadata.resource(type.getName());
var resources =
new LinkedHashSet<java.net.URL>(Collections.list(loader.getResources(resource)));
var own = type.getResource("/" + resource);
if (own != null) resources.add(own);
if (resources.isEmpty()) {
diagnostics.add("Compiled Model signatures: " + type.getName());
return compiled;
}
try {
if (resources.size() != 1) throw new IOException("Duplicate Model metadata resources");
de.neitzel.fxdesigner.protocol.ModelMetadata metadata;
try (var in = resources.iterator().next().openStream()) {
metadata = de.neitzel.fxdesigner.protocol.ModelMetadata.read(in);
}
if (!metadata.model().equals(type.getName())) throw new IOException("Wrong Model identity");
var eligible = new TreeMap<String, PropertyDescriptor>();
for (var p : compiled)
if (!p.getName().equals("latestMessage")
&& p.getReadMethod() != null
&& p.getWriteMethod() != null
&& p.getPropertyType() != null) eligible.put(p.getName(), p);
if (!eligible
.keySet()
.equals(
metadata.properties().stream()
.map(de.neitzel.fxdesigner.protocol.ModelMetadata.Property::name)
.collect(java.util.stream.Collectors.toSet())))
throw new IOException("Stale Model property inventory");
var result = new ArrayList<PropertyDescriptor>();
for (var p : metadata.properties()) {
var actual = eligible.get(p.name());
String element =
Collection.class.isAssignableFrom(actual.getPropertyType())
? element(actual.getReadMethod().getGenericReturnType())
: "";
if (!actual.getPropertyType().getName().equals(p.type())
|| !actual.getReadMethod().getName().equals(p.reader())
|| !actual.getWriteMethod().getName().equals(p.writer())
|| !element.equals(p.elementType()))
throw new IOException("Stale Model signature: " + p.name());
generatedElements.put(type.getName() + "." + p.name(), p.elementType());
result.add(actual);
}
diagnostics.add(
"Generated Model metadata "
+ de.neitzel.fxdesigner.protocol.ModelMetadata.VERSION
+ ": "
+ type.getName());
return List.copyOf(result);
} catch (Exception error) {
incompleteModel = true;
String message =
"Generated Model metadata rejected for " + type.getName() + ": " + error.getMessage();
diagnostics.add(message);
selectionProblems.add(new Problem("", "", "ERROR", message));
return List.of();
}
}
private static boolean simple(Class<?> c) {
@@ -171,7 +240,11 @@ public final class BindingDiscovery {
path,
c.getName(),
kind,
kind.equals("COLLECTION") ? element(p.getReadMethod().getGenericReturnType()) : ""));
kind.equals("COLLECTION")
? generatedElements.getOrDefault(
type.getName() + "." + p.getName(),
element(p.getReadMethod().getGenericReturnType()))
: ""));
if (kind.equals("COMPLEX")) attributes(c, path + ".", next, depth + 1);
}
paths.put(prefix + "latestMessage", String.class);
@@ -0,0 +1,103 @@
package de.neitzel.fxdesigner.target;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.protocol.ModelMetadata;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class GeneratedModelMetadataTest {
@TempDir Path root;
public static class Model {
static {
System.setProperty("generated.model.initialized", "yes");
}
public Model() {
throw new AssertionError();
}
public String getName() {
throw new AssertionError();
}
public void setName(String v) {
throw new AssertionError();
}
}
String fxml() {
return "<?neitzel-model "
+ Model.class.getName()
+ "?><?import javafx.scene.control.TextField?><TextField"
+ " xmlns:fx='http://javafx.com/fxml/1' fx:id='input'/>";
}
ModelMetadata metadata() {
return new ModelMetadata(
Model.class.getName(),
List.of(new ModelMetadata.Property("name", "java.lang.String", "", "getName", "setName")));
}
void write(Path base, byte[] bytes) throws Exception {
Path p = base.resolve(ModelMetadata.resource(Model.class.getName()));
Files.createDirectories(p.getParent());
Files.write(p, bytes);
}
@Test
void generatedAndFallbackHaveSameAuthoringSemanticsWithoutInitialization() throws Exception {
var fallback = BindingDiscovery.inspect(fxml(), "name", "both", getClass().getClassLoader());
write(root, metadata().encode());
try (var loader =
new URLClassLoader(new URL[] {root.toUri().toURL()}, getClass().getClassLoader())) {
var generated = BindingDiscovery.inspect(fxml(), "name", "both", loader);
assertEquals(fallback.attributes(), generated.attributes());
assertEquals(fallback.targets(), generated.targets());
assertEquals(fallback.problems(), generated.problems());
assertTrue(
generated.diagnostics().stream()
.anyMatch(d -> d.startsWith("Generated Model metadata 1.0:")));
assertNull(System.getProperty("generated.model.initialized"));
}
}
@Test
void staleMalformedAndDuplicateMetadataAreErrorsNotSilentFallback() throws Exception {
for (byte[] bytes :
List.of(
"schemaVersion=9.0".getBytes(),
new ModelMetadata(Model.class.getName(), List.of()).encode(),
new ModelMetadata(
Model.class.getName(),
List.of(new ModelMetadata.Property("name", "int", "", "getName", "setName")))
.encode())) {
write(root, bytes);
try (var loader =
new URLClassLoader(new URL[] {root.toUri().toURL()}, getClass().getClassLoader())) {
var result = BindingDiscovery.inspect(fxml(), "name", "both", loader);
assertTrue(
result.problems().stream()
.anyMatch(
p ->
p.severity().equals("ERROR") && p.message().contains("metadata rejected")));
assertFalse(result.attributes().stream().anyMatch(a -> a.path().equals("name")));
}
}
write(root, metadata().encode());
Path second = root.resolve("second");
write(second, metadata().encode());
try (var loader =
new URLClassLoader(
new URL[] {root.toUri().toURL(), second.toUri().toURL()},
getClass().getClassLoader())) {
assertTrue(
BindingDiscovery.inspect(fxml(), "name", "both", loader).problems().stream()
.anyMatch(p -> p.message().contains("Duplicate Model metadata")));
}
}
}