From fa310dedaba8b5a46c27165d376aa142acd4a325 Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 2 Jul 2026 17:50:58 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(intent):=20multi-language=20data=20?= =?UTF-8?q?=E2=80=94=20Java=20Translator=20port,=20=5FLANG=20generation,?= =?UTF-8?q?=20language/file=20seeds,=20Harmonia=20Region=20&=20Language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the TS-era `multilingual` entity feature to the Java stack and exposes it through the intent DSL, with one per-user language flag driving frontend and backend. - SDK: new org.eclipse.dirigible.sdk.db.Translator (api-modules-java) — the db/translator.ts port with a name-based merge: reads _LANG (GUID, Id, , Language — the codbex-uoms-data convention), matches columns to entity fields case-insensitively (reads the whole table and filters in memory, so it is independent of identifier casing/quoting), normalizes locale tags to the primary subtag, tolerates a missing table and no-ops without a language. - DAO template: a multilingual="true" entity overrides ALL read paths — findById, findOne (the generated controller reads single records through findOne; findById alone missed GET-by-id, caught live), findAll x3, query — overlaying translations for the thread-bound Accept-Language (User.getLanguage(); listeners/jobs read base values). TS-parity caveat documented: editing under a non-base language saves displayed values. - Schema template: emits
_LANG per multilingual model (string-typed non-PK/FK/calculated/audit properties, columns named after the PROPERTY, Language VARCHAR(2)); own loop before the base tables so the existing comma logic stays untouched. - Intent DSL: entity `multilingual: true` -> EDM attribute (same one the EDM editor writes); seeds gain `language: bg` (translation rows -> _LANG csvim with auto-numbered GUIDs) and `file: data/x.csv` (large data sets reference an authored CSV — only the .csvim is generated; the path must be in a subfolder since root-level .csv files are intent-owned and scrubbed); top-level `languages: [en, bg]` -> .model root -> Harmonia config.js. - Harmonia: shared locale Alpine store (localStorage codbex.harmonia.language) + Region & Language picker in the generated Settings page (mirrors the IDE settings-locale view); the shared fetch client sends the value as Accept-Language on every call; the document Print flow prefers the configured language. Harmonia itself has no i18n API (verified 1.24.2) — UI-label i18n stays a follow-up on top of the store. - Docs: intent-assistant-guide, engine-intent/root/engine-java CLAUDE.md (the aspirational "repository carries multi-language support" claim now describes the real mechanism), Harmonia README. Tests: Translator-stack IntentEngineIT (multilingual_entity_generates_the_ translation_stack) + IntentParserTest/EdmIntentGeneratorTest/ CsvimIntentGeneratorTest additions. Verified live on the adapted sample-intent-multi-model uoms: list + by-id reads translate under Accept-Language: bg, base values without it, untranslated rows fall back; countries load from an authored data/countries.csv file seed. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- .../eclipse/dirigible/sdk/db/Translator.java | 236 ++++++++++++++++++ components/engine/engine-intent/CLAUDE.md | 2 + .../generator/csvim/CsvimIntentGenerator.java | 88 ++++++- .../generator/edm/EdmIntentGenerator.java | 13 + .../components/intent/model/EntityIntent.java | 23 ++ .../components/intent/model/IntentModel.java | 15 ++ .../components/intent/model/SeedIntent.java | 45 ++++ .../intent/parser/IntentParser.java | 79 +++++- .../main/resources/intent-assistant-guide.md | 49 +++- .../csvim/CsvimIntentGeneratorTest.java | 36 +++ .../generator/edm/EdmIntentGeneratorTest.java | 30 +++ .../intent/parser/IntentParserTest.java | 116 +++++++++ components/engine/engine-java/CLAUDE.md | 2 +- .../shell/js/components/pages/settingsPage.js | 19 ++ .../application-core/shell/js/services/api.js | 17 +- .../shell/js/stores/locale.js | 53 ++++ .../META-INF/dirigible/application/index.html | 1 + .../data/Repository.java.template | 48 ++++ .../data/application.schema.template | 42 ++++ .../README.md | 13 +- .../template/template.js | 3 + .../document/document-page.js.template | 7 +- .../ui/shell/index.html.template | 1 + .../ui/shell/js/config.js.template | 7 +- .../ui/shell/settings.html.template | 16 ++ .../integration/tests/api/IntentEngineIT.java | 71 ++++++ 27 files changed, 1021 insertions(+), 15 deletions(-) create mode 100644 components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/db/Translator.java create mode 100644 components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/locale.js diff --git a/CLAUDE.md b/CLAUDE.md index dab88777994..5997a4a6348 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ Client `.java` under `/registry/public//...` is synchronized by `JavaSy - **Two never-mixed handler styles** for jobs/listeners/websockets: a self-describing interface (`JobHandler.cron()`, `MessageHandler.destination()`, `WebsocketHandler.endpoint()`) **or** a method-level annotation (`@Scheduled`/`@Listener` on a `@Component` method; `@Websocket` class + `@OnX` methods). No reflective by-name fallback; the hybrid is rejected. - **Extension points are plain interfaces** + `@Component` contributions consumed via `List<…>` injection (or `Extensions.find`); there is no `@Extension`/`@ExtensionPoint`. - All client annotations/facades live in `org.eclipse.dirigible.sdk.*` (`api-modules-java`), not the old `engine.java.annotations.*`. Compile **and** bean-wiring errors surface in the IDE Problems view. -- **Manage entities ONLY through their generated `Repository` — never the generic `Store`/`Database` for entity CRUD.** The generated `@Repository extends JavaRepository` is the sole sanctioned load/save/update/delete path; it carries validations, **event publishing** (create/`-updated`/`-deleted` topics that intent triggers/reactions/rollups/notifications consume), and multi-language support. The name-keyed `org.eclipse.dirigible.sdk.db.Store` and raw `Database` SQL bypass all of that silently and must not touch a managed entity. (`updateWithoutEvent` is fine — a deliberate repository method that keeps validations/i18n and only omits the event, for workflow-driven system writes.) So a reusable delegate/service that must touch a *specific* entity lives **in that entity's project** (importing its repository); only entity-agnostic helpers belong in a shared project. See the engine-java guide. +- **Manage entities ONLY through their generated `Repository` — never the generic `Store`/`Database` for entity CRUD.** The generated `@Repository extends JavaRepository` is the sole sanctioned load/save/update/delete path; it carries validations, **event publishing** (create/`-updated`/`-deleted` topics that intent triggers/reactions/rollups/notifications consume), and — for `multilingual: true` entities — the **read-time translation overlay** (every find translates string properties from the sibling `
_LANG` table for the caller's `Accept-Language`, via the SDK `org.eclipse.dirigible.sdk.db.Translator`). The name-keyed `org.eclipse.dirigible.sdk.db.Store` and raw `Database` SQL bypass all of that silently and must not touch a managed entity. (`updateWithoutEvent` is fine — a deliberate repository method that keeps validations/i18n and only omits the event, for workflow-driven system writes.) So a reusable delegate/service that must touch a *specific* entity lives **in that entity's project** (importing its repository); only entity-agnostic helpers belong in a shared project. See the engine-java guide. **Detailed guide:** [`components/engine/engine-java/CLAUDE.md`](components/engine/engine-java/CLAUDE.md). Read it before changing anything under `engine-java`, `data-store-java`, the `sdk.*` annotations, or the `*-java` templates — it covers the container, the consumers, the two handler styles + no-mixing rule, the `JavaHandler`-as-bean path, controller routing / OpenAPI / `@Roles`, `data-store-java` dynamic-map persistence, error surfacing, the **removed** internals (`RepositoryRegistry` / `RepositoryClassConsumer` / `DependencyResolver` / reflective fallback / `@Extension`), and the three-repo (platform + `dirigiblelabs/sample-java-*` + docs) sequencing. @@ -185,7 +185,7 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **Detailed guide:** [`components/engine/engine-intent/CLAUDE.md`](components/engine/engine-intent/CLAUDE.md). Read it before changing anything under that module — it covers the editor-first architecture and altitude contract (model files only, never code), the YAML schema and its semantics (integer-only primary keys, `composition: true` to-one = DEPENDENT master-detail while `required` alone is just a NOT NULL FK, PascalCase property names with UPPER_SNAKE columns, decision `then`/`else`, intent-prefixed table names via `IntentNaming`), the `writeModelFile`-only write surface with the stale-output scrub, the wrong turns already made (wrong altitude, template-output paths, registry-relative vs repository-absolute paths, the `JsonHelper` Gson pitfall, **and the synchronizer-based first incarnation — do not reintroduce it**), and the follow-up list (chaining model-to-code via `.gen` descriptors, `/custom/` escape hatch). Process triggers (`trigger: { onCreate: }`) are wired: the EDM adds a `ProcessId` field + a `triggers` collection to the `.model`, and the `template-application-events-java` template generates a `gen/events/Trigger.java` listener that starts the process on create. That persisted `ProcessId` is in turn **consumed by the generated entity-view UI**: a shared `ProcessTasks` module (`components/resources/resources-dashboard/.../dashboard/services/process-tasks.js`) surfaces the record's actionable BPM user tasks inline via an `` directive (correlating `entity.ProcessId === task.processInstanceId`), wired into every generated view gated on a `hasProcess` flag; the task form completes via the permission-checked `/services/inbox/tasks/{id}` and self-closes (#6074). `IntentEngineIT` is the HTTP-only end-to-end test (~1 minute, no sync cycles). The editor's diagram pane is **mxGraph** (replacing Mermaid, which had unfixable light/dark theming bugs) with a fixed brand-colour palette that reads on both themes — see the module guide's "Intent Editor diagram = mxGraph" section before touching `editor-intent/js/editor.js`. -**Multi-model + layout additions (PRs [#6089](https://github.com/eclipse-dirigible/dirigible/pull/6089)-[#6092](https://github.com/eclipse-dirigible/dirigible/pull/6092)):** the DSL now supports building an app from **several intent models that reference each other cross-model** - a top-level `uses:` block names other models, and a relation gains an optional `model:` alias; a cross-model `manyToOne`/`oneToOne` is emitted as a read-only **PROJECTION** entity + integer FK + dropdown (the codbex cross-project pattern - no local table/DAO/controller for the target), resolved against the owner's already-generated `.model` (leaf-first generation; convention fallback otherwise). **n:m** is an explicit **intermediate entity** (composition to one side + `manyToOne` to the other, which may be cross-model, plus bridge fields like `amount`) - `manyToMany` is parsed but never materialized. New field attributes: `unique`, `precision`/`scale`, `calculatedOnCreate`/`calculatedOnUpdate` (a neutral arithmetic expression for numeric totals, else emitted verbatim into the runtime), `calculatedActionOnCreate`/`calculatedActionOnUpdate` (server-side call-out to a hand-written `@Component implements org.eclipse.dirigible.sdk.db.CalculatedField`, invoked as `Beans.get(.class).calculate(entity)`, taking precedence over the expression — for logic too custom to model, e.g. number generation); field `readOnly: true` (not editable; rendered in the Harmonia form's read-only details block — Label:Value above the buttons — via `isReadOnlyProperty`; `ProcessId`/audit columns/`uuid` are auto-flagged read-only, `status`-style fields opt in); field `major: false` (kept off the entity **list** table — the model's `widgetIsMajor="false"` — still shown in forms + the record details pane; defaults true); entity `imports:` (Java `import` lines injected into the generated repository so a calculated action can be referenced by simple name — Base64-encoded into the `.model`'s `importsCode`, which the Java DAO template emits; the editor's entity-level Imports tab is the model-editor equivalent); entity `audit: true` (the four standard audit columns); entity `group:` (the perspective's nav-group id in the shared application shell). **Depends-On** is exposed as `dependsOn: { relation, valueFrom?, filterBy? }` on a to-one relation (cascading/narrowed dropdown) or a field (auto-populated value) — emitted as the EDM `widgetDependsOn*` attributes (the AngularJS stacks consume them as-is; the Harmonia runtime — form/document watchers + the metadata-driven item-dialog cascade — was added alongside); defaults are the respective primary keys, names are the target's authored property names, cross-model triggers/targets supported. A master owning an `*Item` composition child renders as the **document (header-items) layout** (`MANAGE_DOCUMENT` + `documentItemsEntity`, `uiDocumentModels`), with `aggregate: true` fields shown in the totals footer. `IntentNaming.upperSnake` collapses kebab/space/`.`/`/` separators so a hyphenated model name yields a valid SQL identifier (`sales-invoices` -> `SALES_INVOICES`). Worked example: `dirigiblelabs/sample-intent-multi-model` (six interdependent projects + a navigation-groups project). +**Multi-model + layout additions (PRs [#6089](https://github.com/eclipse-dirigible/dirigible/pull/6089)-[#6092](https://github.com/eclipse-dirigible/dirigible/pull/6092)):** the DSL now supports building an app from **several intent models that reference each other cross-model** - a top-level `uses:` block names other models, and a relation gains an optional `model:` alias; a cross-model `manyToOne`/`oneToOne` is emitted as a read-only **PROJECTION** entity + integer FK + dropdown (the codbex cross-project pattern - no local table/DAO/controller for the target), resolved against the owner's already-generated `.model` (leaf-first generation; convention fallback otherwise). **n:m** is an explicit **intermediate entity** (composition to one side + `manyToOne` to the other, which may be cross-model, plus bridge fields like `amount`) - `manyToMany` is parsed but never materialized. New field attributes: `unique`, `precision`/`scale`, `calculatedOnCreate`/`calculatedOnUpdate` (a neutral arithmetic expression for numeric totals, else emitted verbatim into the runtime), `calculatedActionOnCreate`/`calculatedActionOnUpdate` (server-side call-out to a hand-written `@Component implements org.eclipse.dirigible.sdk.db.CalculatedField`, invoked as `Beans.get(.class).calculate(entity)`, taking precedence over the expression — for logic too custom to model, e.g. number generation); field `readOnly: true` (not editable; rendered in the Harmonia form's read-only details block — Label:Value above the buttons — via `isReadOnlyProperty`; `ProcessId`/audit columns/`uuid` are auto-flagged read-only, `status`-style fields opt in); field `major: false` (kept off the entity **list** table — the model's `widgetIsMajor="false"` — still shown in forms + the record details pane; defaults true); entity `imports:` (Java `import` lines injected into the generated repository so a calculated action can be referenced by simple name — Base64-encoded into the `.model`'s `importsCode`, which the Java DAO template emits; the editor's entity-level Imports tab is the model-editor equivalent); entity `audit: true` (the four standard audit columns); entity `group:` (the perspective's nav-group id in the shared application shell). **Depends-On** is exposed as `dependsOn: { relation, valueFrom?, filterBy? }` on a to-one relation (cascading/narrowed dropdown) or a field (auto-populated value) — emitted as the EDM `widgetDependsOn*` attributes (the AngularJS stacks consume them as-is; the Harmonia runtime — form/document watchers + the metadata-driven item-dialog cascade — was added alongside); defaults are the respective primary keys, names are the target's authored property names, cross-model triggers/targets supported. **Multi-language data** (the TS-era `multilingual` port): entity `multilingual: true` → the schema layer generates a sibling `
_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention) and the generated Java repository overlays translated values on every read for the request's `Accept-Language` (SDK `Translator`, name-based merge); top-level `languages: [en, bg]` feeds the Harmonia shell's **Region & Language** Settings entry (an Alpine `locale` store, localStorage `codbex.harmonia.language`, sent as `Accept-Language` by the shared fetch client — one flag drives UI, data, and the Print default); translations are authored as seeds with `language: bg`, and large data sets reference an authored CSV via seed `file: data/x.csv` (subfolder mandatory — root `.csv` is scrub-owned) instead of inline rows. A master owning an `*Item` composition child renders as the **document (header-items) layout** (`MANAGE_DOCUMENT` + `documentItemsEntity`, `uiDocumentModels`), with `aggregate: true` fields shown in the totals footer. `IntentNaming.upperSnake` collapses kebab/space/`.`/`/` separators so a hyphenated model name yields a valid SQL identifier (`sales-invoices` -> `SALES_INVOICES`). Worked example: `dirigiblelabs/sample-intent-multi-model` (six interdependent projects + a navigation-groups project). **The general platform line this enshrines:** authoring artifacts (`.edm`, `.model`, `.form`, `.report`, `.intent`) get **workspace editors + an explicit Generate**; only runtime artifacts (`.roles`, `.bpmn`, `.csvim`, `.table`, jobs, listeners, …) get **synchronizers**. Applying the synchronizer hammer to an authoring artifact generates into the registry where no modeler, Projects view, or template can use it — that mistake was made once and reverted; the inventory of synchronizers (grep `extends BaseSynchronizer`) deliberately contains no authoring formats. diff --git a/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/db/Translator.java b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/db/Translator.java new file mode 100644 index 00000000000..b40c4f39f1f --- /dev/null +++ b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/db/Translator.java @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.db; + +import java.lang.reflect.Field; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Translates entity properties from a dedicated language table - the Java counterpart of the + * TypeScript {@code db/translator} module. A multilingual entity keeps its base values in its own + * table and per-language overrides in a sibling <TABLE>_LANG table shaped + * GUID, Id, <translated property columns>, Language (the codbex convention - + * e.g. {@code CODBEX_UOM_LANG}): {@code Id} references the base row, {@code Language} is the + * language code (e.g. {@code bg}), and every other column is named after the entity property + * it overrides (e.g. {@code Name}). + * + *

+ * {@link #translateList(List, String, String)} and + * {@link #translateEntity(Object, Object, String, String)} overlay the translated values onto + * already-loaded entities by matching language-table columns to entity fields by name, + * case-insensitively ({@code GUID}/{@code Id}/{@code + * Language} excluded). The whole language table is read and filtered in memory - translation tables + * are small nomenclature data, and this keeps the lookup independent of how the table's identifiers + * were cased/quoted at creation time. + * + *

+ * A {@code null}/blank language is a no-op, and a full locale tag is reduced to its primary subtag + * ({@code en-US} → {@code en}). A missing language table is tolerated (logged, the entities + * stay untranslated) so a multilingual entity works before its translations arrive. + */ +public final class Translator { + + private static final Logger LOGGER = LoggerFactory.getLogger(Translator.class); + + private static final String LANGUAGE_TABLE_SUFFIX = "_LANG"; + private static final String ID_COLUMN = "Id"; + private static final String GUID_COLUMN = "GUID"; + private static final String LANGUAGE_COLUMN = "Language"; + + private Translator() {} + + /** + * Overlay the translated property values for the given language onto every entity in the list. + * + * @param the entity type (public fields named after the model properties) + * @param list the loaded entities; may be null or empty + * @param language the target language code or locale tag; null/blank skips translation + * @param table the BASE table name (the language table is {@code

_LANG}) + * @return the same list, with translated values applied where present + */ + public static List translateList(List list, String language, String table) { + String lang = normalize(language); + if (list == null || list.isEmpty() || lang == null) { + return list; + } + List> rows = readLanguageRows(table, lang); + if (rows.isEmpty()) { + return list; + } + Map> byId = new HashMap<>(); + for (Map row : rows) { + Object id = valueIgnoreCase(row, ID_COLUMN); + if (id != null) { + byId.put(String.valueOf(id), row); + } + } + for (T entity : list) { + Object id = readField(entity, ID_COLUMN); + if (id == null) { + continue; + } + Map row = byId.get(String.valueOf(id)); + if (row != null) { + overlay(entity, row); + } + } + return list; + } + + /** + * Overlay the translated property values for the given language onto a single entity. + * + * @param the entity type (public fields named after the model properties) + * @param entity the loaded entity; may be null + * @param id the entity's identifier (matched against the language table's {@code Id}) + * @param language the target language code or locale tag; null/blank skips translation + * @param table the BASE table name (the language table is {@code
_LANG}) + * @return the same entity, with translated values applied where present + */ + public static T translateEntity(T entity, Object id, String language, String table) { + String lang = normalize(language); + if (entity == null || id == null || lang == null) { + return entity; + } + for (Map row : readLanguageRows(table, lang)) { + Object rowId = valueIgnoreCase(row, ID_COLUMN); + if (rowId != null && String.valueOf(rowId) + .equals(String.valueOf(id))) { + overlay(entity, row); + break; + } + } + return entity; + } + + /** + * Reduce a locale tag to its primary language subtag, lower-cased ({@code en-US} -> {@code en}); + * null for a null/blank input. + */ + private static String normalize(String language) { + if (language == null || language.isBlank()) { + return null; + } + String primary = language.trim() + .split("[-_,;]")[0]; + return primary.isBlank() ? null : primary.toLowerCase(Locale.ROOT); + } + + /** + * Read the whole language table and keep the rows of the requested language. The table name is + * quoted (platform-created tables carry exact-case quoted identifiers); the language match is done + * in memory, case-insensitively on both the column name and the code, so hand-created tables with + * folded identifiers work too. A missing table is tolerated. + */ + private static List> readLanguageRows(String table, String language) { + List> rows = new ArrayList<>(); + String languageTable = table + LANGUAGE_TABLE_SUFFIX; + String script = "SELECT * FROM \"" + languageTable + "\""; + try (Connection connection = Database.getConnection(); + PreparedStatement statement = connection.prepareStatement(script); + ResultSet resultSet = statement.executeQuery()) { + ResultSetMetaData metaData = resultSet.getMetaData(); + int columns = metaData.getColumnCount(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= columns; i++) { + row.put(metaData.getColumnLabel(i), resultSet.getObject(i)); + } + Object rowLanguage = valueIgnoreCase(row, LANGUAGE_COLUMN); + if (rowLanguage != null && language.equalsIgnoreCase(String.valueOf(rowLanguage) + .trim())) { + rows.add(row); + } + } + } catch (Throwable e) { + LOGGER.warn("Entity is marked as language dependent, but its language table [{}] is not accessible", languageTable, e); + } + return rows; + } + + /** + * Set every language-row column onto the matching entity field (by name, case-insensitively), + * skipping the {@code GUID}/{@code Id}/{@code Language} bookkeeping columns, null values, and + * columns without a matching field. + */ + private static void overlay(Object entity, Map row) { + for (Map.Entry column : row.entrySet()) { + String name = column.getKey(); + Object value = column.getValue(); + if (value == null || GUID_COLUMN.equalsIgnoreCase(name) || ID_COLUMN.equalsIgnoreCase(name) + || LANGUAGE_COLUMN.equalsIgnoreCase(name)) { + continue; + } + Field field = fieldIgnoreCase(entity.getClass(), name); + if (field == null) { + continue; + } + try { + if (field.getType() + .isAssignableFrom(value.getClass())) { + field.set(entity, value); + } else if (field.getType() == String.class) { + field.set(entity, String.valueOf(value)); + } + } catch (IllegalAccessException e) { + LOGGER.warn("Cannot overlay translated value onto field [{}] of [{}]", field.getName(), entity.getClass() + .getName(), + e); + } + } + } + + private static Object readField(Object entity, String name) { + Field field = fieldIgnoreCase(entity.getClass(), name); + if (field == null) { + return null; + } + try { + return field.get(entity); + } catch (IllegalAccessException e) { + LOGGER.warn("Cannot read field [{}] of [{}]", name, entity.getClass() + .getName(), + e); + return null; + } + } + + private static Field fieldIgnoreCase(Class type, String name) { + for (Field field : type.getFields()) { + if (field.getName() + .equalsIgnoreCase(name)) { + return field; + } + } + return null; + } + + private static Object valueIgnoreCase(Map row, String column) { + for (Map.Entry entry : row.entrySet()) { + if (entry.getKey() + .equalsIgnoreCase(column)) { + return entry.getValue(); + } + } + return null; + } +} diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 25f24f316d4..d6c651a78e2 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -264,6 +264,7 @@ Semantics worth knowing: - **`init: ` on a to-one relation = the FK's DB-level default (`RelationIntent.init` → `dataDefaultValue` on the FK property, in both `relationProperty` and `crossModelRelationProperty`).** The relation analogue of a field's `defaultValue`; a new row gets this FK on insert when the column is left unset (e.g. `Status` defaults to the DRAFT seed, `PaymentMethod` to Bank, `SentMethod` to E-mail). **Use `init` for an INITIAL status, never a process step.** A `setRelationField` serviceTask that sets the status on process *start* races the trigger's `ProcessId` write-back: the generated `Trigger` loads the entity (status null), calls `Process.start`, then does `entity.ProcessId = id; updateWithoutEvent(entity)` — a **full-row** `super.update` with the stale pre-step copy — which clobbers the status the start-step just set (confirmed live: the invoice reached the Approve task but `Status` stayed null). A DB default is applied at insert, before the trigger reads the row, so it round-trips cleanly. `setRelationField` is correct only for *transitions* (after a user task / on a decision branch), where nothing else writes the row concurrently. - **`trigger: { onCreate|onUpdate|onDelete: , when: "" }` starts the process on the named `` lifecycle event** - fully wired (Java). Any of the three events is supported: `onCreate` binds the entity's base topic, `onUpdate`/`onDelete` the `-updated`/`-deleted` topics the Java DAO publishes (`TriggerSupport` + `EventBinding`); an optional `when` guard (a single `field ==|!= literal`, via `NotificationSupport.guard`) gates `Process.start`. Three parts: (1) the parser validates at most one event kind and that the target is a declared entity; (2) the EDM generator adds a `ProcessId` back-reference property (VARCHAR) to that entity and a `triggers` collection to the `.model` (`TriggerSupport` + `EdmIntentGenerator.buildTriggers`); (3) the **`template-application-events-java`** template (intent-driven, like the other language templates) reads that `triggers` collection and emits one **`gen/events/Trigger.java`** per trigger - a client-Java self-describing `MessageHandler` (a `@Component` whose `destination()` is the entity's per-operation topic via `topicSuffix` and whose `kind()` is `TOPIC`) that loads the entity, applies the `when` guard, calls `Process.start(, businessKey, )`, and writes the instance id back to `ProcessId` (so it starts at most once). The Java DAO template (`template-application-dao-java`) now publishes the create event (`Producer.sendToTopic('${projectName}-${perspectiveName}-${name}', json)`) the way the TS DAO does - that's the topic the handler binds to. `gen/events` is a sibling of `gen/`, so it survives the per-model regeneration wipe. The events template iterates the model's `triggers` via a new **`triggers` collection case in `service-generate/template/generateUtils.js`** (the engine's collection switch is hardcoded; the case has its own loop because triggers are not entity-shaped). The BPM **business key** defaults to the entity's primary key but is **configurable**: `trigger: { ..., businessKey: }` names which trigger-entity field becomes the started instance's business key (the listener still loads the entity by its PK via `findById`; only the business key differs — a separate `businessKeyProperty` in `.glue`). An optional `businessKeyStrategy: timestamp` mints a `yyyyMMddHHmmss` value into that field when it is blank and persists it via the listener's existing update — the simple "for now" generator and the **extension point** for richer pluggable number generators later (sequential, zero-padded, config-prefixed invoice numbers); the parser validates the field exists, the strategy is supported, and (for `timestamp`) the field is `string`/`text`. `TriggerSupport.triggerBusinessKey`/`triggerBusinessKeyStrategy` read them; `GlueIntentGenerator` emits `businessKeyProperty` + `generateBusinessKey`; `Trigger.java.template` renders the mint-if-blank block. `onSchedule` is still unmodelled. **Casing subtlety in the generated handler:** its `import gen..data..{Entity,Repository}` must use the **lowercased** Java package segment (`javaPerspective` = `sanitizeJavaIdentifier(perspective)`, matching the DAO/entity templates' `javaPerspectiveName` folder), while the `destination()` topic (`"--"`) keeps the **raw** perspective so it matches the topic the DAO publishes to (`${projectName}-${perspectiveName}-${name}`). The `triggers` collection case in `generateUtils.js` supplies both (`javaPerspective` for the import, `perspective` for the topic). Using the raw perspective in the import compiled on macOS (case-insensitive FS) but failed `javac` with "package gen.x.data.Member does not exist" because the entity files declare the lowercased package. - **`dependsOn` on a to-one relation or a field = the EDM Depends-On feature (cascading dropdowns + auto-populated fields).** `dependsOn: { relation: , valueFrom?: , filterBy?: }` — the widget reacts to the sibling trigger: the generated form loads the trigger's selected record, reads `valueFrom` (default: the trigger target's PK), then a **relation** re-filters its dropdown options where its own target's `filterBy` (default: that target's PK) equals the value (`POST /search` with an EQ condition; a single remaining option auto-selects), while a **field** copies the value (auto-population; `valueFrom` mandatory, `filterBy` rejected). Emitted by `EdmIntentGenerator.putDependsOn` as the four scalar `widgetDependsOn*` property attributes the AngularJS stacks already consume (so those work for free); the Harmonia runtime was added in the same pass (`form-page.js.template` per-property watcher + `applyDependsOn` methods covering manage/master-detail/allocation forms; `document-page.js.template` header watchers + a generic metadata-driven `applyDraftDependsOn` for the line-item dialog off `detail-register.js.template`'s `editColumns[].dependsOn`, with filtered options in a separate `draftOptions` store so the items table's label resolution keeps the full set; `parameterUtils.js` precomputes `widgetDependsOnControllerUrl` from the trigger sibling). `valueFrom`/`filterBy` use the target's **authored** property names (field lower-camel / relation as declared); same-model references are parse-validated, cross-model ones generation-validated against the resolved owner model (`CrossModelSupport.TargetInfo.propertyNames`). A `documentStatus` relation can neither declare nor trigger a dependsOn. Canonical cases (the `codbex-sample-model-depends-on` set): Country→City cascade (`filterBy` only), Product→UoM narrow-to-referenced (`valueFrom` only), Product→price auto-populate (field). +- **`multilingual: true` on an entity + `language:`/`file:` seeds + top-level `languages:` = the multi-language data stack.** A multilingual entity's translatable (string-typed) properties may carry per-language values in a sibling `
_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention). `EdmIntentGenerator` emits the EDM `multilingual="true"` entity attribute (the same one the EDM editor writes); the schema template generates the language table from it; the Java DAO template overrides every finder to overlay translations via the SDK `org.eclipse.dirigible.sdk.db.Translator` for the caller's `Accept-Language` (thread-bound `User.getLanguage()`; null → no-op, so listeners/jobs read base values). Translations are authored as **seeds with a `language: bg` code** → `CsvimIntentGenerator` writes them into `
_LANG` (`GUID` auto-numbered, `Language` constant; parser validates the entity is multilingual and row keys are `id` + string/text fields). **Large data sets stay out of the intent**: a seed may reference an authored CSV via `file: data/countries.csv` (exactly one of `file`/`rows`; the path MUST be in a subfolder — root-level `.csv` files are intent-owned and scrubbed) — only the `.csvim` is generated, pointing at the developer-owned file. Top-level `languages: [en, bg]` lands on the `.model` root → Harmonia `config.js` `languages` → the shell Settings page's **Region & Language** picker (mirroring the IDE's `settings-locale` view), backed by the shared `locale` Alpine store (localStorage `codbex.harmonia.language`) whose value the shared fetch client sends as `Accept-Language` on every call — one flag drives the backend translation, and the document Print flow prefers it too. Caveat (TS parity): editing a record while a non-base language is active saves the displayed (translated) values into the base table — translations are maintained via seeds/DB, not through the generated UI. - **The YAML `name:` field is the intent's identity for outputs.** `IntentNaming.baseName` prefers it over the artefact name derived from the file name (which is conventionally just `app` from `app.intent`); single-file outputs are `.edm` / `.model` / `.roles` and the table prefix is its upper-snake. - **Physical table names are intent-prefixed**: `_` upper-snake (`ORDERS_ORDER`), via `IntentNaming.tableName`, consistently across `.edm` `dataName`, `.report` `table` and `.csvim` `table`. This avoids SQL reserved words (`ORDER`, `USER`, ...) and cross-project collisions in a shared schema. If the downstream "Generate from EDM" wizard asks for a table prefix, intent projects must leave it empty - the prefix is already part of `dataName`. @@ -424,6 +425,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` / - **Standard print templates (`PrintIntentGenerator`, @Order(800), PR #6119).** One `.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 ``s (non-PK/non-aggregate fields + to-one relations), `
` 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//Print/en/` create-if-absent), not by a code template. Data contract: `{{document.}}` + items rows with `{{}}` — 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. +- **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 `
_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). - **Task-form data sync: clear-D id-only context + live fetch + write-out.** A BPM user-task form must show the entity's **current** data. The first cut hydrated the process variables at task creation, but for an `onCreate`-triggered **document** that instant is when the header is created **empty** (line items, and the total, are added afterwards) — so the Approve form showed `Total: 0`. The final design is **clear D**: the process context carries **only the entity id**, and everything else is fetched dynamically when needed. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java index 4ce61cf3182..acc02144dc2 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java @@ -99,7 +99,14 @@ public void generate(IntentGenerationContext context) { IntentNaming.baseName(context)); continue; } - context.writeModelFile(fileName + ".csv", renderCsv(orderedFieldsOf(entity), entity, seed)); + // A file seed references an AUTHORED CSV (large data sets - countries, currencies, ...): + // only the .csvim is generated, pointing at the developer-owned file. Otherwise the CSV + // body is generated from the inline rows - into the sibling
_LANG table for a + // language seed (`language: bg`, translations for a multilingual entity), else the base. + if (!seed.isFileSeed()) { + String csv = seed.isLanguageSeed() ? renderLanguageCsv(entity, seed) : renderCsv(orderedFieldsOf(entity), entity, seed); + context.writeModelFile(fileName + ".csv", csv); + } context.writeModelFile(fileName + ".csvim", renderCsvim(context, seed, entity, fileName)); } } @@ -131,10 +138,11 @@ private static List orderedFieldsOf(EntityIntent entity) { } /** - * Test seam: render a seed's CSV body without a generation context. Never use in production code. + * Test seam: render a seed's CSV body without a generation context - dispatching between the base + * and the language-table shape exactly like {@link #generate}. Never use in production code. */ static String renderCsvForTest(EntityIntent entity, SeedIntent seed) { - return renderCsv(orderedFieldsOf(entity), entity, seed); + return seed.isLanguageSeed() ? renderLanguageCsv(entity, seed) : renderCsv(orderedFieldsOf(entity), entity, seed); } private static String renderCsv(List fields, EntityIntent entity, SeedIntent seed) { @@ -218,12 +226,82 @@ private static String formatCell(Object value) { return QUOTE_DELIM + s.replace(QUOTE_DELIM, QUOTE_DELIM + QUOTE_DELIM) + QUOTE_DELIM; } + /** + * CSV body of a translation seed: {@code GUID,Id,, + * Language} - the shape of the schema-generated {@code + *
+ * _LANG} table. {@code GUID} is auto-numbered by row order, {@code Id} references the translated + * base row, {@code Language} is the seed's constant code. Only translatable (string-typed, non-PK) + * fields referenced by at least one row become columns. + */ + private static String renderLanguageCsv(EntityIntent entity, SeedIntent seed) { + List translatable = new ArrayList<>(); + for (FieldIntent field : orderedFieldsOf(entity)) { + if (field.isPrimaryKey() || !isTranslatableType(field)) { + continue; + } + for (Map row : seed.getRows()) { + if (row.containsKey(field.getName())) { + translatable.add(field); + break; + } + } + } + String idField = primaryKeyName(entity); + StringBuilder sb = new StringBuilder(256); + sb.append("GUID") + .append(FIELD_DELIM) + .append("Id"); + for (FieldIntent field : translatable) { + sb.append(FIELD_DELIM) + .append(IntentNaming.pascalCase(field.getName())); + } + sb.append(FIELD_DELIM) + .append("Language") + .append('\n'); + int guid = 1; + for (Map row : seed.getRows()) { + sb.append(guid++) + .append(FIELD_DELIM) + .append(formatCell(row.get(idField))); + for (FieldIntent field : translatable) { + sb.append(FIELD_DELIM) + .append(formatCell(row.get(field.getName()))); + } + sb.append(FIELD_DELIM) + .append(formatCell(seed.getLanguage())) + .append('\n'); + } + return sb.toString(); + } + + /** Whether the field's logical type maps to a translatable (string) column. */ + private static boolean isTranslatableType(FieldIntent field) { + String type = field.getType() == null ? "string" + : field.getType() + .toLowerCase(java.util.Locale.ROOT); + return "string".equals(type) || "text".equals(type); + } + + /** The entity's primary-key field name ({@code id} by convention). */ + private static String primaryKeyName(EntityIntent entity) { + for (FieldIntent field : entity.getFields()) { + if (field.isPrimaryKey() && field.getName() != null) { + return field.getName(); + } + } + return "id"; + } + private static String renderCsvim(IntentGenerationContext context, SeedIntent seed, EntityIntent entity, String fileName) { Map entry = new LinkedHashMap<>(); - entry.put("table", IntentNaming.tableName(context, entity.getName())); + entry.put("table", IntentNaming.tableName(context, entity.getName()) + (seed.isLanguageSeed() ? "_LANG" : "")); entry.put("schema", seed.getSchema() == null || seed.getSchema() .isBlank() ? DEFAULT_SCHEMA : seed.getSchema()); - entry.put("file", "/" + context.getProjectName() + "/" + fileName + ".csv"); + String csvPath = seed.isFileSeed() ? seed.getFile() + .trim() + : fileName + ".csv"; + entry.put("file", "/" + context.getProjectName() + "/" + csvPath); entry.put("header", true); entry.put("useHeaderNames", true); entry.put("delimField", FIELD_DELIM); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index 0de9c7021a5..a32316c290a 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -194,6 +194,12 @@ else if (!dependent && !setting && !compositionParents.containsValue(name)) { if (entity.isDashboardExcluded()) { entityMap.put("dashboardWidget", "false"); } + // multilingual: the entity keeps per-language values in a sibling
_LANG table. The + // schema template generates that table and the Java DAO template overlays translated values + // on every read for the caller's Accept-Language (same attribute the EDM editor writes). + if (entity.isMultilingual()) { + entityMap.put("multilingual", "true"); + } // Custom Java imports for the generated entity Repository (e.g. a calculated-field action's // CalculatedField class). Base64-encoded to match the EDM editor's serialization, which the // DAO template's parameterUtils decodes before emitting them into the import block. @@ -295,6 +301,13 @@ else if (!dependent && !setting && !compositionParents.containsValue(name)) { if (notBlank(icon)) { body.put("icon", icon); } + // Data-language codes the app offers (intent `languages:`). Carried on the .model root (JSON + // only - the XML root renders no attributes); the Harmonia shell's Region & Language setting + // lists them and sends the choice as Accept-Language on every request. + if (!model.getLanguages() + .isEmpty()) { + body.put("languages", new ArrayList<>(model.getLanguages())); + } body.put("entities", entityList); body.put("perspectives", perspectiveList); body.put("navigations", new ArrayList<>()); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java index b9faa190b03..04ab38faabe 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java @@ -60,6 +60,16 @@ public class EntityIntent { * {@code importsCode} the DAO template emits. Absent → no custom imports. */ private String imports; + /** + * Marks the entity as multilingual: its translatable (string-typed) properties may carry + * per-language values in a sibling {@code + *
+ * _LANG} table (the codbex convention). Emitted as the EDM entity attribute + * {@code multilingual="true"}; the schema template then generates the language table and the Java + * DAO template overlays translated values on every read for the caller's {@code Accept-Language}. + * Translation rows are authored as {@code seeds} with a {@code language:} code. + */ + private Boolean multilingual; private List fields = new ArrayList<>(); private List relations = new ArrayList<>(); @@ -160,4 +170,17 @@ public List getRelations() { public void setRelations(List relations) { this.relations = relations == null ? new ArrayList<>() : relations; } + + /** Whether this entity keeps per-language values in a sibling language table. */ + public boolean isMultilingual() { + return multilingual != null && multilingual; + } + + public Boolean getMultilingual() { + return multilingual; + } + + public void setMultilingual(Boolean multilingual) { + this.multilingual = multilingual; + } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java index df1294c2aa4..cc535156323 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java @@ -35,6 +35,13 @@ public class IntentModel { /** Other intent models this one references cross-model (see {@link UsesIntent}). */ private List uses = new ArrayList<>(); + /** + * Optional data-language codes the app offers (e.g. {@code [en, bg]}, short lowercase codes; + * {@code en} - the base data - when omitted). Emitted onto the {@code .model} root; the Harmonia + * shell's Region & Language setting lists them and sends the choice as {@code + * Accept-Language} on every call, which the generated multilingual repositories translate by. + */ + private List languages = new ArrayList<>(); private List entities = new ArrayList<>(); private List processes = new ArrayList<>(); @@ -184,4 +191,12 @@ public List getRollups() { public void setRollups(List rollups) { this.rollups = rollups == null ? new ArrayList<>() : rollups; } + + public List getLanguages() { + return languages; + } + + public void setLanguages(List languages) { + this.languages = languages; + } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java index 6b4529e12cf..9838e878700 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java @@ -32,6 +32,25 @@ public class SeedIntent { private String schema; private String description; private List> rows = new ArrayList<>(); + /** + * Optional language code (e.g. {@code bg}): the seed carries translations for a multilingual + * entity instead of base rows. Rows are keyed by {@code id} (the translated base row) plus the + * entity's translatable field names; the generator writes them into the entity's {@code + *
+ * _LANG} table ({@code GUID} auto-numbered, {@code Language} constant). + */ + private String language; + /** + * Optional project-relative path to an authored CSV file carrying the seed data (e.g. + * {@code data/countries.csv}) - the alternative to inline {@link #rows} for large data sets + * (countries, currencies, ...) that would bloat the intent. Exactly one of {@code rows}/{@code + * file} is allowed. The generator then emits only the {@code .csvim} pointing at the file; the CSV + * itself is developer-owned. Its header must carry the physical column names (the + * {@code _} shape a generated seed CSV uses; {@code GUID,Id,...,Language} for a + * language seed). The file must live in a subfolder - root-level {@code .csv} files are owned and + * scrubbed by the intent regeneration. + */ + private String file; public String getName() { return name; @@ -84,4 +103,30 @@ public void setRows(List> rows) { public void addRow(Map row) { this.rows.add(row == null ? new LinkedHashMap<>() : row); } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + /** Whether this seed carries translations (has a {@code language:} code) rather than base rows. */ + public boolean isLanguageSeed() { + return language != null && !language.isBlank(); + } + + public String getFile() { + return file; + } + + public void setFile(String file) { + this.file = file; + } + + /** Whether this seed references an authored CSV file instead of inline rows. */ + public boolean isFileSeed() { + return file != null && !file.isBlank(); + } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 1c5cac4e4b1..f46b4cfffd9 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -153,6 +153,7 @@ private static void validate(IntentModel model) { validateForms(model, entityNames, issues); validateReports(model, entityNames, issues); validateSeeds(model, entityNames, issues); + validateLanguages(model, issues); validateNotifications(model, entityNames, issues); validateSchedules(model, entityNames, issues); validateIntegrations(model, entityNames, issues); @@ -1173,6 +1174,12 @@ private static void validateReports(IntentModel model, Set entityNames, } private static void validateSeeds(IntentModel model, Set entityNames, List issues) { + java.util.Map byName = new java.util.HashMap<>(); + for (EntityIntent entity : model.getEntities()) { + if (entity.getName() != null) { + byName.put(entity.getName(), entity); + } + } Set seedNames = new HashSet<>(); for (SeedIntent seed : model.getSeeds()) { if (seed.getName() == null || seed.getName() @@ -1189,10 +1196,78 @@ private static void validateSeeds(IntentModel model, Set entityNames, Li } else if (!entityNames.contains(seed.getEntity())) { issues.add("seed [" + seed.getName() + "] targets unknown entity [" + seed.getEntity() + "]"); } - if (seed.getRows() - .isEmpty()) { + if (seed.isFileSeed()) { + // The seed data lives in an authored CSV: inline rows are mutually exclusive, and the + // file must sit in a subfolder - root-level .csv files are owned and scrubbed by the + // intent regeneration, which would delete the authored data. + if (!seed.getRows() + .isEmpty()) { + issues.add("seed [" + seed.getName() + "] declares both `file` and inline `rows` - use exactly one"); + } + String file = seed.getFile() + .trim(); + if (file.startsWith("/") || file.contains("..")) { + issues.add("seed [" + seed.getName() + "] file [" + file + "] must be a project-relative path"); + } else if (!file.contains("/")) { + issues.add("seed [" + seed.getName() + "] file [" + file + "] must live in a subfolder (e.g. data/" + file + + ") - root-level .csv files are owned and scrubbed by the intent regeneration"); + } + } else if (seed.getRows() + .isEmpty()) { issues.add("seed [" + seed.getName() + "] has no rows"); } + if (seed.isLanguageSeed()) { + validateLanguageSeed(seed, byName.get(seed.getEntity()), issues); + } + } + } + + /** + * A translation seed ({@code language: bg}) targets a multilingual entity's language table: the + * code is a short lowercase language code, and its rows carry only the base row's {@code id} plus + * translatable (string/text, non-PK) fields of the entity. + */ + private static void validateLanguageSeed(SeedIntent seed, EntityIntent entity, List issues) { + if (!seed.getLanguage() + .matches("[a-z]{2,3}")) { + issues.add("seed [" + seed.getName() + "] language [" + seed.getLanguage() + + "] must be a short lowercase language code (e.g. bg)"); + } + if (entity == null) { + return; // the unknown entity is reported separately + } + if (!entity.isMultilingual()) { + issues.add("seed [" + seed.getName() + "] carries translations but entity [" + entity.getName() + + "] is not multilingual - add `multilingual: true` to the entity"); + return; + } + Set allowed = new HashSet<>(); + for (FieldIntent field : entity.getFields()) { + if (field.getName() == null) { + continue; + } + String type = field.getType() == null ? "string" + : field.getType() + .toLowerCase(Locale.ROOT); + if (field.isPrimaryKey() || "string".equals(type) || "text".equals(type)) { + allowed.add(field.getName()); + } + } + for (java.util.Map row : seed.getRows()) { + for (String key : row.keySet()) { + if (!allowed.contains(key)) { + issues.add("seed [" + seed.getName() + "] row references [" + key + + "] which is not the id or a translatable (string/text) field of [" + entity.getName() + "]"); + } + } + } + } + + private static void validateLanguages(IntentModel model, List issues) { + for (String language : model.getLanguages()) { + if (language == null || !language.matches("[a-z]{2,3}")) { + issues.add("languages entry [" + language + "] must be a short lowercase language code (e.g. en, bg)"); + } } } } diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 351e71c16ab..2aab25ccae4 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -157,6 +157,24 @@ composition is opt-in. **Audit columns:** `audit: true` on an entity adds the four standard audit columns (`CreatedAt`, `CreatedBy`, `UpdatedAt`, `UpdatedBy`), populated by the platform's audit annotations. +**Multilingual entities (`multilingual: true`):** the entity's translatable (string-typed) properties +may carry per-language values in a sibling `
_LANG` table (generated automatically by the schema +layer: `GUID, Id, , Language`). Every read of the generated Java +repository overlays the translated values for the caller's `Accept-Language` - the Harmonia shell's +Region & Language setting (fed by the top-level `languages:`) sends the user's choice on every call. +Author translations as seeds with a `language:` code (see seeds). Typical for nomenclatures: + +```yaml +languages: [en, bg] # top level: the data languages the app offers +entities: + - name: UoM + kind: setting + multilingual: true + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } +``` + **Custom imports (`imports:` on an entity):** a multi-line string of Java `import ...;` lines injected verbatim into that entity's generated repository, so a calculated-field action (or any custom class) can be referenced from the calculated fields by simple name. Pair it with `calculatedActionOnCreate`: @@ -406,7 +424,36 @@ seeds: - { id: 2, name: Reference } ``` -**Rules:** `entity` must be declared; integer `id`s stay integral. +**Rules:** `entity` must be declared; integer `id`s stay integral. A row may set a to-one relation's +FK by the relation's authored name (e.g. `Country: 34` on a City row). + +**Large data sets - reference a CSV file instead of inline rows.** Small configuration sets and +statuses belong inline (their values are part of the flows and UX); a countries/currencies-sized list +is just data and would bloat the intent. Point the seed at an authored CSV in a **subfolder** (root +`.csv` files are owned and scrubbed by regeneration); only the `.csvim` is generated. The CSV's header +carries the physical column names (`COUNTRY_ID,COUNTRY_NAME,...`): + +```yaml +seeds: + - name: countries + entity: Country + file: data/countries.csv # developer-owned; exactly one of file/rows +``` + +**Translations (`language:` on a seed).** For a `multilingual: true` entity, a seed with a short +language code carries per-language values - it lands in the entity's `
_LANG` table. Rows carry +the base row's `id` plus translatable (string/text) fields only: + +```yaml +seeds: + - name: uoms-bg + entity: UoM + language: bg + rows: + - { id: 1, name: "Килограм" } +``` +(A `language:` seed may also use `file:` - the authored CSV then carries the +`GUID,Id,,Language` header.) ### notifications - email on a data change diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGeneratorTest.java index 21653f19a9d..920d298c6ae 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGeneratorTest.java @@ -62,6 +62,42 @@ void seedRowsMaySetAToOneRelationForeignKey() { """, csv, "a row's relation-name key should become the FK column"); } + private static final String MULTILINGUAL_YAML = """ + name: uoms + languages: [en, bg] + entities: + - name: UoM + kind: setting + multilingual: true + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + - { name: iso, type: string, length: 10 } + - { name: numerator, type: decimal } + seeds: + - name: uoms-bg + entity: UoM + language: bg + rows: + - { id: 1, name: "Килограм" } + - { id: 2, name: "Литър", iso: "Л" } + """; + + @Test + void languageSeedRendersTheLangTableShape() { + IntentModel model = IntentParser.parse(MULTILINGUAL_YAML); + String csv = CsvimIntentGenerator.renderCsvForTest(model.getEntities() + .get(0), + model.getSeeds() + .get(0)); + assertEquals(""" + GUID,Id,Name,Iso,Language + 1,1,Килограм,,bg + 2,2,Литър,Л,bg + """, csv, + "a language seed should render GUID (auto-numbered) + Id + the referenced translatable columns + the Language code"); + } + @Test void relationFreeSeedsKeepTheirShape() { IntentModel model = IntentParser.parse(YAML); diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index 16fce179074..0075e3d9f1c 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java @@ -186,6 +186,36 @@ void dependsOnEmitsWidgetAttributesWithPrimaryKeyDefaults() { assertNull(countryFk.get("widgetDependsOnProperty")); } + @Test + void multilingualEntityAndLanguagesFlowIntoTheModel() { + String yaml = """ + name: uoms + languages: [en, bg] + entities: + - name: UoM + kind: setting + multilingual: true + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + """; + IntentModel parsed = IntentParser.parse(yaml); + Map model = EdmIntentGenerator.buildModelJsonForTest(parsed, "uoms"); + List> entities = entities(model); + + assertEquals("true", entityByName(entities, "UoM").get("multilingual"), + "a multilingual entity should carry the EDM multilingual attribute"); + assertNull(entityByName(entities, "Product").get("multilingual"), "a regular entity must not carry the attribute"); + + @SuppressWarnings("unchecked") + List languages = (List) ((Map) model.get("model")).get("languages"); + assertEquals(List.of("en", "bg"), languages, "the intent's languages should land on the .model root"); + } + private static Map buildFromResource(String resource, String intentName) { IntentModel parsed = IntentParser.parse(readResource(resource)); return EdmIntentGenerator.buildModelJsonForTest(parsed, intentName); diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java index 9ad8ef5dfa8..ed0b31af0a3 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java @@ -294,6 +294,122 @@ void dependsOnFieldRequiresValueFromAndForbidsFilterBy() { "expected a filterBy-on-field issue, got: " + ex.getIssues()); } + /** A multilingual UoM setting with a Bulgarian translation seed. */ + private static final String MULTILINGUAL_HEAD = """ + name: uoms + languages: [en, bg] + entities: + - name: UoM + kind: setting + multilingual: true + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + - { name: numerator, type: decimal } + seeds: + - name: uoms-bg + entity: UoM + language: bg + rows: + - { id: 1, name: "Килограм" } + """; + + @Test + void multilingualEntityAndLanguageSeedParse() { + IntentModel model = IntentParser.parse(MULTILINGUAL_HEAD); + assertTrue(model.getEntities() + .get(0) + .isMultilingual()); + assertEquals("bg", model.getSeeds() + .get(0) + .getLanguage()); + assertEquals(java.util.List.of("en", "bg"), model.getLanguages()); + } + + @Test + void languageSeedOnNonMultilingualEntityIsRejected() { + String yaml = MULTILINGUAL_HEAD.replace(" multilingual: true\n", ""); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("is not multilingual - add `multilingual: true`")), + "expected a not-multilingual issue, got: " + ex.getIssues()); + } + + @Test + void languageSeedWithNonTranslatableRowKeyIsRejected() { + String yaml = MULTILINGUAL_HEAD.replace("name: \"Килограм\"", "name: \"Килограм\", numerator: 5"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("[numerator] which is not the id or a translatable")), + "expected a non-translatable row-key issue, got: " + ex.getIssues()); + } + + @Test + void malformedLanguageCodesAreRejected() { + String yaml = MULTILINGUAL_HEAD.replace("languages: [en, bg]", "languages: [en, Bulgarian]") + .replace("language: bg", "language: BG"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("languages entry [Bulgarian]")), + "expected a languages-entry issue, got: " + ex.getIssues()); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("language [BG] must be a short lowercase language code")), + "expected a seed-language issue, got: " + ex.getIssues()); + } + + @Test + void fileSeedParsesAndRootLevelOrAbsolutePathsAreRejected() { + String head = """ + name: countries + entities: + - name: Country + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true } + seeds: + - name: countries + entity: Country + """; + // A subfolder path with no inline rows is the valid shape for a large authored data set. + IntentModel model = IntentParser.parse(head.stripTrailing() + "\n file: data/countries.csv\n"); + assertEquals("data/countries.csv", model.getSeeds() + .get(0) + .getFile()); + + // A root-level file would be scrubbed by the intent regeneration - rejected with guidance. + IntentValidationException ex = assertThrows(IntentValidationException.class, + () -> IntentParser.parse(head.stripTrailing() + "\n file: countries.csv\n")); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("must live in a subfolder")), + "expected a subfolder issue, got: " + ex.getIssues()); + + // Absolute / escaping paths are rejected. + ex = assertThrows(IntentValidationException.class, + () -> IntentParser.parse(head.stripTrailing() + "\n file: /countries/data.csv\n")); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("must be a project-relative path")), + "expected a relative-path issue, got: " + ex.getIssues()); + + // file and inline rows are mutually exclusive. + ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(head.stripTrailing() + """ + + file: data/countries.csv + rows: + - { id: 1, name: Afghanistan } + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("declares both `file` and inline `rows`")), + "expected a mutual-exclusion issue, got: " + ex.getIssues()); + } + @Test void dependsOnOnDocumentStatusRelationIsRejected() { String yaml = DEPENDS_ON_HEAD.stripTrailing() + """ diff --git a/components/engine/engine-java/CLAUDE.md b/components/engine/engine-java/CLAUDE.md index e4d7b118146..c9bb1ce8eaa 100644 --- a/components/engine/engine-java/CLAUDE.md +++ b/components/engine/engine-java/CLAUDE.md @@ -111,7 +111,7 @@ lazily). `EntityBeanMapper` does bean↔map; `JavaEntityToHbmMapper` reflects an (shares `HbmXmlDescriptor` with `data-store` — audit both if you change either). SessionFactory roots at the default user-data datasource, not SystemDB. -**Manage entities ONLY through their generated `Repository` — NEVER the generic `Store`/`Database` for entity CRUD.** The generated repository (`@Repository extends JavaRepository`) is the *only* sanctioned way to load/save/update/delete a managed entity, because it carries validations, **event publishing** (`Producer.sendToTopic` on the create/`-updated`/`-deleted` topics that intent triggers/reactions/rollups/notifications listen on), multi-language support, and other per-entity behaviour. The generic `org.eclipse.dirigible.sdk.db.Store` (name-keyed dynamic map) and raw `Database` SQL **bypass all of that silently** and MUST NOT be used to read or mutate a managed entity. (`updateWithoutEvent` is fine — it's a deliberate repository method that keeps `super.update`'s validations/i18n and only omits the event, for workflow-driven system writes: intent SetField/Writer/trigger delegates.) Consequence for a *reusable* delegate/service: it can't statically import a foreign `Entity`, so the code that touches a specific entity must live **in that entity's project** (where it imports that project's repository); keep only entity-agnostic helpers (e.g. a number generator over its own `NumberRepository`) in a shared project. Don't make code "general" by reaching into arbitrary entities through `Store`. +**Manage entities ONLY through their generated `Repository` — NEVER the generic `Store`/`Database` for entity CRUD.** The generated repository (`@Repository extends JavaRepository`) is the *only* sanctioned way to load/save/update/delete a managed entity, because it carries validations, **event publishing** (`Producer.sendToTopic` on the create/`-updated`/`-deleted` topics that intent triggers/reactions/rollups/notifications listen on), the multilingual read-overlay (a `multilingual: true` entity's finds translate string properties from its `
_LANG` table for the caller's `Accept-Language` via `org.eclipse.dirigible.sdk.db.Translator`), and other per-entity behaviour. The generic `org.eclipse.dirigible.sdk.db.Store` (name-keyed dynamic map) and raw `Database` SQL **bypass all of that silently** and MUST NOT be used to read or mutate a managed entity. (`updateWithoutEvent` is fine — it's a deliberate repository method that keeps `super.update`'s validations/i18n and only omits the event, for workflow-driven system writes: intent SetField/Writer/trigger delegates.) Consequence for a *reusable* delegate/service: it can't statically import a foreign `Entity`, so the code that touches a specific entity must live **in that entity's project** (where it imports that project's repository); keep only entity-agnostic helpers (e.g. a number generator over its own `NumberRepository`) in a shared project. Don't make code "general" by reaching into arbitrary entities through `Store`. ## Errors are surfaced to developers diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/settingsPage.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/settingsPage.js index aed15704a28..2132276cfca 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/settingsPage.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/settingsPage.js @@ -24,6 +24,25 @@ document.addEventListener('alpine:init', () => { content: '', // the selected entity's manage-list fragment HTML (rendered via x-html) loading: false, error: null, + // Region & Language: the app's single language flag, mirrored from the locale store so the + // picker's x-model has a plain component property; changes persist through the store (and take + // effect on the next data load - the fetch client sends the value as Accept-Language). + language: 'en', + + init() { + const locale = Alpine.store('locale'); + if (locale) { + this.language = locale.value; + this.$watch('language', (v) => locale.set(v)); + } + }, + + // The offered data-language codes and their display names (delegates to the locale store). + languageOptions() { + const locale = Alpine.store('locale'); + if (!locale) return []; + return locale.languages().map((code) => ({ value: code, text: locale.displayName(code) })); + }, async select(name, url) { if (this.selected === name) return; diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/api.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/api.js index ea37365fbfc..3f99a2d7d67 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/api.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/api.js @@ -73,16 +73,31 @@ App.services.api = { } }, + // The app's single language flag (the Region & Language setting, an Alpine store). Sent as + // Accept-Language on every call so the SAME flag drives the backend: generated multilingual + // repositories overlay
_LANG values for it. Absent store (standalone pages) -> no header + // override (the browser's own Accept-Language applies). + language() { + try { + const locale = window.Alpine && Alpine.store('locale'); + return (locale && locale.value) || null; + } catch (e) { return null; } + }, + // request(method, url, body, opts?) — opts selects the base URL for this call: // { baseUrl } explicit override, or { base } named entry in baseUrls. async request(method, url, body, opts = {}) { const baseUrl = this.resolveBaseUrl(opts); + const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; + const language = this.language(); + if (language) headers['Accept-Language'] = language; + let r; try { r = await fetch(`${baseUrl}${url}`, { method, - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + headers, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }); diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/locale.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/locale.js new file mode 100644 index 00000000000..0f4808d9a86 --- /dev/null +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/locale.js @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors + * SPDX-License-Identifier: EPL-2.0 + */ +/* + * locale store — the app's single language flag (the Region & Language setting). A global store + * (like the theme) persisted per user in localStorage. The shared fetch client (services/api.js) + * sends the value as Accept-Language on every call, so the same flag drives BOTH the frontend and + * the backend: generated multilingual repositories overlay
_LANG values for it, and the + * document Print flow prefers it. The available codes come from the generated App.config.languages + * (the intent's `languages:`); a value outside that list falls back to the first entry. + */ +document.addEventListener('alpine:init', () => { + const STORAGE_KEY = 'codbex.harmonia.language'; + + Alpine.store('locale', { + value: 'en', + + init() { + const configured = this.languages(); + let saved = null; + try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) { /* storage unavailable */ } + this.value = (saved && configured.includes(saved)) ? saved : (configured[0] || 'en'); + }, + + // The app's offered data-language codes (from config.js; defaults to just 'en'). + languages() { + const configured = (window.App && App.config && App.config.languages) || []; + return Array.isArray(configured) && configured.length ? configured : ['en']; + }, + + // Human-readable name for a code ('bg' -> 'Bulgarian'), falling back to the code itself. + displayName(code) { + try { + const name = new Intl.DisplayNames(['en'], { type: 'language' }).of(code); + return name || code; + } catch (e) { return code; } + }, + + set(lang) { + if (!lang || lang === this.value) return; + this.value = lang; + try { localStorage.setItem(STORAGE_KEY, lang); } catch (e) { /* storage unavailable */ } + }, + }); +}, { once: true }); diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html index d6038da6f64..4375c50971a 100644 --- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html +++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html @@ -283,6 +283,7 @@

+ diff --git a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template index dbd674dfc53..ad8ea07addc 100644 --- a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template +++ b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template @@ -46,6 +46,15 @@ import org.eclipse.dirigible.components.data.store.java.repository.JavaRepositor import org.eclipse.dirigible.sdk.component.Repository; import org.eclipse.dirigible.sdk.messaging.Producer; import org.eclipse.dirigible.sdk.utils.Json; +#if($multilingual == "true") +#if(!$documentMaster) +import org.eclipse.dirigible.components.data.store.java.repository.Criteria; +#end +import org.eclipse.dirigible.sdk.db.Translator; +import org.eclipse.dirigible.sdk.security.User; +import java.util.List; +import java.util.Map; +#end #if($haveCalculatedPropertyExpression) import org.eclipse.dirigible.sdk.utils.Calc; #end @@ -154,6 +163,45 @@ public class ${name}Repository extends JavaRepository<${name}Entity> { #end } } +#if($multilingual == "true") + + // ${name} is multilingual: every read overlays the translated property values from the + // ${tablePrefix}${dataName}_LANG table for the caller's language (the Accept-Language request + // header - the Harmonia shell sends the Settings language there). Outside a request scope, or + // with no translations for the language, the base values pass through unchanged. + + @Override + public ${name}Entity findById(Object id) { + return Translator.translateEntity(super.findById(id), id, User.getLanguage(), "${tablePrefix}${dataName}"); + } + + @Override + public java.util.Optional<${name}Entity> findOne(Object id) { + // The generated controller reads single records through findOne - translate that path too. + return super.findOne(id) + .map(entity -> Translator.translateEntity(entity, id, User.getLanguage(), "${tablePrefix}${dataName}")); + } + + @Override + public List<${name}Entity> findAll() { + return Translator.translateList(super.findAll(), User.getLanguage(), "${tablePrefix}${dataName}"); + } + + @Override + public List<${name}Entity> findAll(int limit, int offset) { + return Translator.translateList(super.findAll(limit, offset), User.getLanguage(), "${tablePrefix}${dataName}"); + } + + @Override + public List<${name}Entity> findAll(Criteria criteria) { + return Translator.translateList(super.findAll(criteria), User.getLanguage(), "${tablePrefix}${dataName}"); + } + + @Override + public List<${name}Entity> query(String hql, Map parameters) { + return Translator.translateList(super.query(hql, parameters), User.getLanguage(), "${tablePrefix}${dataName}"); + } +#end #if($documentMaster) /** diff --git a/components/template/template-application-schema/src/main/resources/META-INF/dirigible/template-application-schema/data/application.schema.template b/components/template/template-application-schema/src/main/resources/META-INF/dirigible/template-application-schema/data/application.schema.template index 3fc50b9d9d7..14501da87c2 100644 --- a/components/template/template-application-schema/src/main/resources/META-INF/dirigible/template-application-schema/data/application.schema.template +++ b/components/template/template-application-schema/src/main/resources/META-INF/dirigible/template-application-schema/data/application.schema.template @@ -11,6 +11,48 @@ { "schema": { "structures": [ +## A multilingual entity gets a sibling
_LANG translation table (the codbex convention): +## GUID pk, Id -> the base row, one column per translatable (string-typed, non-PK, non-FK, +## non-calculated, non-audit) property NAMED AFTER THE PROPERTY (that is what the runtime +## Translator matches on), and the Language code. Emitted before the base tables so each entry +## is always comma-terminated (at least the base table itself follows). +#foreach ($model in $models) +#if($model.multilingual == "true" && $model.type != "REPORT" && $model.type != "FILTER" && $model.type != "PROJECTION") + { + "name": "${tablePrefix}${model.dataName}_LANG", + "type": "TABLE", + "columns": [ + { + "type": "INTEGER", + "primaryKey": true, + "identity": true, + "name": "GUID" + }, + { + "type": "INTEGER", + "name": "Id" + }, +#foreach ($property in $model.properties) +#if(($property.dataType == "VARCHAR" || $property.dataType == "CHAR" || $property.dataType == "CLOB") && !$property.dataPrimaryKey && !$property.relationshipEntityName && $property.isCalculatedProperty != "true" && (!$property.auditType || $property.auditType == "NONE")) + { + "type": "${property.dataType}", +#if($property.dataLength) + "length": ${property.dataLength}, +#end + "nullable": true, + "name": "${property.name}" + }, +#end +#end + { + "type": "VARCHAR", + "length": 2, + "name": "Language" + } + ] + }, +#end +#end #foreach ($model in $models) #if($model.type != "REPORT" && $model.type != "FILTER" && $model.type != "PROJECTION") { diff --git a/components/template/template-application-ui-harmonia-java/README.md b/components/template/template-application-ui-harmonia-java/README.md index 537decb1444..fb00a5ee29c 100644 --- a/components/template/template-application-ui-harmonia-java/README.md +++ b/components/template/template-application-ui-harmonia-java/README.md @@ -26,7 +26,18 @@ the same for document headers plus a **metadata-driven** cascade in the line-ite dialog (`detail-register.js.template` emits `editColumns[].dependsOn`; filtered options live in a separate `draftOptions` store so the items table's label resolution keeps the full option set). The trigger's controller URL is precomputed as -`widgetDependsOnControllerUrl` by `service-generate`'s `parameterUtils.js`. Remaining items are refinements — see the checklist + the +`widgetDependsOnControllerUrl` by `service-generate`'s `parameterUtils.js`. +**Multi-language data** is wired through a single per-user flag: the Settings page's +**Region & Language** picker (rendered from the generated `config.js` `languages`, hidden +for a single language) writes the shared `locale` Alpine store +(`application-core/.../shell/js/stores/locale.js`, localStorage +`codbex.harmonia.language`); the shared fetch client sends the value as +`Accept-Language` on every call, which the generated multilingual Java repositories +translate by (`
_LANG` overlay), and the document Print flow prefers the same +language when a template for it exists. UI **labels** remain untranslated — the Harmonia +framework itself has no i18n API (verified against 1.24.2: only breakpoint + +colour-scheme helpers), so label i18n is a documented follow-up on top of the locale +store; the generated `translations/en-US/*.json` catalogs already exist for it. Remaining items are refinements — see the checklist + the repo-root `HARMONIA_RUNTIME_PLAN.md` "Implementation status" + "Follow-ups". ## Architecture (how it differs from the Angular module) diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template.js b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template.js index 97731d198e4..0759f9319e2 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template.js +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template.js @@ -22,6 +22,9 @@ export function generate(model, parameters) { // else the model-level icon (the intent's `icon`), else a neutral default. parameters.appIcon = (parameters.appIcon && String(parameters.appIcon).trim()) ? String(parameters.appIcon).trim() : ((model.icon && String(model.icon).trim()) ? String(model.icon).trim() : 'blocks'); + // Data-language codes the app offers (the intent's `languages:`, carried on the .model root). + // Rendered into config.js as a JSON array; the shell's Region & Language setting lists them. + parameters.appLanguages = JSON.stringify(Array.isArray(model.languages) && model.languages.length ? model.languages : ['en']); return generateUtils.generateFiles(model, parameters, templateSources); }; diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template index c3e71347a64..c9e384818ec 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-page.js.template @@ -176,7 +176,12 @@ document.addEventListener('alpine:init', () => { console.error('Print languages lookup failed', e); } this.printBusy = false; - if (languages.length > 1) { + // The Region & Language setting wins when a template exists for it - print directly in the + // user's configured language instead of asking. + const configured = (Alpine.store('locale') || {}).value; + if (configured && languages.some(l => l.code === configured)) { + await this.printDoc(configured); + } else if (languages.length > 1) { this.printLanguages = languages; this.printLangOpen = true; } else { diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template index 1c3cf5df504..93bf9f1066f 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template @@ -382,6 +382,7 @@ + diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/js/config.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/js/config.js.template index f3f4d3b5eca..e8506669b6b 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/js/config.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/js/config.js.template @@ -23,5 +23,10 @@ App.config = { // GET (list, $limit/$offset), GET /{id}, POST, PUT /{id}, DELETE /{id}. // NB: uses javaGenFolderName (the Java-sanitised gen folder, e.g. sales-order -> sales_order), // matching the package the DAO/REST templates emit — NOT the raw genFolderName. - restBase: '/services/java/${projectName}/gen/${javaGenFolderName}/api' + restBase: '/services/java/${projectName}/gen/${javaGenFolderName}/api', + + // Data-language codes this app offers (the intent's `languages:`). The shell's Region & Language + // setting lists them; the chosen code is sent as Accept-Language on every API call, which the + // generated multilingual repositories translate by. A single entry hides the picker. + languages: ${appLanguages} }; diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/settings.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/settings.html.template index f358c401586..7b72b68417c 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/settings.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/settings.html.template @@ -1,3 +1,4 @@ +#set($dollar = '$')
+ +
+ Region & Language +
+ +
+ +
+
+
Configuration & nomenclature for ${projectName}. #foreach($entity in $models) #if($entity.type == "SETTING") diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index e95260b1b84..86379613b00 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -54,10 +54,14 @@ class IntentEngineIT extends IntegrationTest { name: orders description: Order management with approval workflow version: 1 + # Data languages the app offers: the Harmonia Region & Language setting lists them and the + # multilingual entities translate by the chosen one. + languages: [en, bg] entities: - name: Country kind: setting + multilingual: true description: ISO 3166-1 country reference data fields: - { name: id, type: integer, primaryKey: true, generated: true } @@ -146,6 +150,18 @@ class IntentEngineIT extends IntegrationTest { rows: - { id: 1, name: Afghanistan, code2: AF } - { id: 2, name: Albania, code2: AL } + # Translations for the multilingual Country - land in ORDERS_COUNTRY_LANG. + - name: countries-bg + entity: Country + language: bg + rows: + - { id: 1, name: "Афганистан" } + - { id: 2, name: "Албания" } + # Large data sets stay OUT of the intent: an authored CSV in a subfolder, referenced + # by path - only the .csvim is generated. + - name: countries-extra + entity: Country + file: data/countries-extra.csv notifications: - name: orderUpdated @@ -975,6 +991,38 @@ void harmonia_form_page_generates_the_depends_on_runtime() { "the item dialog should carry the metadata-driven dependsOn machinery"); } + @Test + void multilingual_entity_generates_the_translation_stack() { + writeIntent(INTENT_YAML); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + generateFromModel("template-application-ui-harmonia-java/template/template.js", "orders.model"); + + // Schema: the multilingual Country gets its sibling language table with the codbex shape. + String schema = contentOf("gen/orders/schema/" + PROJECT + ".schema"); + assertTrue(schema.contains("ORDERS_COUNTRY_LANG"), "the schema should declare the
_LANG table"); + assertTrue(schema.contains("\"name\": \"Language\""), "the language table should carry the Language column"); + assertTrue(schema.contains("\"name\": \"GUID\""), "the language table should carry the GUID primary key"); + assertFalse(schema.contains("ORDERS_CUSTOMER_LANG"), "a non-multilingual entity must not get a language table"); + + // Java DAO: every read overlays the translations for the caller's Accept-Language. + String repository = contentOf("gen/orders/data/settings/CountryRepository.java"); + assertTrue(repository.contains("Translator.translateList(super.findAll(), User.getLanguage(), \"ORDERS_COUNTRY\")"), + "the multilingual repository should overlay translations on findAll"); + assertTrue(repository.contains("Translator.translateEntity(super.findById(id)"), + "the multilingual repository should overlay translations on findById"); + assertTrue(repository.contains("public java.util.Optional findOne(Object id)"), + "the multilingual repository must also override findOne - the generated controller reads single records through it"); + String customerRepository = contentOf("gen/orders/data/customer/CustomerRepository.java"); + assertFalse(customerRepository.contains("Translator."), "a non-multilingual repository must stay untouched"); + + // Shell config: the offered data languages feed the Region & Language setting. + String config = contentOf("gen/orders/js/config.js"); + assertTrue(config.contains("languages: [\"en\",\"bg\"]"), "config.js should carry the app's data languages"); + } + @Test void regeneration_scrubs_stale_model_files() { writeIntent(INTENT_YAML); @@ -1203,6 +1251,13 @@ private void assertEdmAndModel() { modelBody.contains("\"widgetDependsOnProperty\": \"Customer\"") && modelBody.contains("\"widgetDependsOnValueFrom\": \"CreditLimit\""), "the .model JSON twin should carry the widgetDependsOn* attributes"); + + // Multilingual: Country carries the EDM multilingual attribute (its translations live in + // ORDERS_COUNTRY_LANG) and the intent's data languages land on the .model root. + assertTrue(edmXml.contains("multilingual=\"true\""), "a multilingual entity should carry the EDM multilingual attribute"); + assertTrue(modelBody.contains("\"multilingual\": \"true\""), "the .model twin should carry the multilingual attribute"); + assertTrue(modelBody.contains("\"languages\"") && modelBody.contains("\"bg\""), + "the intent's languages should land on the .model root"); } private void assertGlue() { @@ -1443,6 +1498,22 @@ private void assertSeeds() { assertTrue(csvBody.startsWith("COUNTRY_ID,COUNTRY_NAME,COUNTRY_CODE2"), "csv header should carry the upper-snake column names in entity-field order"); assertTrue(csvBody.contains("1,Afghanistan,AF"), "csv should include the Afghanistan row with an integral id"); + + // The bg translation seed lands in the language table with the codbex _LANG shape. + String langCsvim = contentOf("countries-bg.csvim"); + assertTrue(langCsvim.contains("\"table\": \"ORDERS_COUNTRY_LANG\""), "a language seed should target the
_LANG table"); + String langCsv = contentOf("countries-bg.csv"); + assertTrue(langCsv.startsWith("GUID,Id,Name,Language"), + "the language csv should carry GUID + Id + the referenced PascalCase translatable columns + Language"); + assertTrue(langCsv.contains("1,1,Афганистан,bg"), "the language csv should carry the translation rows with auto-numbered GUIDs"); + + // A file seed (large authored data set) generates ONLY the .csvim, pointing at the + // developer-owned CSV in its subfolder; no CSV body is generated (and none is scrubbed). + String fileCsvim = contentOf("countries-extra.csvim"); + assertTrue(fileCsvim.contains("\"file\": \"/" + PROJECT + "/data/countries-extra.csv\""), + "a file seed's csvim should point at the authored CSV"); + assertTrue(fileCsvim.contains("\"table\": \"ORDERS_COUNTRY\""), "a file seed still targets the entity's table"); + assertFalse(resource("countries-extra.csv").exists(), "a file seed must not generate a CSV body"); } @AfterEach From c118979de4ac18a50f780336ae2da1dc7f0799fb Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 2 Jul 2026 20:29:09 +0300 Subject: [PATCH 2/3] chore: format engine-intent sources (formatter cache had masked violations) Co-Authored-By: Claude Fable 5 --- .../intent/generator/csvim/CsvimIntentGenerator.java | 3 ++- .../dirigible/components/intent/model/EntityIntent.java | 3 ++- .../eclipse/dirigible/components/intent/model/SeedIntent.java | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java index acc02144dc2..3629bc1402e 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java @@ -229,7 +229,8 @@ private static String formatCell(Object value) { /** * CSV body of a translation seed: {@code GUID,Id,, * Language} - the shape of the schema-generated {@code - *
+ * +
* _LANG} table. {@code GUID} is auto-numbered by row order, {@code Id} references the translated * base row, {@code Language} is the seed's constant code. Only translatable (string-typed, non-PK) * fields referenced by at least one row become columns. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java index 04ab38faabe..8d624a41a48 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java @@ -63,7 +63,8 @@ public class EntityIntent { /** * Marks the entity as multilingual: its translatable (string-typed) properties may carry * per-language values in a sibling {@code - *
+ * +
* _LANG} table (the codbex convention). Emitted as the EDM entity attribute * {@code multilingual="true"}; the schema template then generates the language table and the Java * DAO template overlays translated values on every read for the caller's {@code Accept-Language}. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java index 9838e878700..761e9d28bba 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java @@ -36,7 +36,8 @@ public class SeedIntent { * Optional language code (e.g. {@code bg}): the seed carries translations for a multilingual * entity instead of base rows. Rows are keyed by {@code id} (the translated base row) plus the * entity's translatable field names; the generator writes them into the entity's {@code - *
+ * +
* _LANG} table ({@code GUID} auto-numbered, {@code Language} constant). */ private String language; From 7376595ab98a624b24b002fa43258e3727cfda44 Mon Sep 17 00:00:00 2001 From: Nedelcho Delchev Date: Fri, 3 Jul 2026 09:44:47 +0300 Subject: [PATCH 3/3] feat(reports): cross-model dimensions + typed per-column filters (#6126) * feat(reports): cross-model dimensions + typed per-column filters (server-side) Two report-stack upgrades driven by the multi-model sample: - ReportIntentGenerator joins a CROSS-MODEL relation dimension (dimensions: [Customer] where Customer is model: customers) against the owning model's real table / primary-key column / label field, resolved via CrossModelSupport exactly like the EDM FK (leaf-first, loud failure). Previously the join used this model's intent-prefixed naming and pointed at a non-existent local table; relation.field and filter (buildWhere) paths are covered too. - The generated report stack gains typed per-column filters applied SERVER-side, so pagination, count and CSV export all reflect them: - dao template (reportFileEntity): 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" :reportFilter with typed named parameters; operators EQ/NE/GT/GTE/LT/LTE/LIKE; exportCsv(filter) honors the same conditions; the count subquery gains the PostgreSQL-required alias. - rest template: /search, /count and /export map an unknown column/operator (IllegalArgumentException) to 400; /export accepts the conditions body. - Harmonia report page: generation-time typed column metadata (reportColumns [{key, kind: date|number|boolean|text}] from the report columns) renders a filter panel - date ranges, number ranges, boolean select, text contains - with Apply/Clear, an active-count badge on the toolbar toggle, and a POST /search + /count switch when filters are on. Tests: IntentEngineIT.report_file_stack_generates_typed_column_filters. Verified live on the multi-model sample's four new invoice reports: cross-model joins return customer/product NAMES ("Acme Ltd", not FK ids); GTE/LTE ranges, LIKE contains, combined ranges, filtered count and export all correct over HTTP; unknown column -> 400. Co-Authored-By: Claude Fable 5 * fix(test): report UI files live under the RAW genFolderName (case-sensitive FS) The generation service derives genFolderName from the report file name in its ORIGINAL case ("OrdersByCustomer"), while the Java files use the sanitized javaGenFolderName ("ordersbycustomer") - two distinct folders on a case-sensitive filesystem. The IT asserted both under the sanitized name, which only passed on macOS where the two paths collapse into one folder. Co-Authored-By: Claude Fable 5 * feat(reports): month()/year() date-bucket dimensions `dimensions: ["month(date)"]` groups a date for aggregation as a sortable YYYYMM integer ((EXTRACT(YEAR) * 100 + EXTRACT(MONTH)) - e.g. 202607); `year(field)` emits the plain year. Enables monthly income/VAT reports: - name: MonthlyRevenue source: SalesInvoice dimensions: ["month(date)"] measures: ["count(*)", "sum(net)", "sum(vat)", "sum(total)"] The YYYYMM integer pairs naturally with the number-range column filters. Standard-SQL EXTRACT (H2/PostgreSQL; SQL Server lacks EXTRACT - documented limitation). Covered by the OrdersByMonth assertions in IntentEngineIT; verified live on the multi-model sample (June/July buckets with correct net/VAT/total sums, month >= 202607 range filter). Co-Authored-By: Claude Fable 5 * feat(reports): right-align numeric columns and format by pattern, from the .report model The .report model now carries per-column rendering metadata (single source for every report UI): numeric columns (INTEGER/BIGINT/DECIMAL) get `align: "right"`, decimals additionally the platform money pattern (`### ### ### ##0.00`). ReportIntentGenerator emits them; the Harmonia report-file template derives the same defaults from the column types for .report files that predate the metadata. Consumers: - report-file page: headers + cells right-align via the metadata; decimal cells format by the DecimalFormat-style pattern (grouped thousands, fixed decimals - the document totals formatter); pattern-less numeric columns (counts, year/month buckets) render as clean integers instead of the DB's 1.0 / 202607.0. - in-SPA EDM report table: same alignment + formatter, resolved at generation time from the property types / formatPattern. Covered by the align/pattern assertions in IntentEngineIT; verified live on MonthlyRevenue (five right-aligned columns, patterned sums, clean buckets). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- components/engine/engine-intent/CLAUDE.md | 1 + .../report/ReportIntentGenerator.java | 84 +++++++++++++-- .../main/resources/intent-assistant-guide.md | 4 + .../data/reportFileEntity.java.template | 89 +++++++++++++-- .../api/reportFileEntity.java.template | 22 +++- .../README.md | 5 +- .../template/template-report-file.js | 8 ++ .../report-file/index.html.template | 54 +++++++++- .../report-file/report.js.template | 102 +++++++++++++++++- .../perspective/report/table-page.js.template | 18 ++++ .../report/table-view.html.template | 8 +- .../integration/tests/api/IntentEngineIT.java | 68 +++++++++++- 12 files changed, 433 insertions(+), 30 deletions(-) diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index d6c651a78e2..97b93d41e60 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -425,6 +425,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` / - **Standard print templates (`PrintIntentGenerator`, @Order(800), PR #6119).** One `.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 ``s (non-PK/non-aggregate fields + to-one relations), `
` 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//Print/en/` create-if-absent), not by a code template. Data contract: `{{document.}}` + items rows with `{{}}` — 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" :reportFilter`, 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 `
_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). diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java index 5d0703417a8..0b43ed58cfc 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java @@ -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; @@ -90,6 +92,11 @@ public class ReportIntentGenerator implements IntentTargetGenerator { private static final Set 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() { @@ -139,6 +146,29 @@ private static Map 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)); @@ -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; } } @@ -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. @@ -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 joins, ColumnRef ref) { if (ref.join != null) { joins.putIfAbsent(ref.join.alias, ref.join); @@ -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 { @@ -427,6 +486,13 @@ private static Map 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; } diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 2aab25ccae4..2d5c27feef2 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -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 diff --git a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/reportFileEntity.java.template b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/reportFileEntity.java.template index be8b3a93afd..433c5b2de6e 100644 --- a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/reportFileEntity.java.template +++ b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/reportFileEntity.java.template @@ -12,15 +12,32 @@ import org.eclipse.dirigible.sdk.utils.Json; * Generated repository for the ${name} report. Executes the report query against the configured data * source and returns the rows as generic maps keyed by the result-set column aliases. * + * Besides the report's own declared parameters, a request may carry per-column {@code conditions} + * ({@code [{column, operator, value}]}) applied OVER the report's output: the query is wrapped as + * {@code SELECT * FROM (QUERY) AS "REPORT_DATA" WHERE "" :reportFilter}. Columns are + * validated against the report's column aliases and typed from the report metadata, operators + * against a fixed whitelist - so the filter surface is exactly the visible table. + * * Do not modify the content as it may be re-generated again. */ public class ${name}Repository { private static final String QUERY = ${queryJava}; + /** The report's output columns (result-set alias -> SQL type) - the filterable surface. */ + private static final Map FILTER_COLUMNS = Map.ofEntries( +#foreach($column in $columns) + Map.entry("${column.alias}", "${column.type}")#if($foreach.hasNext),#end +#end + ); + + private static final Map OPERATORS = + Map.of("EQ", "=", "NE", "<>", "GT", ">", "GTE", ">=", "LT", "<", "LTE", "<=", "LIKE", "LIKE"); + @SuppressWarnings("unchecked") public List> findAll(Integer limit, Integer offset, Map filter) { - StringBuilder sql = new StringBuilder(QUERY); + List> parameters = baseParameters(filter); + StringBuilder sql = new StringBuilder(filteredQuery(filter, parameters)); if (limit != null) { sql.append(" LIMIT ").append(limit.intValue()); } @@ -28,7 +45,7 @@ public class ${name}Repository { sql.append(" OFFSET ").append(offset.intValue()); } try { - String result = Database.queryNamed(sql.toString(), parameters(filter)); + String result = Database.queryNamed(sql.toString(), Json.stringify(parameters)); return (List>) Json.parse(result, List.class); } catch (Throwable t) { throw new RuntimeException("Failed to execute report ${name}", t); @@ -37,9 +54,10 @@ public class ${name}Repository { @SuppressWarnings("unchecked") public long count(Map filter) { - String sql = "SELECT COUNT(*) AS REPORT_COUNT FROM (" + QUERY + ")"; + List> parameters = baseParameters(filter); + String sql = "SELECT COUNT(*) AS REPORT_COUNT FROM (" + filteredQuery(filter, parameters) + ") AS \"REPORT_TOTAL\""; try { - String result = Database.queryNamed(sql, parameters(filter)); + String result = Database.queryNamed(sql, Json.stringify(parameters)); List> rows = (List>) Json.parse(result, List.class); if (rows.isEmpty()) { return 0L; @@ -54,16 +72,71 @@ public class ${name}Repository { } } - public List> exportCsv() { - return findAll(null, null, null); + public List> exportCsv(Map filter) { + return findAll(null, null, filter); + } + + /** + * The report query, wrapped with the request's per-column conditions when present. Each condition + * binds a named parameter typed from the column's report metadata. + */ + private static String filteredQuery(Map filter, List> parameters) { + List> conditions = conditionsOf(filter); + if (conditions.isEmpty()) { + return QUERY; + } + StringBuilder sql = new StringBuilder("SELECT * FROM (").append(QUERY).append(") AS \"REPORT_DATA\" WHERE "); + for (int i = 0; i < conditions.size(); i++) { + Map condition = conditions.get(i); + String column = stringValue(condition.get("column")); + String type = FILTER_COLUMNS.get(column); + if (type == null) { + throw new IllegalArgumentException("Unknown report filter column: " + column); + } + String operator = OPERATORS.get(stringValue(condition.get("operator"))); + if (operator == null) { + throw new IllegalArgumentException("Unknown report filter operator: " + condition.get("operator")); + } + if (i > 0) { + sql.append(" AND "); + } + String marker = "reportFilter" + i; + sql.append("\"").append(column).append("\" ").append(operator).append(" :").append(marker); + parameters.add(parameter(marker, parameterType(type), condition.get("value"))); + } + return sql.toString(); + } + + @SuppressWarnings("unchecked") + private static List> conditionsOf(Map filter) { + Object conditions = filter == null ? null : filter.get("conditions"); + if (conditions instanceof List list) { + List> result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map map) { + result.add((Map) map); + } + } + return result; + } + return List.of(); + } + + /** The named-parameter type for a report column type. */ + private static String parameterType(String columnType) { + return "CHARACTER VARYING".equalsIgnoreCase(columnType) ? "VARCHAR" : columnType; + } + + private static String stringValue(Object value) { + return value == null ? null : value.toString(); } - private static String parameters(Map filter) { + private static List> baseParameters(Map filter) { List> parameters = new ArrayList<>(); #foreach($parameter in $parameters) parameters.add(parameter("${parameter.name}", "${parameter.type}", value(filter, "${parameter.name}", "${parameter.initial}"))); #end - return Json.stringify(parameters); + return parameters; } private static Map parameter(String name, String type, Object value) { diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/reportFileEntity.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/reportFileEntity.java.template index 203f9adcdaa..76b2bf93c4f 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/reportFileEntity.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/reportFileEntity.java.template @@ -66,7 +66,11 @@ public class ${name}Controller { #if($needsRoles) checkPermissions("read"); #end - return Map.of("count", repository.count(filter)); + try { + return Map.of("count", repository.count(filter)); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e); + } } @Post("/search") @@ -75,16 +79,26 @@ public class ${name}Controller { #if($needsRoles) checkPermissions("read"); #end - return repository.findAll(intValue(filter, "$limit"), intValue(filter, "$offset"), filter); + try { + return repository.findAll(intValue(filter, "$limit"), intValue(filter, "$offset"), filter); + } catch (IllegalArgumentException e) { + // An unknown filter column/operator is a client error, not a server failure. + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e); + } } @Post("/export") @Documentation("Export ${name}") - public List> exportCsv() { + public List> exportCsv(@Body Map filter) { #if($needsRoles) checkPermissions("read"); #end - return repository.exportCsv(); + try { + // Exports honor the same per-column conditions the table shows. + return repository.exportCsv(filter); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e); + } } private static Integer intValue(Map filter, String key) { diff --git a/components/template/template-application-ui-harmonia-java/README.md b/components/template/template-application-ui-harmonia-java/README.md index fb00a5ee29c..e46a259a939 100644 --- a/components/template/template-application-ui-harmonia-java/README.md +++ b/components/template/template-application-ui-harmonia-java/README.md @@ -34,7 +34,10 @@ for a single language) writes the shared `locale` Alpine store `codbex.harmonia.language`); the shared fetch client sends the value as `Accept-Language` on every call, which the generated multilingual Java repositories translate by (`
_LANG` overlay), and the document Print flow prefers the same -language when a template for it exists. UI **labels** remain untranslated — the Harmonia +language when a template for it exists. The standalone **report page** offers **typed per-column filters** (date ranges, +number ranges, boolean, text contains) from generation-time column metadata, applied +**server-side** over the wrapped report query — pagination, count and CSV export all +reflect the active filters. UI **labels** remain untranslated — the Harmonia framework itself has no i18n API (verified against 1.24.2: only breakpoint + colour-scheme helpers), so label i18n is a documented follow-up on top of the locale store; the generated `translations/en-US/*.json` catalogs already exist for it. Remaining items are refinements — see the checklist + the diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template-report-file.js b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template-report-file.js index 88f1fb41094..dc9a82763be 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template-report-file.js +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/template-report-file.js @@ -24,6 +24,14 @@ export function generate(model, parameters) { if (e.typeTypescript === "Date") { model.hasDates = true; } + // Rendering defaults for .report files that predate the align/pattern column metadata: + // numeric columns right-align, decimals get the platform money pattern. + if (!e.align) { + e.align = e.typeTypescript === 'number' ? 'right' : 'left'; + } + if (!e.pattern && (String(e.type).toUpperCase() === 'DECIMAL' || String(e.type).toUpperCase() === 'DOUBLE')) { + e.pattern = '### ### ### ##0.00'; + } }); model?.parameters?.forEach(e => { const parsedDataType = parameterUtils.parseDataTypes(e.type); diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/index.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/index.html.template index 603e69372e6..3e57af8735d 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/index.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/index.html.template @@ -27,10 +27,60 @@
+ + +
+
+
+ +
+
+ + +
+
+
+
@@ -46,13 +96,13 @@
- + diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template index 5b40855de17..5a5a201ff8f 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template @@ -36,6 +36,16 @@ document.addEventListener('alpine:init', () => { displayName: '${name}', // humanised in init() — "MembersWithLoansDue" -> "Members With Loans Due" rows: [], columns: [], // derived from the data — the controller returns rows keyed by column alias + // The report's typed output columns (generation-time metadata) — the filterable surface. Each + // entry is { key: , kind: date|number|boolean|text }, driving the per-column + // filter controls (date/number ranges, boolean select, text contains). + reportColumns: [ +#foreach($column in $columns) + { key: '${column.alias}', kind: #if($column.typeTypescript == "Date")'date'#elseif($column.typeTypescript == "number")'number'#elseif($column.typeTypescript == "boolean")'boolean'#{else}'text'#end, align: '${column.align}'#if($column.pattern), pattern: '${column.pattern}'#end }, +#end + ], + showFilters: false, + filters: {}, // column key -> { from, to, min, max, contains, equals } (per kind) state: 'loading', // loading | error | empty | default error: null, page: 1, @@ -53,21 +63,68 @@ document.addEventListener('alpine:init', () => { this.preview = new URLSearchParams(location.search).get('preview') === '1'; } catch (e) { /* no location */ } if (this.preview) this.limit = 5; + for (const col of this.reportColumns) { + this.filters[col.key] = { from: '', to: '', min: null, max: null, contains: '', equals: '' }; + } try { document.title = this.displayName; } catch (e) { /* no document */ } this.load(); }, + // The active per-column conditions in the controller's shape: [{ column, operator, value }]. + // Ranges become GTE/LTE pairs, text becomes a LIKE with wildcards, boolean an EQ. + conditions() { + const conditions = []; + for (const col of this.reportColumns) { + const f = this.filters[col.key]; + if (!f) continue; + if (col.kind === 'date') { + if (f.from) conditions.push({ column: col.key, operator: 'GTE', value: f.from }); + if (f.to) conditions.push({ column: col.key, operator: 'LTE', value: f.to }); + } else if (col.kind === 'number') { + if (f.min !== null && f.min !== '') conditions.push({ column: col.key, operator: 'GTE', value: f.min }); + if (f.max !== null && f.max !== '') conditions.push({ column: col.key, operator: 'LTE', value: f.max }); + } else if (col.kind === 'boolean') { + if (f.equals !== '') conditions.push({ column: col.key, operator: 'EQ', value: f.equals === 'true' }); + } else if (f.contains) { + conditions.push({ column: col.key, operator: 'LIKE', value: '%' + f.contains + '%' }); + } + } + return conditions; + }, + + get activeFilterCount() { return this.conditions().length; }, + + applyFilters() { this.page = 1; this.load(); }, + + clearFilters() { + for (const col of this.reportColumns) { + this.filters[col.key] = { from: '', to: '', min: null, max: null, contains: '', equals: '' }; + } + this.page = 1; + this.load(); + }, + async load() { this.state = 'loading'; this.error = null; try { const offset = (this.page - 1) * this.limit; - const rows = await reportHttp.get(this.apiBase + '?${dollar}limit=' + this.limit + '&${dollar}offset=' + offset); + const conditions = this.conditions(); + let rows; + if (conditions.length) { + rows = await reportHttp.post(this.apiBase + '/search', { ${dollar}limit: this.limit, ${dollar}offset: offset, conditions }); + } else { + rows = await reportHttp.get(this.apiBase + '?${dollar}limit=' + this.limit + '&${dollar}offset=' + offset); + } this.rows = rows || []; - // Columns come from the row keys (the report's result-set aliases, e.g. "Member Name"). + // Columns come from the row keys (the report's result-set aliases, e.g. "Member Name"), + // falling back to the generation-time metadata when a filter empties the page. if (this.rows.length) this.columns = Object.keys(this.rows[0]); + else if (!this.columns.length && this.reportColumns.length) this.columns = this.reportColumns.map(c => c.key); try { - const c = await reportHttp.get(this.apiBase + '/count'); + const c = conditions.length + ? await reportHttp.post(this.apiBase + '/count', { conditions }) + : await reportHttp.get(this.apiBase + '/count'); this.count = (c && c.count != null) ? c.count : this.rows.length; } catch (e) { this.count = this.rows.length; } this.state = this.rows.length ? 'default' : 'empty'; @@ -78,6 +135,42 @@ document.addEventListener('alpine:init', () => { this.refreshIcons(); }, + columnMeta(key) { return this.reportColumns.find(c => c.key === key) || null; }, + + // Numeric columns right-align (from the .report column metadata). + alignClass(key) { + const meta = this.columnMeta(key); + return meta && meta.align === 'right' ? 'text-right' : ''; + }, + + // Cell text: decimals format by the column's DecimalFormat-style pattern (grouped thousands, + // fixed decimals - the platform money pattern); everything else renders as-is. + cellText(key, row) { + const v = row[key]; + const meta = this.columnMeta(key); + if (meta && meta.pattern) return this.displayNumber(v, meta.pattern); + // Pattern-less numeric columns (counts, year/month buckets) still render as clean integers - + // the DB returns them as decimals (1.0, 202607.0). + if (meta && meta.kind === 'number') return this.displayNumber(v, '##0'); + return v; + }, + + displayNumber(v, pattern) { + if (v === null || v === undefined || v === '') return ''; + const n = Number(v); + if (isNaN(n)) return v; + let decimals = 2; + let groupSep = ' '; + if (pattern) { + const dot = pattern.lastIndexOf('.'); + decimals = dot >= 0 ? (pattern.length - dot - 1) : 0; + groupSep = pattern.indexOf(' ') >= 0 ? ' ' : (pattern.indexOf(',') >= 0 ? ',' : ''); + } + const parts = n.toFixed(decimals).split('.'); + if (groupSep) parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, groupSep); + return parts.length > 1 ? parts[0] + '.' + parts[1] : parts[0]; + }, + get totalPages() { return Math.max(1, Math.ceil(this.count / this.limit)); }, next() { if (this.page < this.totalPages) { this.page++; this.load(); } }, prev() { if (this.page > 1) { this.page--; this.load(); } }, @@ -85,7 +178,8 @@ document.addEventListener('alpine:init', () => { // Export the FULL result set (POST /export returns all rows) as CSV. async exportCsv() { try { - const data = await reportHttp.post(this.apiBase + '/export', {}); + // The export honors the active per-column filters - it downloads what the table shows. + const data = await reportHttp.post(this.apiBase + '/export', { conditions: this.conditions() }); const rows = Array.isArray(data) ? data : []; if (!rows.length) return; const cols = Object.keys(rows[0]); diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-page.js.template index 15781d93486..5cb70250a5e 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-page.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-page.js.template @@ -29,6 +29,24 @@ document.addEventListener('alpine:init', () => { this.refreshIcons(); }, + // Format a float using a DecimalFormat-style pattern (decimals from the part after '.', grouping + // from the pattern's separator) - same formatter the document totals use. + displayNumber(v, pattern) { + if (v === null || v === undefined || v === '') return ''; + const n = Number(v); + if (isNaN(n)) return v; + let decimals = 2; + let groupSep = ' '; + if (pattern) { + const dot = pattern.lastIndexOf('.'); + decimals = dot >= 0 ? (pattern.length - dot - 1) : 0; + groupSep = pattern.indexOf(' ') >= 0 ? ' ' : (pattern.indexOf(',') >= 0 ? ',' : ''); + } + const parts = n.toFixed(decimals).split('.'); + if (groupSep) parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, groupSep); + return parts.length > 1 ? parts[0] + '.' + parts[1] : parts[0]; + }, + async exportCsv() { try { const data = await App.services.api.post(this.apiPath + '/export', {}); diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-view.html.template index 2749ad8b791..6bf727e2f38 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report/table-view.html.template @@ -21,7 +21,7 @@ #foreach($property in $properties) - + #end @@ -29,7 +29,13 @@ diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 86379613b00..d51f6722f8d 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -134,6 +134,11 @@ class IntentEngineIT extends IntegrationTest { source: Order dimensions: [customer] measures: ["count(*)", "sum(total)"] + # month(field) buckets a date dimension into a sortable YYYYMM integer. + - name: OrdersByMonth + source: Order + dimensions: ["month(orderDate)"] + measures: ["count(*)", "sum(total)"] - name: BigOrderItems source: OrderItem description: Order items with quantity over one, with their order date @@ -219,7 +224,7 @@ void parse_returns_the_full_model() { .body("processes", hasSize(1)) .body("processes[0].steps", hasSize(6)) .body("forms", hasSize(1)) - .body("reports", hasSize(2)) + .body("reports", hasSize(3)) .body("permissions", hasSize(2)) .body("seeds[0].rows", hasSize(2))); } @@ -1023,6 +1028,54 @@ void multilingual_entity_generates_the_translation_stack() { assertTrue(config.contains("languages: [\"en\",\"bg\"]"), "config.js should carry the app's data languages"); } + @Test + void report_file_stack_generates_typed_column_filters() { + writeIntent(INTENT_YAML); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + // Replay the Harmonia report-file template like the editor does. The generation service + // derives the gen folder from the report file name (each report owns gen/). + String payload = "{\"template\":\"template-application-ui-harmonia-java/template/template-report-file.js\",\"parameters\":{}}"; + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body(payload) + .when() + .post("/services/js/service-generate/generate.mjs/model/" + WORKSPACE + "/" + PROJECT + + "?path=OrdersByCustomer.report") + .then() + .statusCode(201)); + + // Backend: the report repository validates and applies per-column conditions over the wrapped + // query, typed from the report's own column metadata. + String repository = contentOf("gen/ordersbycustomer/data/reports/OrdersByCustomerRepository.java"); + assertTrue(repository.contains("FILTER_COLUMNS"), "the report repository should carry the filterable-column allowlist"); + assertTrue(repository.contains("SELECT * FROM (\").append(QUERY).append(\") AS \\\"REPORT_DATA\\\" WHERE"), + "conditions should wrap the report query"); + assertTrue(repository.contains("\"GTE\", \">=\""), "range operators should be whitelisted"); + String controller = contentOf("gen/ordersbycustomer/api/reports/OrdersByCustomerController.java"); + assertTrue(controller.contains("exportCsv(@Body Map filter)"), "export should honor the active filters"); + + // Frontend: the generated report page carries typed column metadata and the filter machinery. + // NB the case split: the UI files use the RAW genFolderName (the report file name, + // "OrdersByCustomer"), while the Java files use the sanitized javaGenFolderName + // ("ordersbycustomer") - two distinct folders on a case-sensitive filesystem. + String page = contentOf("gen/OrdersByCustomer/reports/OrdersByCustomer/report.js"); + assertTrue(page.contains("reportColumns"), "the report page should embed the typed column metadata"); + assertTrue(page.contains("{ key: 'Customer', kind: 'text', align: 'left' }"), + "the joined dimension should be a left-aligned text column"); + assertTrue(page.contains("kind: 'number'"), "the aggregate measures should be number columns"); + assertTrue(page.contains("operator: 'GTE'") && page.contains("operator: 'LIKE'"), + "the page should build range and contains conditions"); + String view = contentOf("gen/OrdersByCustomer/reports/OrdersByCustomer/index.html"); + assertTrue(view.contains("applyFilters()") && view.contains("data-lucide=\"filter\""), + "the report view should carry the filter panel and toolbar toggle"); + assertTrue(view.contains("alignClass(col)") && view.contains("cellText(col, row)"), + "the report table should align and format cells from the column metadata"); + assertTrue(page.contains("align: 'right', pattern: '### ### ### ##0.00'"), + "the page metadata should carry alignment + the money pattern for decimal columns"); + } + @Test void regeneration_scrubs_stale_model_files() { writeIntent(INTENT_YAML); @@ -1453,6 +1506,19 @@ private void assertReport() { assertTrue(body.contains("\"table\": \"ORDERS_ORDER\""), "report table should be the same intent-prefixed table name the EDM declares as dataName"); assertTrue(body.contains("\"aggregate\": \"COUNT\""), "count(*) should be parsed into an aggregate COUNT column"); + + // month(field) buckets the date dimension into a sortable YYYYMM integer, grouped the same way. + String monthly = contentOf("OrdersByMonth.report"); + assertTrue( + monthly.contains( + "(EXTRACT(YEAR FROM Order.\\\"ORDER_ORDER_DATE\\\") * 100 + EXTRACT(MONTH FROM Order.\\\"ORDER_ORDER_DATE\\\"))"), + "a month(field) dimension should emit the YYYYMM EXTRACT expression"); + assertTrue(monthly.contains("as \\\"Month Order Date\\\""), "the bucketed column should carry a humanized alias"); + assertTrue(monthly.contains("GROUP BY (EXTRACT(YEAR"), "the aggregation should group by the bucket expression"); + + // Rendering metadata on the model: numeric columns right-align, decimals carry the money pattern. + assertTrue(body.contains("\"align\": \"right\""), "numeric report columns should carry align: right"); + assertTrue(body.contains("\"pattern\": \"### ### ### ##0.00\""), "decimal report columns should carry the money pattern"); assertTrue(body.contains("\"aggregate\": \"SUM\""), "sum(total) should be parsed into an aggregate SUM column"); // The query is materialised SQL (not left empty): SELECT ... FROM
#if($property.widgetLabel)${property.widgetLabel}#else${property.name}#end#if($property.widgetLabel)${property.widgetLabel}#else${property.name}#end
as ... GROUP BY. // Physical table/column identifiers are double-quoted so the SQL runs on PostgreSQL (which folds