From 3c05c849f8c976db62fa334cb43159be30f9e097 Mon Sep 17 00:00:00 2001 From: delchev Date: Tue, 4 Aug 2026 16:29:45 +0300 Subject: [PATCH 1/2] feat(print): the feeder resolves line-item to-one relations - {{Unit}} / {{Unit.Name}} render in items columns (#6501) The generated PrintFeeder's items map carried only the line's scalar fields, so an invoice line could not print its unit or product - while header relations DID get resolved maps + __label. Each line-item to-one relation is now fed the same way: PrintFeederSupport contributes itemNodes (the composition back-reference to the document excluded - that is the header itself), and the template loads the target per row through its generated repository (validations + the multilingual translation overlay apply), caches lookups per DISTINCT key rather than per row, and hangs a map carrying __label + the target's fields under the relation's own key - so {{Unit}} renders the label and {{Unit.Name}} descends. Same-model targets name their fields one by one (the audit); a cross-model target is copied reflectively per the #6422 rule, so this project's committed gen/ survives the owner retiring a field. Covered by PrintFeederSupportTest (item nodes: back-ref exclusion, Settings perspective, prefixed variables, cross-model no-named-fields) and IntentEmissionCoverageIT (BillLine gains a Unit relation; the generated BillPrintFeeder's lookup/cache/__label emission is asserted and the whole app compiles and publishes end-to-end - green locally). Co-Authored-By: Claude Fable 5 --- .../intent/generator/PrintFeederSupport.java | 65 ++++++++++++++++++- .../generator/PrintFeederSupportTest.java | 42 ++++++++++++ .../events/PrintFeeder.java.template | 42 ++++++++++++ .../template/generateUtils.js | 18 +++++ .../tests/api/IntentEmissionCoverageIT.java | 14 ++++ 5 files changed, 179 insertions(+), 2 deletions(-) diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupport.java index b7dbfa84f5..fbd3eae481 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupport.java @@ -110,12 +110,73 @@ private static Map buildFeeder(EntityIntent master, EntityIntent addNode(relation, "root", "document", 1, model, byName, compositionParents, context, nodes, usedVars, visited); } feeder.put("nodes", nodes); + feeder.put("itemNodes", buildItemNodes(items, master, model, byName, compositionParents, context, usedVars)); // Drives the one reflective copy helper the template emits - only when a cross-model node needs it. - feeder.put("hasCrossModel", nodes.stream() - .anyMatch(node -> Boolean.TRUE.equals(node.get("crossModel")))); + feeder.put("hasCrossModel", hasCrossModel(feeder)); return feeder; } + /** + * One node per to-one relation of the LINE-ITEMS entity (its composition back-reference to the + * master excluded - that is the document itself), so an items-table column can render the + * relation's label ({@code {{Unit}}} - "1 month") or descend into its fields + * ({@code {{Unit.Name}}}), exactly as the header relations resolve. Depth 1: an item relation feeds + * its target's own record, not the target's further graph. + */ + private static List> buildItemNodes(EntityIntent items, EntityIntent master, IntentModel model, + Map byName, Map compositionParents, IntentGenerationContext context, + Set usedVars) { + List> itemNodes = new ArrayList<>(); + for (RelationIntent relation : items.getRelations()) { + if (!isToOne(relation) || relation.getName() == null || relation.getTo() == null) { + continue; + } + if (relation.isComposition() && master.getName() + .equals(relation.getTo())) { + continue; // the back-reference to the document - the header, not an item lookup + } + boolean crossModel = relation.getModel() != null && !relation.getModel() + .isBlank(); + // Prefixed so an item relation named like a header one (Customer on both) cannot collide. + String entityVar = uniqueVar("item" + IntentNaming.pascalCase(relation.getName()), usedVars); + Map node = new LinkedHashMap<>(); + node.put("entityVar", entityVar); + node.put("mapVar", entityVar + "Map"); + node.put("fkProperty", IntentNaming.pascalCase(relation.getName())); + node.put("keyInParent", IntentNaming.pascalCase(relation.getName())); + node.put("entity", relation.getTo()); + node.put("crossModel", crossModel); + if (crossModel) { + UsesIntent uses = findUses(model, relation.getModel()); + CrossModelSupport.TargetInfo target = uses == null ? null : CrossModelSupport.resolve(context, uses, relation.getTo()); + node.put("model", relation.getModel()); + node.put("perspective", target != null ? target.perspectiveName() : relation.getTo()); + node.put("labelField", target != null ? target.labelField() : "Name"); + // No `scalars`: the template copies the owner's fields reflectively (see the class note). + node.put("scalars", List.of()); + } else { + EntityIntent target = byName.get(relation.getTo()); + node.put("model", ""); + node.put("perspective", target != null && target.isSetting() ? "Settings" + : IntentEntities.resolvePerspective(relation.getTo(), compositionParents)); + node.put("labelField", nameField(target)); + node.put("scalars", scalarDescriptors(target)); + } + itemNodes.add(node); + } + return itemNodes; + } + + /** Whether any header or item node is cross-model - drives the one reflective copy helper. */ + @SuppressWarnings("unchecked") + private static boolean hasCrossModel(Map feeder) { + boolean header = ((List>) feeder.get("nodes")).stream() + .anyMatch(node -> Boolean.TRUE.equals(node.get("crossModel"))); + boolean item = ((List>) feeder.get("itemNodes")).stream() + .anyMatch(node -> Boolean.TRUE.equals(node.get("crossModel"))); + return header || item; + } + /** * Append a relation node (and, for a same-model target within the depth budget, its own to-one * relations) in pre-order so every parent variable is materialised before the child that reads it. diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupportTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupportTest.java index 9984dadc75..fcddac670c 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupportTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupportTest.java @@ -46,6 +46,8 @@ class PrintFeederSupportTest { - { name: quantity, type: decimal, required: true } relations: - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + - { name: Unit, kind: manyToOne, to: Unit } + - { name: Product, kind: manyToOne, to: Product, model: customers } - name: SalesInvoiceStatus kind: setting fields: @@ -61,6 +63,11 @@ class PrintFeederSupportTest { fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: name, type: string } + - name: Unit + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } """; private final IntentModel model = IntentParser.parse(INTENT); @@ -163,6 +170,41 @@ void crossModelNodeNamesNoFieldsOfTheOwner() { assertEquals(Boolean.TRUE, feeder.get("hasCrossModel"), "the reflective copy helper is emitted for this feeder"); } + /** + * An items-table column must be able to render a line's to-one relation ({@code {{Unit}}} - "1 + * month") exactly as the header relations resolve: one node per item to-one, the composition + * back-reference to the document excluded, same-model fields named / cross-model copied + * reflectively, and the variables prefixed so a header relation of the same name cannot collide. + */ + @Test + @SuppressWarnings("unchecked") + void itemToOneRelationsAreFedWithLabelAndFields() { + Map feeder = feeder(); + List> itemNodes = (List>) feeder.get("itemNodes"); + Map> byKey = new java.util.LinkedHashMap<>(); + for (Map node : itemNodes) { + byKey.put((String) node.get("keyInParent"), node); + } + + assertFalse(byKey.containsKey("SalesInvoice"), "the composition back-reference is the header, not an item lookup"); + + Map unit = byKey.get("Unit"); + assertNotNull(unit, "a same-model item relation is fed"); + assertEquals(Boolean.FALSE, unit.get("crossModel")); + assertEquals("Settings", unit.get("perspective"), "a setting target loads from the shared Settings perspective"); + assertEquals("Name", unit.get("labelField")); + assertEquals("itemUnit", unit.get("entityVar"), "item variables are prefixed so header names cannot collide"); + List> unitScalars = (List>) unit.get("scalars"); + assertFalse(unitScalars.isEmpty(), "a same-model item node names its fields - {{Unit.Name}} descends"); + + Map product = byKey.get("Product"); + assertNotNull(product, "a cross-model item relation is fed"); + assertEquals(Boolean.TRUE, product.get("crossModel")); + assertEquals("customers", product.get("model")); + assertTrue(((List>) product.get("scalars")).isEmpty(), + "a cross-model item node dereferences no named field of the owner (dirigible #6422)"); + } + /** A document whose whole relation graph is local needs no reflective copy helper. */ @Test void feederWithoutCrossModelRelationsSkipsTheHelper() { diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/PrintFeeder.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/PrintFeeder.java.template index 6da7bba92c..f099af70df 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/PrintFeeder.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/PrintFeeder.java.template @@ -87,6 +87,10 @@ public class ${className}PrintFeeder { #end List items = new ArrayList<>(); +#foreach($n in $itemNodes) + // ${n.entity} lookups (via the items' ${n.fkProperty}): one load per DISTINCT key, not per row. + Map ${n.entityVar}Cache = new LinkedHashMap<>(); +#end for (gen.${javaGenFolderName}.data.${itemsJavaPerspective}.${itemsEntity}Entity item : new gen.${javaGenFolderName}.data.${itemsJavaPerspective}.${itemsEntity}Repository().findAll(Criteria.create().eq("${itemsFkProperty}", id))) { Map im = new LinkedHashMap<>(); #foreach($s in $itemScalars) @@ -95,6 +99,44 @@ public class ${className}PrintFeeder { #else im.put("${s.name}", item.${s.name}); #end +#end +#foreach($n in $itemNodes) + + // ${n.entity} (via ${n.fkProperty}) - so an items column renders {{${n.keyInParent}}} (the + // label) or descends into {{${n.keyInParent}.}}, exactly as the header relations do. + gen.${n.genFolder}.data.${n.javaPerspective}.${n.entity}Entity ${n.entityVar} = null; + if (item.${n.fkProperty} != null) { + ${n.entityVar} = ${n.entityVar}Cache.get(item.${n.fkProperty}); + if (${n.entityVar} == null) { + ${n.entityVar} = new gen.${n.genFolder}.data.${n.javaPerspective}.${n.entity}Repository().findById(item.${n.fkProperty}); + if (${n.entityVar} != null) { + ${n.entityVar}Cache.put(item.${n.fkProperty}, ${n.entityVar}); + } + } + } + Map ${n.mapVar} = new LinkedHashMap<>(); + if (${n.entityVar} != null) { +#if($n.crossModel) + // ${n.model} owns this record, so its fields are copied as they are TODAY rather than + // named here (dirigible #6422). + copyRecordFields(${n.entityVar}, ${n.mapVar}); +#if($n.labelField != "") + ${n.mapVar}.put("__label", ${n.mapVar}.get("${n.labelField}")); +#end +#else +#if($n.labelField != "") + ${n.mapVar}.put("__label", ${n.entityVar}.${n.labelField}); +#end +#foreach($s in $n.scalars) +#if($s.date) + ${n.mapVar}.put("${s.name}", ${n.entityVar}.${s.name} == null ? null : ${n.entityVar}.${s.name}.toString()); +#else + ${n.mapVar}.put("${s.name}", ${n.entityVar}.${s.name}); +#end +#end +#end + } + im.put("${n.keyInParent}", ${n.mapVar}); #end items.add(im); } diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index f1f0f28b74..3b214ff173 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -1425,6 +1425,24 @@ export function generateFiles(model, parameters, templateSources) { // committed gen/ survives the owner retiring a field. hasCrossModel emits // that one helper only where it is needed. hasCrossModel: feeder.hasCrossModel === true, + // Line-item to-one lookups: the same shape as the header nodes, resolved + // per row (label + fields), so an items-table column can render the + // relation ({{Unit}} / {{Unit.Name}}). + itemNodes: (feeder.itemNodes || []).map(function (n) { + return { + entityVar: n.entityVar, + mapVar: n.mapVar, + fkProperty: n.fkProperty, + keyInParent: n.keyInParent, + entity: n.entity, + crossModel: n.crossModel === true, + model: n.model, + genFolder: n.crossModel ? sanitizeJavaIdentifier(n.model) : parameters.javaGenFolderName, + javaPerspective: sanitizeJavaIdentifier(n.perspective), + labelField: n.labelField, + scalars: n.scalars + }; + }), nodes: (feeder.nodes || []).map(function (n) { return { entityVar: n.entityVar, diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 0d30895d1e..493c156445 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -414,6 +414,9 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { name: amount, type: decimal } relations: - { name: Bill, kind: manyToOne, to: Bill, composition: true, required: true } + # an item-level to-one: the print feeder must feed it per row so an items-table + # column can render {{Unit}} (the label, translated) or {{Unit.Name}} + - { name: Unit, kind: manyToOne, to: Unit } # keyed cross-entity aggregate: a signed ledger summed per (Person, Unit) into a # materialised total row keyed by the same two FKs. Ledger.amount is SENSITIVE and @@ -1173,6 +1176,17 @@ private void assertEmission() { assertTrue(sendBill.contains("attachLanguageSource.Locale"), "the language must be read off the person's locale"); assertTrue(sendBill.contains("org.eclipse.dirigible.sdk.print.Print.defaultLanguage()"), "a null/blank locale must fall back to the application language set at send time"); + + // The feeder resolves the LINE-ITEM to-one relations per row - an items-table column renders + // {{Unit}} (the target's label, through the repository so the translation overlay applies) or + // descends into {{Unit.Name}} - with one load per DISTINCT key, not one per row. + String billFeeder = contentOf("gen/events/emission/BillPrintFeeder.java"); + assertTrue(billFeeder.contains("itemUnitCache"), "item relation lookups are cached per distinct key: " + billFeeder); + assertTrue(billFeeder.contains("new gen.emission.data.settings.UnitRepository().findById(item.Unit)"), + "the item's Unit is loaded through its generated repository"); + assertTrue(billFeeder.contains("itemUnitMap.put(\"__label\", itemUnit.Name)"), + "the item relation map carries the __label the binder renders for a bare {{Unit}}"); + assertTrue(billFeeder.contains("im.put(\"Unit\", itemUnitMap)"), "the map is hung under the relation's own key on the row"); assertTrue(sendBill.contains("catch (Exception"), "a transition's mail must be fail-soft - the status flip has already committed"); // (2) On a PROCESS STEP - a JavaDelegate whose work IS the message: it re-loads the trigger From d698a0def6b6c951235fd8c071f273a4663d69dc Mon Sep 17 00:00:00 2001 From: delchev Date: Tue, 4 Aug 2026 16:34:56 +0300 Subject: [PATCH 2/2] fix(print): the scaffold emits only keys the feeder puts; back-references label by their number (#6503) The scaffold and the feeder generator disagreed about one contract, so a freshly scaffolded standard.print carried dead placeholders that render as empty values: {{document.Id}} in the footer of a master without a DocumentTitle field (the feeder deliberately excludes the primary key), and bare relation maps with no __label (a document back-reference's target has a number, not a `name` field). Both generators now converge on one label resolution, the shared IntentEntities.labelFieldOf: an authored name field, the stored Name a label: expression generates, or the DocumentTitle field - so a document back-reference prints its number ("GI0000042") instead of an empty value. The scaffold emits a same-model relation field only when its target resolves a label, and the footer never falls back to the primary key. PrintScaffoldFeederContractTest locks the contract: every {{...}} placeholder the scaffold emits is a key the feeder puts, over a fixture with a DocumentTitle number, an EntityStatus, a document back-reference and a label-less target. Co-Authored-By: Claude Fable 5 --- .../intent/generator/IntentEntities.java | 32 +++ .../intent/generator/PrintFeederSupport.java | 18 +- .../generator/print/PrintIntentGenerator.java | 37 +++- .../PrintScaffoldFeederContractTest.java | 185 ++++++++++++++++++ .../print/PrintIntentGeneratorTest.java | 7 +- 5 files changed, 257 insertions(+), 22 deletions(-) create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/PrintScaffoldFeederContractTest.java diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentEntities.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentEntities.java index 3ac7305f55..e51b19a353 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentEntities.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentEntities.java @@ -82,6 +82,38 @@ public static String resolvePerspective(String entityName, Map c } /** The entity's primary-key field, or null when none is declared. */ + /** + * The property a to-one target's records are LABELED by, resolving broader than the authored + * {@code name} field so a document back-reference labels as its number: (1) an authored + * {@code name} field; (2) the stored {@code Name} a {@code label:} expression generates; (3) the + * {@code function: DocumentTitle} field - a document's human identity (its number). Empty when + * nothing resolves - the caller then omits the {@code __label} put / the scaffold field rather than + * reference a value that cannot exist. + * + * @param target the relation's target entity, may be {@code null} + * @return the PascalCase label property, or {@code ""} + */ + public static String labelFieldOf(EntityIntent target) { + if (target == null) { + return ""; + } + for (FieldIntent field : target.getFields()) { + if (field.getName() != null && "name".equalsIgnoreCase(field.getName())) { + return IntentNaming.pascalCase(field.getName()); + } + } + if (target.getLabel() != null && !target.getLabel() + .isBlank()) { + return "Name"; // the stored, repository-recomputed label property the expression generates + } + for (FieldIntent field : target.getFields()) { + if (field.isDocumentTitle() && field.getName() != null) { + return IntentNaming.pascalCase(field.getName()); + } + } + return ""; + } + public static FieldIntent primaryKeyOf(EntityIntent entity) { if (entity == null) { return null; diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupport.java index fbd3eae481..20cd092ab0 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/PrintFeederSupport.java @@ -271,19 +271,15 @@ private static String masterFkProperty(EntityIntent items, String masterName) { } /** - * The same-model to-one target's label property: its {@code name} field (PascalCased), or empty - * when the target has none — the template then omits the {@code __label} put rather than emit an - * accessor for a field that does not exist (which would fail {@code javac}). + * The same-model to-one target's label property, resolved by the shared + * {@link IntentEntities#labelFieldOf(EntityIntent)} - an authored {@code name} field, the stored + * {@code Name} a {@code label:} generates, or the {@code DocumentTitle} field (so a document + * back-reference labels as its number). Empty when nothing resolves - the template then omits the + * {@code __label} put rather than emit an accessor for a field that does not exist (which would + * fail {@code javac}). */ private static String nameField(EntityIntent target) { - if (target != null) { - for (FieldIntent field : target.getFields()) { - if (field.getName() != null && "name".equalsIgnoreCase(field.getName())) { - return IntentNaming.pascalCase(field.getName()); - } - } - } - return ""; + return IntentEntities.labelFieldOf(target); } private static UsesIntent findUses(IntentModel model, String alias) { diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/print/PrintIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/print/PrintIntentGenerator.java index 28cd4772d6..c35f219868 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/print/PrintIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/print/PrintIntentGenerator.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; +import org.eclipse.dirigible.components.intent.generator.IntentEntities; import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; import org.eclipse.dirigible.components.intent.generator.IntentNaming; import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; @@ -74,7 +75,7 @@ public void generate(IntentGenerationContext context) { String fileName = "doc/Templates/" + master.getKey() .getName() + "/Print/en/standard.print"; - context.writeModelFileIfAbsent(fileName, buildTemplate(master.getKey(), master.getValue())); + context.writeModelFileIfAbsent(fileName, buildTemplate(master.getKey(), master.getValue(), IntentEntities.byName(model))); LOGGER.debug("Generated standard print template [{}]", fileName); } } @@ -148,13 +149,18 @@ private static EntityIntent soleCompositionChild(EntityIntent master, java.util. } /** - * Builds the standard template for one document master. + * Builds the standard template for one document master. Every placeholder the scaffold emits is a + * key the generated print feeder actually puts (the scaffold/feeder contract): the primary key is + * never referenced (the feeder deliberately excludes it), and a same-model relation appears only + * when its target resolves a label - a dead {@code {{...}}} renders as an empty value that a + * template author then hunts through the whole pipeline. * * @param master the header entity * @param items the line-items entity + * @param byName all entities by name (to resolve a relation target's label) * @return the {@code .print} template source */ - static String buildTemplate(EntityIntent master, EntityIntent items) { + public static String buildTemplate(EntityIntent master, EntityIntent items, Map byName) { String label = IntentNaming.humanize(master.getName()); StringBuilder template = new StringBuilder(4096); template.append("