From 3c05c849f8c976db62fa334cb43159be30f9e097 Mon Sep 17 00:00:00 2001 From: delchev Date: Tue, 4 Aug 2026 16:29:45 +0300 Subject: [PATCH] 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