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 @@ -18,11 +18,14 @@
import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext;
import org.eclipse.dirigible.components.intent.generator.IntentNaming;
import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator;
import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport;
import org.eclipse.dirigible.components.intent.model.CheckIntent;
import org.eclipse.dirigible.components.intent.model.EntityIntent;
import org.eclipse.dirigible.components.intent.model.FieldIntent;
import org.eclipse.dirigible.components.intent.model.IntentModel;
import org.eclipse.dirigible.components.intent.model.RelationIntent;
import org.eclipse.dirigible.components.intent.model.SeedIntent;
import org.eclipse.dirigible.components.intent.model.UsesIntent;
import org.eclipse.dirigible.repository.api.IRepository;
import org.eclipse.dirigible.repository.api.IResource;
import org.slf4j.Logger;
Expand Down Expand Up @@ -83,7 +86,7 @@ public void generate(IntentGenerationContext context) {
return;
}

Map<String, Object> manifest = buildManifest(baseName, context.getProjectName(), model, edmEntities);
Map<String, Object> manifest = buildManifest(baseName, context.getProjectName(), model, edmEntities, context);
context.writeModelFile(baseName + ".test", GSON.toJson(manifest) + "\n");
LOGGER.debug("Generated app-test manifest [{}.test]", baseName);
}
Expand All @@ -102,6 +105,15 @@ public void generate(IntentGenerationContext context) {
*/
public static Map<String, Object> buildManifest(String baseName, String project, IntentModel model,
Map<String, Map<String, Object>> edmEntities) {
return buildManifest(baseName, project, model, edmEntities, null);
}

/**
* The full variant carrying the generation context, which cross-model relation resolution needs (a
* {@code null} context falls back to the naming-convention target coordinates - unit tests).
*/
public static Map<String, Object> buildManifest(String baseName, String project, IntentModel model,
Map<String, Map<String, Object>> edmEntities, IntentGenerationContext context) {
Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("module", baseName);
manifest.put("standaloneShell", "/services/web/" + project + "/gen/" + baseName + "/index.html");
Expand All @@ -120,13 +132,14 @@ public static Map<String, Object> buildManifest(String baseName, String project,
if ("MANAGE_DETAILS".equals(string(edm.get("layoutType"))) || "PROJECTION".equals(string(edm.get("type")))) {
continue;
}
entities.add(entityManifest(entity, edm, model));
entities.add(entityManifest(entity, edm, model, context, edmEntities));
}
manifest.put("entities", entities);
return manifest;
}

private static Map<String, Object> entityManifest(EntityIntent entity, Map<String, Object> edm, IntentModel model) {
private static Map<String, Object> entityManifest(EntityIntent entity, Map<String, Object> edm, IntentModel model,
IntentGenerationContext context, Map<String, Map<String, Object>> edmEntities) {
Map<String, Object> out = new LinkedHashMap<>();
String name = entity.getName();
out.put("name", name);
Expand All @@ -138,6 +151,12 @@ private static Map<String, Object> entityManifest(EntityIntent entity, Map<Strin
out.put("api", "/" + sanitizeJavaIdentifier(string(edm.get("perspectiveName"))) + "/" + name + "Controller");
out.put("table", string(edm.get("dataName")));

// A hierarchical entity renders its list as a tree (role=treeitem, no table/columnheaders),
// so the runner must branch on it.
if (entity.getHierarchy() != null && !entity.getHierarchy()
.isBlank()) {
out.put("hierarchy", true);
}
boolean multilingual = "true".equals(string(edm.get("multilingual")));
if (multilingual) {
out.put("multilingual", true);
Expand All @@ -149,8 +168,23 @@ private static Map<String, Object> entityManifest(EntityIntent entity, Map<Strin
if (hasSeed(model, name)) {
out.put("expectSeedData", true);
}
// exactlyOne checks: exactly one of the named fields may be non-null - a sample record
// filling all of them is rejected with 400, so the runner keeps only the first
List<List<String>> exactlyOne = new ArrayList<>();
for (CheckIntent check : entity.getChecks() == null ? List.<CheckIntent>of() : entity.getChecks()) {
if ("exactlyOne".equals(check.getKind()) && check.getFields() != null && !check.getFields()
.isEmpty()) {
exactlyOne.add(check.getFields()
.stream()
.map(IntentNaming::pascalCase)
.toList());
}
}
if (!exactlyOne.isEmpty()) {
out.put("exactlyOne", exactlyOne);
}
out.put("fields", fields(entity));
List<Map<String, Object>> relations = relations(entity, model);
List<Map<String, Object>> relations = relations(entity, model, context, edmEntities);
if (!relations.isEmpty()) {
out.put("relations", relations);
}
Expand All @@ -176,7 +210,13 @@ private static List<Map<String, Object>> fields(EntityIntent entity) {
if (field.getLength() != null) {
out.put("length", field.getLength());
}
if (field.isReadOnly()) {
// Read-only must mirror the generated form exactly, or the runner waits forever on an
// input that is not there: an author-marked field and a uuid render in the read-only
// details block (no #f_<Name> input), a calculated field renders as a non-editable
// input, an aggregate renders in the document totals footer, and a dependsOn field is
// auto-populated by its trigger relation's watcher (the runner must not fill it).
if (field.isReadOnly() || "uuid".equalsIgnoreCase(field.getType()) || field.isCalculated() || field.isAggregate()
|| field.getDependsOn() != null) {
out.put("readOnly", true);
}
out.put("major", field.isMajor());
Expand All @@ -186,15 +226,26 @@ private static List<Map<String, Object>> fields(EntityIntent entity) {
}

/**
* The user-pickable to-one relations rendered as dropdowns. Cross-model relations are omitted —
* their target lives in another module's manifest, so a single-module runner cannot resolve a
* sample option for them (a phase-2 concern).
* The user-pickable to-one relations rendered as dropdowns. A cross-model relation's target lives
* in another module — its option rows are resolved through an {@code apiAbsolute} controller URL
* (the same owner-project coordinates the generated dropdown uses), so the runner can fill the
* required FK without the target being in this manifest. A {@code function: EntityStatus} relation
* is marked {@code entityStatus} — it renders as a status pill / is excluded from the editable
* inputs by the form templates, and its value comes from the {@code init:} DB default, so the
* runner must neither pick nor post it.
*/
private static List<Map<String, Object>> relations(EntityIntent entity, IntentModel model) {
private static List<Map<String, Object>> relations(EntityIntent entity, IntentModel model, IntentGenerationContext context,
Map<String, Map<String, Object>> edmEntities) {
Map<String, UsesIntent> usesByAlias = new LinkedHashMap<>();
for (UsesIntent uses : model.getUses()) {
if (uses.getModel() != null) {
usesByAlias.put(uses.getModel(), uses);
}
}
List<Map<String, Object>> relations = new ArrayList<>();
for (RelationIntent relation : entity.getRelations()) {
boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind());
if (!toOne || relation.isCrossModel() || relation.getTo() == null) {
if (!toOne || relation.getTo() == null) {
continue;
}
Map<String, Object> out = new LinkedHashMap<>();
Expand All @@ -205,7 +256,79 @@ private static List<Map<String, Object>> relations(EntityIntent entity, IntentMo
out.put("required", true);
}
out.put("widget", "dropdown");
out.put("labelFrom", labelFieldOf(relation.getTo(), model));
if (relation.isEntityStatus()) {
out.put("entityStatus", true);
}
// dependsOn cascade: the option list narrows to target rows whose filterBy equals the
// trigger sibling's value - the runner must pick MATCHING samples (the dependent row
// first, then its FK as the trigger's sample), not independent first rows.
if (relation.getDependsOn() != null) {
Map<String, Object> dependsOn = new LinkedHashMap<>();
dependsOn.put("relation", IntentNaming.pascalCase(relation.getDependsOn()
.getRelation()));
if (relation.getDependsOn()
.getFilterBy() != null) {
dependsOn.put("filterBy", IntentNaming.pascalCase(relation.getDependsOn()
.getFilterBy()));
}
out.put("dependsOn", dependsOn);
}
// where: static option filter - only matching target rows are offered as options
if (relation.getWhere() != null && relation.getWhere()
.size() == 1) {
Map.Entry<String, Object> condition = relation.getWhere()
.entrySet()
.iterator()
.next();
Map<String, Object> where = new LinkedHashMap<>();
where.put("by", IntentNaming.pascalCase(condition.getKey()));
where.put("value", condition.getValue());
out.put("where", where);
}
if (relation.isCrossModel()) {
UsesIntent uses = usesByAlias.get(relation.getModel());
if (uses == null) {
continue;
}
CrossModelSupport.TargetInfo info;
try {
info = CrossModelSupport.resolve(context, uses, relation.getTo());
} catch (RuntimeException ex) {
// the EDM generator (order 200) fails loudly for a truly unresolvable target;
// reaching here means a degraded context - omit the relation rather than emit a
// guessed URL
LOGGER.warn("Omitting cross-model relation [{}] of [{}] from the app-test manifest - target unresolved",
relation.getName(), entity.getName(), ex);
continue;
}
out.put("crossModel", true);
out.put("apiAbsolute", "/services/java/" + uses.resolveProject() + "/gen/" + sanitizeJavaIdentifier(uses.getModel())
+ "/api/" + sanitizeJavaIdentifier(info.perspectiveName()) + "/" + relation.getTo() + "Controller");
out.put("labelFrom", info.labelField());
// leafOnly: the generated validation rejects a non-leaf target - the runner must
// pick a row no other row references via the target's hierarchy edge
if (relation.isLeafOnly() && info.hierarchyProperty() != null) {
out.put("leafOnly", Map.of("hierarchyProperty", info.hierarchyProperty()));
}
} else {
// relative controller path of the same-model target - resolvable even when the
// target is a composition detail (excluded from this manifest's entities list)
Map<String, Object> targetEdm = edmEntities.get(relation.getTo());
if (targetEdm != null) {
out.put("api",
"/" + sanitizeJavaIdentifier(string(targetEdm.get("perspectiveName"))) + "/" + relation.getTo() + "Controller");
}
out.put("labelFrom", labelFieldOf(relation.getTo(), model));
if (relation.isLeafOnly()) {
for (EntityIntent target : model.getEntities()) {
if (relation.getTo()
.equals(target.getName())
&& target.getHierarchy() != null) {
out.put("leafOnly", Map.of("hierarchyProperty", IntentNaming.pascalCase(target.getHierarchy())));
}
}
}
}
relations.add(out);
}
return relations;
Expand Down Expand Up @@ -333,6 +456,9 @@ private static String idProperty(Map<String, Map<String, Object>> edmEntities) {
private static String layout(String layoutType) {
return switch (layoutType == null ? "" : layoutType) {
case "MANAGE_DOCUMENT" -> "document";
// the view family replaces the table page - the runner must not expect columns/rows
case "MANAGE_CALENDAR" -> "calendar";
case "MANAGE_SLOTS" -> "slots";
default -> "manage-list";
};
}
Expand Down
Loading
Loading