Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,38 @@ public static String resolvePerspective(String entityName, Map<String, String> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,73 @@ private static Map<String, Object> 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<Map<String, Object>> buildItemNodes(EntityIntent items, EntityIntent master, IntentModel model,
Map<String, EntityIntent> byName, Map<String, String> compositionParents, IntentGenerationContext context,
Set<String> usedVars) {
List<Map<String, Object>> 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<String, Object> 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<String, Object> feeder) {
boolean header = ((List<Map<String, Object>>) feeder.get("nodes")).stream()
.anyMatch(node -> Boolean.TRUE.equals(node.get("crossModel")));
boolean item = ((List<Map<String, Object>>) 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.
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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<String, EntityIntent> byName) {
String label = IntentNaming.humanize(master.getName());
StringBuilder template = new StringBuilder(4096);
template.append("<!-- Standard print template for ")
Expand Down Expand Up @@ -194,7 +200,17 @@ static String buildTemplate(EntityIntent master, EntityIntent items) {
appendField(template, " ", field.getName());
}
for (RelationIntent relation : master.getRelations()) {
if (isToOne(relation)) {
if (!isToOne(relation) || relation.getName() == null) {
continue;
}
// The feeder labels a same-model relation only when its target resolves a label property
// (name / label: / DocumentTitle); without one, a bare {{document.<Relation>}} would render
// an empty value - so the scaffold omits the field instead of emitting a dead placeholder.
// A cross-model target is labeled by the owner-model convention and always emitted.
boolean crossModel = relation.getModel() != null && !relation.getModel()
.isBlank();
if (crossModel || !IntentEntities.labelFieldOf(byName.get(relation.getTo()))
.isEmpty()) {
appendField(template, " ", relation.getName());
}
}
Expand Down Expand Up @@ -241,12 +257,17 @@ static String buildTemplate(EntityIntent master, EntityIntent items) {
template.append(" </section>\n\n");
}

// Footer: the label plus the document number when one exists. NEVER the primary key - the
// feeder deliberately excludes it, so {{document.Id}} would be a dead placeholder.
template.append(" <footer>\n");
template.append(" <text align=\"center\">")
.append(escape(label))
.append(" {{document.")
.append(number != null ? IntentNaming.pascalCase(number.getName()) : "Id")
.append("}}</text>\n");
.append(escape(label));
if (number != null) {
template.append(" {{document.")
.append(IntentNaming.pascalCase(number.getName()))
.append("}}");
}
template.append("</text>\n");
template.append(" </footer>\n\n");
template.append(" </page>\n");
template.append("</document>\n");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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);
Expand Down Expand Up @@ -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<String, Object> feeder = feeder();
List<Map<String, Object>> itemNodes = (List<Map<String, Object>>) feeder.get("itemNodes");
Map<String, Map<String, Object>> byKey = new java.util.LinkedHashMap<>();
for (Map<String, Object> 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<String, Object> 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<Map<String, Object>> unitScalars = (List<Map<String, Object>>) unit.get("scalars");
assertFalse(unitScalars.isEmpty(), "a same-model item node names its fields - {{Unit.Name}} descends");

Map<String, Object> 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<Map<String, Object>>) 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() {
Expand Down
Loading
Loading