Discover Table compatibility and declared Row alignment

This commit is contained in:
2026-11-13 15:33:40 +01:00
parent 3417788194
commit 5462f480a4
26 changed files with 1086 additions and 76 deletions
@@ -325,6 +325,9 @@ public final class BindingDiscovery {
? List.<BindingMetadata.Converter>of()
: converters.candidates(source, chosen == null ? null : chosen.type(), direction);
repeats = new RepeatDiscovery(loader, attributes, owner, incompleteModel, controlTypes, ids);
var tables = new TableDiscovery(loader, repeats, modelType, owner);
var problems = new ArrayList<>(validate(doc, imports, ids, converters));
problems.addAll(tables.validate(doc));
return new BindingMetadata(
model,
selected,
@@ -333,13 +336,16 @@ public final class BindingDiscovery {
targets,
diagnostics,
candidates,
validate(doc, imports, ids, converters),
problems,
owner.controller == null ? "" : owner.controller.getName(),
owner.viewModel == null ? "" : owner.viewModel.getName(),
owner.methods(chosen == null ? null : chosen.type()),
repeats.collections(),
repeats.candidates(selected),
repeats.panes());
repeats.panes(),
tables.collections(),
tables.rows(selected),
tables.columns(selected));
}
private void selectOwner(Document doc, List<String> imports, String resource, Class<?> model)
@@ -133,10 +133,13 @@ final class RepeatDiscovery {
}
RepeatOption component(String name, String path) {
return component(name, collection(path));
}
RepeatOption component(String name, RepeatOption source) {
ComponentInfo info = components.computeIfAbsent(name, this::readComponent);
if (info.error() != null)
return new RepeatOption(name, info.declared(), "INCOMPATIBLE", info.error());
var source = collection(path);
if (source.status().equals("INCOMPATIBLE"))
return new RepeatOption(
name, info.declared(), "UNVERIFIED", "Choose a collection to compare element types");
@@ -215,15 +218,7 @@ final class RepeatDiscovery {
|| !ControllerMetadata.accessible(accessor.getDeclaringClass(), true))
throw new IllegalArgumentException("Component needs an accessible writable modelProperty");
Class<?> property = BindingDiscovery.propertyType(accessor.getGenericReturnType(), Map.of());
var resource = type.getResource(type.getSimpleName() + ".fxml");
if (resource == null)
throw new IllegalArgumentException("Component FXML unavailable: " + name);
Document doc;
try (var in = resource.openStream()) {
byte[] bytes = in.readNBytes(512 * 1024 + 1);
if (bytes.length > 512 * 1024) throw new IOException("Component FXML exceeds limit");
doc = BindingDiscovery.parse(new String(bytes, StandardCharsets.UTF_8));
}
Document doc = resource(type);
Element root = doc.getDocumentElement();
if (!"http://javafx.com/fxml/1".equals(root.getNamespaceURI())
|| !"root".equals(root.getLocalName()))
@@ -255,6 +250,17 @@ final class RepeatDiscovery {
}
}
static Document resource(Class<?> type) throws Exception {
var resource = type.getResource(type.getSimpleName() + ".fxml");
if (resource == null)
throw new IllegalArgumentException("Component FXML unavailable: " + type.getName());
try (var in = resource.openStream()) {
byte[] bytes = in.readNBytes(512 * 1024 + 1);
if (bytes.length > 512 * 1024) throw new IOException("Component FXML exceeds limit");
return BindingDiscovery.parse(new String(bytes, StandardCharsets.UTF_8));
}
}
private void indexResources() throws IOException {
var entries = new LinkedHashSet<String>();
for (String key : List.of("java.class.path", "jdk.module.path"))
@@ -0,0 +1,351 @@
package de.neitzel.fxdesigner.target;
import de.neitzel.fx.view.ViewModel;
import de.neitzel.fxdesigner.protocol.BindingMetadata.*;
import java.beans.*;
import java.lang.reflect.*;
import java.time.temporal.*;
import java.util.*;
import org.w3c.dom.*;
/** Table's bean-Attribute rules and declared Row positions; never executes application code. */
final class TableDiscovery {
private static final String FX = "http://javafx.com/fxml/1", P = "de.neitzel.fx.view.";
private final ClassLoader loader;
private final RepeatDiscovery repeats;
private final Class<?> model;
private final boolean unknownOwner;
TableDiscovery(
ClassLoader loader, RepeatDiscovery repeats, Class<?> model, ControllerMetadata owner) {
this.loader = loader;
this.repeats = repeats;
this.model =
owner.viewModel != null && !ViewModel.class.isAssignableFrom(owner.viewModel)
? owner.viewModel
: model;
unknownOwner = owner.unknownController || owner.viewModel == null;
}
private Class<?> load(String name) throws ClassNotFoundException {
return Class.forName(name, false, loader);
}
// Custom BeanInfo is executable. Absence of safe evidence must not be reported as
// incompatibility.
private List<PropertyDescriptor> beans(Class<?> type) throws Exception {
if (type == null || type == Object.class || type.isInterface())
throw new IllegalArgumentException("Bean Attributes unavailable");
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass())
try {
load(c.getName() + "BeanInfo");
throw new IllegalArgumentException("Custom BeanInfo requires generated metadata");
} catch (ClassNotFoundException ignored) {
}
return Arrays.stream(
Introspector.getBeanInfo(type, Object.class, Introspector.IGNORE_ALL_BEANINFO)
.getPropertyDescriptors())
.sorted(Comparator.comparing(PropertyDescriptor::getName))
.toList();
}
private static boolean writable(PropertyDescriptor p) {
return p.getReadMethod() != null && p.getWriteMethod() != null;
}
private static Class<?> argument(Type t) {
if (!(t instanceof ParameterizedType p) || p.getActualTypeArguments().length != 1) return null;
Type a = p.getActualTypeArguments()[0];
if (a instanceof Class<?> c) return c;
if (a instanceof WildcardType w
&& w.getUpperBounds().length == 1
&& w.getUpperBounds()[0] instanceof Class<?> c) return c;
return null;
}
RepeatOption collection(String path) {
if (unknownOwner)
return option(path, "?", "UNVERIFIED", "Table Model behind ViewModel is unresolved");
try {
var p = beans(model).stream().filter(b -> b.getName().equals(path)).findFirst().orElse(null);
if (p == null || !writable(p))
return option(
path,
"?",
"INCOMPATIBLE",
"Table requires a direct read/write bean collection Attribute: " + path);
if (!Collection.class.isAssignableFrom(p.getReadMethod().getReturnType()))
return option(path, "?", "INCOMPATIBLE", "Table Attribute is not a collection: " + path);
Class<?> element = argument(p.getReadMethod().getGenericReturnType());
if (element == null)
return option(
path, "?", "INCOMPATIBLE", "Table cannot read the collection's declared element class");
var backing = repeats.collection(path);
return option(
path,
element.getName(),
backing.status(),
"Table bean element type; " + backing.reason());
} catch (Exception | LinkageError e) {
return option(path, "?", "UNVERIFIED", e.getMessage());
}
}
List<RepeatOption> collections() {
var names = new TreeSet<String>();
repeats.collections().forEach(c -> names.add(c.name()));
try {
for (var b : beans(model))
if (b.getReadMethod() != null
&& Collection.class.isAssignableFrom(b.getReadMethod().getReturnType()))
names.add(b.getName());
} catch (Exception | LinkageError ignored) {
}
return names.stream().map(this::collection).toList();
}
List<RepeatOption> rows(String path) {
var source = collection(path);
return repeats.candidates(path).stream().map(c -> repeats.component(c.name(), source)).toList();
}
private static boolean simple(Class<?> type) {
return type == String.class
|| type == Boolean.class
|| type.isEnum()
|| type.isPrimitive() && type != char.class && type != void.class
|| Number.class.isAssignableFrom(type)
|| Date.class.isAssignableFrom(type)
|| Calendar.class.isAssignableFrom(type)
|| TemporalAccessor.class.isAssignableFrom(type)
|| TemporalAmount.class.isAssignableFrom(type);
}
List<RepeatOption> columns(String path) {
var c = collection(path);
if (c.type().equals("?")) return List.of();
try {
var result = new ArrayList<RepeatOption>();
for (var b : beans(load(c.type()))) {
Class<?> type = b.getPropertyType();
boolean ok = writable(b) && type != null && simple(type);
result.add(
option(
b.getName(),
type == null ? "?" : type.getName(),
ok ? "COMPATIBLE" : "INCOMPATIBLE",
ok
? "Simple read/write Table Attribute"
: "Table Columns require simple read/write bean Attributes"));
if (result.size() > 256) throw new IllegalArgumentException("Too many Table Attributes");
}
return List.copyOf(result);
} catch (Exception | LinkageError e) {
return List.of();
}
}
private RepeatOption column(String path, String attribute) {
var c = collection(path);
if (c.type().equals("?"))
return option(
attribute, "?", "UNVERIFIED", "Column type unavailable without collection element type");
try {
var b =
beans(load(c.type())).stream()
.filter(p -> p.getName().equals(attribute))
.findFirst()
.orElse(null);
if (b == null || !writable(b) || b.getPropertyType() == null || !simple(b.getPropertyType()))
return option(
attribute,
"?",
"INCOMPATIBLE",
"Column requires a simple read/write Attribute: " + attribute);
return option(
attribute, b.getPropertyType().getName(), "COMPATIBLE", "Simple Table Attribute");
} catch (Exception | LinkageError e) {
return option(attribute, "?", "UNVERIFIED", "Column type unverified: " + e.getMessage());
}
}
List<Problem> validate(Document document) {
var result = new ArrayList<Problem>();
var all = document.getElementsByTagName("*");
for (int i = 0; i < all.getLength(); i++) {
var table = (Element) all.item(i);
if (!named(table, "Table")) continue;
String id = table.getAttributeNS(FX, "id");
if (id.isEmpty()) id = "Table #" + i;
String path = table.getAttribute("collection"), row = table.getAttribute("row");
if (children(table).stream()
.anyMatch(e -> Set.of("collection", "row").contains(e.getTagName()))) {
result.add(
new Problem(id, "Table", "WARNING", "Nested Table properties require Source review"));
continue;
}
var c = collection(path);
add(result, id, "collection", c);
var r = repeats.component(row, c);
add(result, id, "row", r);
var wrappers =
children(table).stream().filter(e -> e.getTagName().equals("columns")).toList();
if (wrappers.size() > 1
|| !wrappers.isEmpty() && children(table).stream().anyMatch(e -> named(e, "Column"))) {
result.add(new Problem(id, "columns", "ERROR", "Mixed Column containers"));
continue;
}
var columns =
children(wrappers.isEmpty() ? table : wrappers.getFirst()).stream()
.filter(e -> named(e, "Column"))
.toList();
for (var col : columns) {
if (!children(col).isEmpty())
result.add(
new Problem(
id, "columns", "WARNING", "Nested Column properties require Source review"));
else add(result, id, "columns", column(path, col.getAttribute("attribute")));
}
if (!r.status().equals("INCOMPATIBLE")) result.add(alignment(id, row, columns));
}
return result;
}
private Problem alignment(String id, String row, List<Element> columns) {
String prefix = "Declared Column/Row alignment: ";
try {
var type = load(row);
var doc = RepeatDiscovery.resource(type);
var root = doc.getDocumentElement();
var owner = new ControllerMetadata(type, false, null);
if (owner.viewModel != ViewModel.class)
throw new IllegalArgumentException("custom Row ViewModel may change binding semantics");
String base = root.getAttribute("type");
if (!(base.equals("javafx.scene.layout.HBox")
|| base.equals("HBox")
&& imports(doc).stream()
.anyMatch(
v ->
v.equals("javafx.scene.layout.HBox")
|| v.equals("javafx.scene.layout.*"))))
throw new IllegalArgumentException("Row is not declared as an HBox");
var fields = new ArrayList<Element>();
for (var child : children(root)) {
if (FX.equals(child.getNamespaceURI()) && "define".equals(child.getLocalName())) continue;
if (child.getTagName().equals("children")) fields.addAll(children(child));
else if (!child.getTagName().contains(":")
&& Character.isUpperCase(
child.getTagName().charAt(child.getTagName().lastIndexOf('.') + 1)))
fields.add(child);
else throw new IllegalArgumentException("Row contains unresolved property/include content");
}
if (fields.size() < columns.size())
return new Problem(
id,
"alignment",
"WARNING",
prefix + "mismatch — fewer declared Row fields than Columns. Open Row Component.");
var bindings = new ArrayList<Element>();
for (var define : children(root))
if (FX.equals(define.getNamespaceURI()) && "define".equals(define.getLocalName()))
for (var list : children(define))
if (named(list, "BindingList")) {
var content = children(list);
if (content.size() == 1 && content.getFirst().getTagName().equals("elements"))
content = children(content.getFirst());
bindings.addAll(
content.stream()
.filter(e -> named(e, "DirectBinding") || named(e, "ComputedBinding"))
.toList());
}
for (int i = 0; i < columns.size(); i++) {
var col = columns.get(i);
var field = fields.get(i);
String fieldId = field.getAttributeNS(FX, "id");
int identities = 0;
var rowNodes = doc.getElementsByTagName("*");
for (int j = 0; j < rowNodes.getLength(); j++)
if (!fieldId.isEmpty()
&& fieldId.equals(((Element) rowNodes.item(j)).getAttributeNS(FX, "id")))
identities++;
if (identities != 1) throw new IllegalArgumentException("field has no unique fx:id");
var matches =
bindings.stream()
.filter(b -> !fieldId.isEmpty() && fieldId.equals(b.getAttribute("control")))
.toList();
if (!children(col).isEmpty()
|| matches.size() != 1
|| !named(matches.getFirst(), "DirectBinding")
|| !children(matches.getFirst()).isEmpty())
throw new IllegalArgumentException(
"field " + (i + 1) + " has no unique direct Attribute binding");
var binding = matches.getFirst();
String path = binding.getAttribute("path");
if (path.isEmpty()
|| binding.getAttribute("property").isEmpty()
|| !Set.of("view", "model", "both").contains(binding.getAttribute("direction"))
|| !binding.getAttribute("converter").isEmpty())
throw new IllegalArgumentException("field binding requires runtime interpretation");
if (!path.equals(col.getAttribute("attribute")))
return new Problem(
id,
"alignment",
"WARNING",
prefix
+ "mismatch at Column "
+ (i + 1)
+ " ("
+ col.getAttribute("attribute")
+ " versus Row "
+ path
+ "). Open Row Component.");
}
return new Problem(
id,
"alignment",
"INFO",
prefix
+ "matches direct bindings; constructor/runtime layout changes remain unverified.");
} catch (Exception | LinkageError e) {
return new Problem(
id,
"alignment",
"WARNING",
prefix + "unverified — " + e.getMessage() + ". Open Row Component.");
}
}
private static void add(List<Problem> out, String id, String property, RepeatOption v) {
if (!v.status().equals("COMPATIBLE"))
out.add(
new Problem(
id, property, v.status().equals("INCOMPATIBLE") ? "ERROR" : "WARNING", v.reason()));
}
private static RepeatOption option(String name, String type, String status, String reason) {
return new RepeatOption(name, type, status, reason == null ? "Unavailable metadata" : reason);
}
private static List<Element> children(Element e) {
var out = new ArrayList<Element>();
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element c) out.add(c);
return out;
}
private static List<String> imports(Document d) {
var out = new ArrayList<String>();
for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction p && p.getTarget().equals("import"))
out.add(p.getData().trim());
return out;
}
private static boolean named(Element e, String name) {
if (e.getTagName().equals(P + name)) return true;
var imports = imports(e.getOwnerDocument());
return e.getTagName().equals(name)
&& (imports.contains(P + name) || imports.contains(P + "*"))
&& imports.stream().noneMatch(s -> s.endsWith("." + name) && !s.equals(P + name));
}
}
@@ -92,7 +92,7 @@ public final class WorkerMain {
new Version(1, 0),
new Version(1, 0),
new Version(1, 0),
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v4"),
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v5"),
Set.of()));
var welcome = FrameCodec.read(socket.getInputStream());
if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1)
@@ -0,0 +1,222 @@
package de.neitzel.fxdesigner.target;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fx.view.ViewModelClass;
import java.util.*;
import javafx.beans.property.*;
import javafx.scene.layout.HBox;
import org.junit.jupiter.api.Test;
class TableDiscoveryTest {
public static class Item {
static {
System.setProperty("table.item.initialized", "yes");
}
public String getStreet() {
throw new AssertionError();
}
public void setStreet(String v) {}
public int getNumber() {
throw new AssertionError();
}
public void setNumber(int v) {}
public Item getNested() {
throw new AssertionError();
}
public void setNested(Item v) {}
public char getLetter() {
throw new AssertionError();
}
public void setLetter(char v) {}
public String getReadOnly() {
throw new AssertionError();
}
}
public static class Model {
static {
System.setProperty("table.model.initialized", "yes");
}
public List<Item> getItems() {
throw new AssertionError();
}
public void setItems(List<Item> v) {}
public List getRaw() {
throw new AssertionError();
}
public void setRaw(List v) {}
public String getName() {
throw new AssertionError();
}
public void setName(String v) {}
}
public static class TableRow extends HBox {
static {
System.setProperty("table.row.initialized", "yes");
}
public ObjectProperty<Item> modelProperty() {
throw new AssertionError();
}
}
public static class UnknownRow extends HBox {
public ObjectProperty<Item> modelProperty() {
throw new AssertionError();
}
}
public static class DuplicateRow extends TableRow {}
public static class BeanModel extends Model {}
public static class BeanModelBeanInfo extends java.beans.SimpleBeanInfo {
static {
System.setProperty("table.beaninfo.initialized", "yes");
}
public BeanModelBeanInfo() {
throw new AssertionError("BeanInfo executed");
}
}
public static class Replacement {
public Replacement(Model m) {
throw new AssertionError();
}
public ListProperty<Item> itemsProperty() {
throw new AssertionError();
}
}
@ViewModelClass(Replacement.class)
public static class Controller {}
private String xml(String path, String row, String cols, String controller) {
return "<?neitzel-model "
+ Model.class.getName()
+ "?><?import de.neitzel.fx.view.*?><?import javafx.scene.layout.VBox?>"
+ "<VBox xmlns:fx='http://javafx.com/fxml/1'"
+ (controller.isEmpty() ? "" : " fx:controller='" + controller + "'")
+ "><Table fx:id='t' collection='"
+ path
+ "' row='"
+ row
+ "'><columns>"
+ cols
+ "</columns></Table></VBox>";
}
private String col(String attr) {
return "<Column header='" + attr + "' attribute='" + attr + "'/>";
}
private de.neitzel.fxdesigner.protocol.BindingMetadata inspect(
String path, Class<?> row, String cols, String controller) throws Exception {
return BindingDiscovery.inspect(
xml(path, row.getName(), cols, controller), path, "view", getClass().getClassLoader());
}
@Test
void discoversTableTypesAndDeclaredAlignmentWithoutInitialization() throws Exception {
var m = inspect("items", TableRow.class, col("street") + col("number"), "");
assertTrue(
m.problems().stream().noneMatch(p -> p.severity().equals("ERROR")),
m.problems().toString());
assertTrue(
m.problems().stream()
.anyMatch(p -> p.property().equals("alignment") && p.severity().equals("INFO")));
assertTrue(
m.tableColumns().stream()
.anyMatch(c -> c.name().equals("street") && c.status().equals("COMPATIBLE")));
for (String name : List.of("nested", "letter", "readOnly"))
assertTrue(
m.tableColumns().stream()
.anyMatch(c -> c.name().equals(name) && c.status().equals("INCOMPATIBLE")));
for (String key :
List.of("table.model.initialized", "table.item.initialized", "table.row.initialized"))
assertNull(System.getProperty(key));
}
@Test
void warnsOnReorderedAndUnknownFieldsWithoutChangingXml() throws Exception {
var mismatch = inspect("items", TableRow.class, col("number") + col("street"), "");
assertTrue(
mismatch.problems().stream()
.anyMatch(p -> p.severity().equals("WARNING") && p.message().contains("mismatch")));
var unknown = inspect("items", UnknownRow.class, col("street"), "");
assertTrue(
unknown.problems().stream()
.anyMatch(p -> p.property().equals("alignment") && p.message().contains("unverified")));
}
@Test
void keepsCustomBeanInfoAndDuplicateFieldIdsUnverified() throws Exception {
String text =
xml("items", TableRow.class.getName(), col("street"), "")
.replace(Model.class.getName(), BeanModel.class.getName());
var bean = BindingDiscovery.inspect(text, "items", "view", getClass().getClassLoader());
assertTrue(
bean.problems().stream()
.anyMatch(p -> p.property().equals("collection") && p.severity().equals("WARNING")));
assertNull(System.getProperty("table.beaninfo.initialized"));
var duplicate = inspect("items", DuplicateRow.class, col("street"), "");
assertTrue(
duplicate.problems().stream()
.anyMatch(
p -> p.property().equals("alignment") && p.message().contains("unique fx:id")));
}
@Test
void rejectsMissingComplexReadOnlyAndUnsupportedScalarAttributes() throws Exception {
for (String name : List.of("absent", "nested", "letter", "readOnly", "nested.street")) {
var m = inspect("items", TableRow.class, col(name), "");
assertTrue(
m.problems().stream()
.anyMatch(p -> p.property().equals("columns") && p.severity().equals("ERROR")),
name);
}
}
@Test
void tableNeedsBeanMetadataEvenWhenReplacementHasListProperty() throws Exception {
var m = inspect("items", TableRow.class, col("street"), Controller.class.getName());
assertTrue(
m.problems().stream()
.anyMatch(p -> p.property().equals("collection") && p.severity().equals("ERROR")));
for (String path : List.of("raw", "name", "missing", "items.street"))
assertTrue(
inspect(path, TableRow.class, col("street"), "").problems().stream()
.anyMatch(p -> p.property().equals("collection") && p.severity().equals("ERROR")),
path);
}
@Test
void rejectsWrongRowModelAndKeepsUnknownSourceSyntaxUnverified() throws Exception {
var wrong = inspect("items", RepeatDiscoveryTest.Wrong.class, col("street"), "");
assertTrue(
wrong.problems().stream()
.anyMatch(p -> p.property().equals("row") && p.severity().equals("ERROR")));
var nested =
inspect("items", TableRow.class, "<Column><attribute>street</attribute></Column>", "");
assertTrue(nested.problems().stream().anyMatch(p -> p.message().contains("Nested Column")));
}
}
@@ -0,0 +1,3 @@
<?neitzel-model de.neitzel.fxdesigner.target.TableDiscoveryTest$Item?>
<?import javafx.scene.control.TextField?><?import de.neitzel.fx.view.*?>
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.HBox"><fx:define><BindingList><DirectBinding control="street" property="text" path="street" direction="both"/></BindingList></fx:define><TextField fx:id="street"/><TextField fx:id="street"/></fx:root>
@@ -0,0 +1,3 @@
<?neitzel-model de.neitzel.fxdesigner.target.TableDiscoveryTest$Item?>
<?import javafx.scene.control.TextField?><?import de.neitzel.fx.view.*?>
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.HBox"><fx:define><BindingList><DirectBinding control="street" property="text" path="street" direction="both"/><DirectBinding control="number" property="text" path="number" direction="both"/></BindingList></fx:define><children><TextField fx:id="street"/><TextField fx:id="number"/></children></fx:root>
@@ -0,0 +1,3 @@
<?neitzel-model de.neitzel.fxdesigner.target.TableDiscoveryTest$Item?>
<?import javafx.scene.control.TextField?>
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.HBox"><TextField fx:id="street"/></fx:root>