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 3ac7305f551..e51b19a3534 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 b7dbfa84f57..20cd092ab08 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. @@ -210,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 28cd4772d6c..c35f2198682 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("