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
1 change: 1 addition & 0 deletions components/engine/engine-intent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` /

- **Standard print templates (`PrintIntentGenerator`, @Order(800), PR #6119).** One `<Entity>.print` per document (header-items) master — same structural detection as the EDM document layout (composition child named `*Item`), so no flag is needed. The template (document-template DSL, `dirigible-parsers-document`) derives from the model: humanized title + `documentTitle` number subtitle, header `<field>`s (non-PK/non-aggregate fields + to-one relations), `<table source="items">` with type-aware alignment, totals footer with the `total` aggregate emphasized. `.print` is in `INTENT_OWNED_EXTENSIONS` (scrubbed) but has **no `EXTENSION_TO_RECIPE` entry** — it is consumed at publish by `engine-document`'s `PrintTemplateSynchronizer` (seeds CMS `Templates/<Entity>/Print/en/` create-if-absent), not by a code template. Data contract: `{{document.<PascalProp>}}` + items rows with `{{<PascalProp>}}` — the Harmonia Print button assembles that payload client-side with FK labels resolved. Tests parse-validate the output with the DSL parser (test-scoped `dirigible-parsers-document` dependency); `IntentEngineIT` asserts `Order.print` in the generate pass.

- **Cross-model report dimensions + typed per-column report filters.** `ReportIntentGenerator` joins a **cross-model** relation dimension (`dimensions: [Customer]` where Customer is `model: customers`) against the owning model's real table/PK/label via `CrossModelSupport` (same resolution as the EDM FK; leaf-first, loud failure) — previously the join used this model's intent-prefixed naming and pointed at a non-existent table. The generated report stack gained **server-side per-column conditions**: the report repository (`template-application-dao-java/data/reportFileEntity.java.template`) accepts `conditions: [{column, operator, value}]` validated against the report's own column aliases + types (`FILTER_COLUMNS` from `$columns`) and wraps the query (`SELECT * FROM (QUERY) AS "REPORT_DATA" WHERE "alias" <op> :reportFilter<i>`, typed named parameters); operators EQ/NE/GT/GTE/LT/LTE/LIKE; unknown column/operator → 400 from the controller; `/export` accepts the same conditions (downloads what the table shows). The Harmonia report page embeds generation-time column metadata (`reportColumns: [{key, kind: date|number|boolean|text}]` from `$column.typeTypescript`) and renders a typed filter panel — date range, number range, boolean select, text contains — switching to `POST /search`/`/count` when filters are active. **Date-bucket dimensions**: `month(field)` (sortable YYYYMM integer via `(EXTRACT(YEAR)*100 + EXTRACT(MONTH))`) and `year(field)` group a date for aggregation — e.g. monthly income/VAT (`dimensions: ["month(date)"]`); standard-SQL `EXTRACT`, so H2/PostgreSQL (SQL Server lacks EXTRACT — documented limitation). The YYYYMM integer pairs with the number-range column filters. Covered by `IntentEngineIT.report_file_stack_generates_typed_column_filters` + the `OrdersByMonth` assertions in `assertReport`; the cross-model joins and monthly buckets verified live on the multi-model sample's invoice reports.
- **Multi-language data (`multilingual`) + language/file seeds + `languages:`** — the TS-era data-translation feature ported to the Java stack and exposed in the DSL, end-to-end: SDK `Translator` (api-modules-java, name-based `<TABLE>_LANG` overlay), multilingual finder overrides in `Repository.java.template`, `_LANG` table emission in `application.schema.template`, `EntityIntent.multilingual`/`SeedIntent.language`/`SeedIntent.file`/`IntentModel.languages` with parser validation, `CsvimIntentGenerator` language + file seeds, and the Harmonia Region & Language Settings entry (shared `locale` store + `Accept-Language` in `api.js` + Print default). See the semantics bullet above. Covered by `IntentParserTest`/`EdmIntentGeneratorTest`/`CsvimIntentGeneratorTest`/`IntentEngineIT` (`multilingual_entity_generates_the_translation_stack`).
- **Depends-On (`dependsOn`) exposed in the DSL** — cascading dropdowns + auto-populated fields, end-to-end: parser validation (`validateDependsOn`), `EdmIntentGenerator.putDependsOn` emitting the `widgetDependsOn*` EDM attributes (AngularJS stacks consume them as-is), the whole Harmonia runtime (manage form + document header watchers, metadata-driven item-dialog cascade, `draftOptions` separation, Refresh/Add-new filter re-application), `parameterUtils.widgetDependsOnControllerUrl`, and `CrossModelSupport.TargetInfo.propertyNames` for cross-model reference validation. See the semantics bullet above. Covered by `IntentParserTest`/`EdmIntentGeneratorTest`/`IntentEngineIT` (incl. a generated-DocumentPage content assertion); showcased in `dirigiblelabs/sample-intent-multi-model`.
- **Form-control `size` + related-field `show` (Harmonia layout DSL), and manual n:m Add/Delete (PR #6117).** Two symmetric authoring attributes on the intent, both flowing through `EdmIntentGenerator` into the `.model` and read by the Harmonia templates: (1) **`size`** on a field OR a to-one relation = the form-control width as a 12-column grid span (1-12, typically 3/4/6/12) → the property's `widgetSize` → `grid-column: span N` (previously `widgetSize` was always empty = half-width, so there was no way to pack short controls onto one row; the parser fail-fast-validates the range). (2) **`show: [field, ...]`** on a to-one relation = target field names to surface as extra **read-only** columns wherever the relation renders as a lookup column (master-detail + document allocation tables) — emitted as `lookupColumns` on the FK property (a List, so it is **skipped from the scalar-only `.edm` XML** via the `Iterable`/`Map` guard in `appendPropertyValue`, and lives only in the `.model` twin); `detail-register.js.template` emits one `via` column per entry, and the shared `detailPanel` now keys its FK-lookup fetch by **FK → the whole referenced row** (not just the label) so a `via` column reads any field off the same already-fetched row — no extra request, and it works for a **cross-model** target (avoiding the raw-vs-sanitized web-folder URL trap: no URL is constructed at all). Both documented in `intent-assistant-guide.md`. Alongside these, the Harmonia **document allocation panels** gained a manual **Add/Delete** corrective override (reusing the shared `detailPanel` `addRow`/`askDelete`/`confirmDelete`; both go through the junction Repository so the create/`-deleted` events fire and the paid/balance/status rollup recomputes — Edit omitted; delete-to-zero keeps the last non-zero status by the rollup's existing `signum > 0` guard, a documented limitation).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext;
import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport;
import org.eclipse.dirigible.components.intent.generator.IntentNaming;
import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator;
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.UsesIntent;
import org.eclipse.dirigible.components.intent.model.ReportIntent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -90,6 +92,11 @@ public class ReportIntentGenerator implements IntentTargetGenerator {
private static final Set<String> KNOWN_AGGREGATES = Set.of("COUNT", "SUM", "AVG", "MIN", "MAX");
private static final Pattern DOTTED_REF = Pattern.compile("\\b([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z_][A-Za-z0-9_]*)\\b");
private static final Pattern SIMPLE_CONDITION = Pattern.compile("^\\s*(\\S+)\\s*(<=|>=|<>|!=|=|<|>)\\s*(.+?)\\s*$");
/**
* {@code month(field)} / {@code year(field)} dimension - the bucket function in group 1, field in
* group 2.
*/
private static final Pattern DATE_BUCKET = Pattern.compile("\\s*(month|year)\\s*\\(\\s*([^)]+?)\\s*\\)\\s*", Pattern.CASE_INSENSITIVE);

@Override
public String name() {
Expand Down Expand Up @@ -139,6 +146,29 @@ private static Map<String, Object> build(IntentGenerationContext context, Report
if (dimension == null || dimension.isBlank()) {
continue;
}
// A month(field)/year(field) dimension buckets a date for aggregation: month emits the
// sortable YYYYMM integer (EXTRACT(YEAR) * 100 + EXTRACT(MONTH) - e.g. 202607), year the
// plain year. EXTRACT is standard SQL (H2, PostgreSQL); SQL Server does not support it -
// date-bucketed reports are an H2/PostgreSQL feature for now.
Matcher bucket = DATE_BUCKET.matcher(dimension.trim());
if (bucket.matches()) {
String function = bucket.group(1)
.toLowerCase(Locale.ROOT);
String fieldReference = bucket.group(2)
.trim();
ColumnRef ref = resolve(context, model, source, baseAlias, fieldReference);
registerJoin(joins, ref);
String expression = "month".equals(function)
? "(EXTRACT(YEAR FROM " + ref.qualified() + ") * 100 + EXTRACT(MONTH FROM " + ref.qualified() + "))"
: "EXTRACT(YEAR FROM " + ref.qualified() + ")";
String alias = humanize(function + " " + fieldReference.replace('.', ' '));
columns.add(column(ref.tableAlias, alias, ref.physicalColumn, "INTEGER", "NONE", aggregated));
selectParts.add(expression + " as \"" + alias + "\"");
if (aggregated) {
groupParts.add(expression);
}
continue;
}
ColumnRef ref = resolve(context, model, source, baseAlias, dimension.trim());
registerJoin(joins, ref);
columns.add(column(ref.tableAlias, ref.displayAlias, ref.physicalColumn, ref.reportType, "NONE", aggregated));
Expand Down Expand Up @@ -224,9 +254,10 @@ private static ColumnRef resolve(IntentGenerationContext context, IntentModel mo
ref.tableAlias = targetAlias;
ref.physicalColumn = column(targetAlias, fieldName);
FieldIntent targetField = fieldByName(target, fieldName);
ref.reportType = reportType(targetField == null ? null : targetField.getType());
// A cross-model target's fields are not in this model; string is the safe display type.
ref.reportType = targetField == null ? "CHARACTER VARYING" : reportType(targetField.getType());
ref.displayAlias = humanize(reference.replace('.', ' '));
ref.join = join(context, source, relation, target, targetAlias, baseAlias);
ref.join = join(context, model, source, relation, target, targetAlias, baseAlias);
return ref;
}
}
Expand All @@ -247,13 +278,15 @@ private static ColumnRef resolve(IntentGenerationContext context, IntentModel mo
&& ("manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()))) {
EntityIntent target = entityByName(model, relation.getTo());
String targetAlias = relation.getTo();
String labelField = labelFieldName(target);
// A cross-model target's label comes from the resolved owner model (its Name-like field).
CrossModelSupport.TargetInfo info = crossModelInfo(context, model, relation);
String labelField = info != null ? info.labelField() : labelFieldName(target);
FieldIntent labeled = fieldByName(target, labelField);
ref.tableAlias = targetAlias;
ref.physicalColumn = column(targetAlias, labelField);
ref.reportType = reportType(labeled == null ? null : labeled.getType());
ref.reportType = info != null ? "CHARACTER VARYING" : reportType(labeled == null ? null : labeled.getType());
ref.displayAlias = humanize(reference);
ref.join = join(context, source, relation, target, targetAlias, baseAlias);
ref.join = join(context, model, source, relation, target, targetAlias, baseAlias);
return ref;
}
// Best-effort: treat the reference as a raw column on the source.
Expand Down Expand Up @@ -293,15 +326,41 @@ private static boolean isTextType(String type) {
return "string".equals(t) || "text".equals(t) || "uuid".equals(t);
}

private static Join join(IntentGenerationContext context, EntityIntent source, RelationIntent relation, EntityIntent target,
String targetAlias, String baseAlias) {
FieldIntent targetPk = target == null ? null : primaryKeyOf(target);
private static Join join(IntentGenerationContext context, IntentModel model, EntityIntent source, RelationIntent relation,
EntityIntent target, String targetAlias, String baseAlias) {
String fkColumn = quote(column(source.getName(), relation.getName()));
// A cross-model target's table and primary-key column come from the resolved owner model -
// this model's intent-prefixed naming would point at a non-existent local table.
CrossModelSupport.TargetInfo info = crossModelInfo(context, model, relation);
if (info != null) {
return new Join(info.tableDataName(), targetAlias,
baseAlias + "." + fkColumn + " = " + targetAlias + "." + quote(info.keyColumn()));
}
FieldIntent targetPk = target == null ? null : primaryKeyOf(target);
String pkColumn = quote(column(targetAlias, targetPk == null ? "id" : targetPk.getName()));
return new Join(IntentNaming.tableName(context, targetAlias), targetAlias,
baseAlias + "." + fkColumn + " = " + targetAlias + "." + pkColumn);
}

/**
* The resolved owner-model facts for a cross-model relation, or null for a same-model one.
* Resolution mirrors the EDM generator (workspace, then registry; convention fallback only with a
* null context) and fails loudly for an unresolvable dependency - generate leaf-first.
*/
private static CrossModelSupport.TargetInfo crossModelInfo(IntentGenerationContext context, IntentModel model,
RelationIntent relation) {
if (!relation.isCrossModel()) {
return null;
}
for (UsesIntent uses : model.getUses()) {
if (relation.getModel()
.equals(uses.getModel())) {
return CrossModelSupport.resolve(context, uses, relation.getTo());
}
}
return null;
}

private static void registerJoin(Map<String, Join> joins, ColumnRef ref) {
if (ref.join != null) {
joins.putIfAbsent(ref.join.alias, ref.join);
Expand Down Expand Up @@ -351,7 +410,7 @@ private static String buildWhere(IntentGenerationContext context, IntentModel mo
if (relation != null && relation.getTo() != null) {
EntityIntent target = entityByName(model, relation.getTo());
String targetAlias = relation.getTo();
joins.putIfAbsent(targetAlias, join(context, source, relation, target, targetAlias, baseAlias));
joins.putIfAbsent(targetAlias, join(context, model, source, relation, target, targetAlias, baseAlias));
matcher.appendReplacement(dotted,
Matcher.quoteReplacement(targetAlias + "." + quote(column(targetAlias, matcher.group(2)))));
} else {
Expand Down Expand Up @@ -427,6 +486,13 @@ private static Map<String, Object> column(String tableAlias, String alias, Strin
column.put("grouping", grouping && "NONE".equals(aggregate));
column.put("tId", translationId(alias));
column.put("label", alias);
// Rendering metadata carried on the model so every report UI aligns and formats consistently:
// numeric columns right-align; decimals carry the platform money pattern.
boolean numeric = "INTEGER".equals(reportType) || "BIGINT".equals(reportType) || "DECIMAL".equals(reportType);
column.put("align", numeric ? "right" : "left");
if ("DECIMAL".equals(reportType)) {
column.put("pattern", "### ### ### ##0.00");
}
return column;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ reports:
```

**Rules:** `source` is a declared entity. A bare to-one relation dimension shows the target's label,

A dimension may bucket a date for aggregation: `month(field)` (a sortable YYYYMM integer, e.g.
202607) or `year(field)` — e.g. `dimensions: ["month(date)"]` with `measures: ["sum(total)", "sum(vat)"]`
for monthly income/VAT. (Uses standard-SQL `EXTRACT` — H2/PostgreSQL; not SQL Server.)
`relation.field` joins to a related field, `field` is a plain column.

### permissions - roles
Expand Down
Loading
Loading