diff --git a/CLAUDE.md b/CLAUDE.md index 4f61295d94..dde3cb0a54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,7 +187,7 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **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); the supported language set is a PLATFORM concern (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en`, tenant-overridable via the tenant configuration) — the Harmonia **Region & Language** Settings entry always offers that set (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), while the top-level `languages: [en, bg]` only declares which languages the module PROVIDES translations for; the application shell warns about modules missing a platform language, and untranslated content falls back to the 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). -**First-class document numbering (`number:` + the `.numbers` artefact, `engine-numbering`):** a string field may declare `number: { series: Sales Invoice, per: Company, stampOn: create|issue }` — the intent references a **series by name only**; the number's shape (literal prefix + sequence zero-padded to a total width, no token grammar) lives OUTSIDE the model: declared per module in an authored **`.numbers`** artefact (`{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`, a requirement declaration like `.roles`) and configured per tenant in the application shell's Document Numbering settings (`/services/core/numbering`). One per-tenant table `DIRIGIBLE_DOCUMENT_NUMBERS` holds shape AND counter, one row per (series, partition); the synchronizer only INSERTs missing rows, Settings writes prefix/size/next, the allocator (`sdk.numbering.DocumentNumbers.next`) increments the counter — sequences are continuous, never auto-reset, and allocating an undeclared series fails loudly. `per:` partitions a series by a to-one relation's value (per company — two legal entities never share a counter). A differing cross-module re-declaration fails that artefact naming both modules; the removed `format`/`scope`/`resetOn` keys are rejected at parse. Details in the engine-intent guide's numbering bullet. +**First-class document numbering (`number:` + the `.numbers` artefact, `engine-numbering`):** a string field may declare `number: { series: Sales Invoice, per: Company, stampOn: create|issue }` — the intent references a **series by name only**; the number's shape (literal prefix + sequence zero-padded to a total width, no token grammar) lives OUTSIDE the model: declared per module in an authored **`.numbers`** artefact (`{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`, a requirement declaration like `.roles`) and configured per tenant in the application shell's Document Numbering settings (`/services/core/numbering`). One per-tenant table `DIRIGIBLE_DOCUMENT_NUMBERS` holds shape AND counter, one row per (series, partition); the synchronizer only INSERTs missing rows, Settings writes prefix/size/next, the allocator (`sdk.numbering.DocumentNumbers.next`) increments the counter — sequences are continuous, never auto-reset, and allocating an undeclared series fails loudly. `per:` partitions a series by a to-one relation's value (per company — two legal entities never share a counter); the declaration may name the **partition source** (`"partitions": {"table", "key", "label"}` — authored physical coordinates) so Settings labels partition rows by the entity's display name and lists a VIRTUAL row per value before its first allocation (saving provisions it — seed a company's starting number before its first document). A differing cross-module re-declaration fails that artefact naming both modules; the removed `format`/`scope`/`resetOn` keys are rejected at parse. Details in the engine-intent guide's numbering bullet. **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/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 8ae427d132..663b7214ba 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -293,7 +293,7 @@ Semantics worth knowing: - **`composition: true` on a to-one relation makes it a composition.** The owning entity becomes DEPENDENT (managed as details under its parent's perspective) and the FK is NOT NULL. `required: true` *alone* only makes the FK NOT NULL - the entity stays a top-level PRIMARY association (plain dropdown, its own perspective). Composition is **opt-in**, matching the Dirigible convention (where it is an explicit `relationshipType="COMPOSITION"` and most required FKs are plain associations); `composition` already implies NOT NULL, so `required` need not also be set. Only a `manyToOne`/`oneToOne` can be a composition; an entity's *first* `composition` to-one is its composition parent. Declare the inverse `oneToMany` on the master (`Member` with `loans: oneToMany to Loan` + `Loan.member` `composition: true`) so `Loan` is managed as a detail of `Member`; the `oneToMany` itself is navigation-only (the EDM generator ignores `oneToMany`/`manyToMany` since the FK lives on the child). (This replaced the earlier "first required to-one is automatically a composition" heuristic, which made entities like a `Loan` with a required `member` FK silently nest under `Member` instead of staying top-level.) **Every to-one FK property** (composition or association) carries `relationshipType` / `relationshipCardinality` (`1_n` / `n_1` / `1_1`) / `relationshipName` (`_`) / `relationshipEntityName` / `relationshipEntityPerspectiveName` - the last two drive the generated dropdown's data URL, so they are not optional. - **`kind: setting` on an entity marks it as nomenclature / configuration.** `EntityIntent.kind` (default null = a regular managed entity); `kind: setting` makes `EdmIntentGenerator` emit the entity with `type="SETTING"` (and `entityType="SETTING"` in the mxGraph cell) instead of PRIMARY. The template engine keys on `entity.type === "SETTING"` (`service-generate/template/generateUtils.js`) to route it under the dashboard's global **Settings** perspective (it nulls the layout and sets `perspectiveName = "Settings"`), so a setting entity does NOT get its own generated perspective. Crucially the EDM generator also resolves any relation **targeting** a setting entity to the `Settings` perspective (`perspectiveFor(...)`), so an FK dropdown to a setting points at `api/Settings/` rather than a missing per-entity perspective. Settings are still real entities (own table, CSVIM seeds, FK columns) - only their UI placement differs. -- **First-class document numbering (`number:` on a string field) — the intent references a SERIES, the shape lives outside the model.** `number: { series: Sales Invoice, per: Company, stampOn: issue }` on a non-key string field gives it a platform-allocated, gap-free document number. A number series is a **tenant-level business object**: the intent (and the generated code) reference it only by name; its shape — a literal prefix + the sequence zero-padded to a total width, no token grammar — is declared once per module in a **`.numbers` artefact** at the project root (`{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`, AUTHORED like `.roles`, never generated) and configured per tenant in the application shell's **Document Numbering** settings (`/services/core/numbering`). The `.numbers` synchronizer (`engine-numbering`, `NumberSeriesSynchronizer`, multitenant, `SynchronizersOrder.NUMBER_SERIES` = before anything allocating) INSERTs a missing series row per tenant and never updates one — the counter is live and the shape may be tenant-configured; an identical cross-module re-declaration is a skip (a shared legal range), a DIFFERING one fails that artefact loudly naming both locations; artefact DELETE never touches the series row. Sequences are CONTINUOUS and never auto-reset (BG law; an annual restart is an admin setting prefix + next in January). `per:` names a to-one relation (never an EntityStatus) whose value PARTITIONS the series — one row per (series, partition) in the per-tenant `DIRIGIBLE_DOCUMENT_NUMBERS` table, each partition its own sequence/prefix/width, materialized on first allocation from the series' base row (two legal entities in one tenant must not share a counter; identical numbers across partitions are correct). `stampOn: create` = the generated DAO allocates at insert via `sdk.numbering.DocumentNumbers.next(series[, partition])`; `stampOn: issue` = the field is created with a UUID placeholder (the `generatedUuid` auto-fill) and the generated `gen/events//NumberStamp.java` delegate replaces it at the issue step, idempotently. Allocating an UNDECLARED series fails loudly — never invent a shape. The REMOVED keys `format`/`scope`/`resetOn` are rejected on the raw YAML tree (`IntentParser.rejectRemovedNumberKeys`) because the typed Gson mapping would silently drop them — an intent still carrying `format:` must fail, not quietly lose its shape. `NumberingSupport` builds the `numbering` glue collection; `NumberingSdkIT` covers the SDK + synchronizer end-to-end. +- **First-class document numbering (`number:` on a string field) — the intent references a SERIES, the shape lives outside the model.** `number: { series: Sales Invoice, per: Company, stampOn: issue }` on a non-key string field gives it a platform-allocated, gap-free document number. A number series is a **tenant-level business object**: the intent (and the generated code) reference it only by name; its shape — a literal prefix + the sequence zero-padded to a total width, no token grammar — is declared once per module in a **`.numbers` artefact** at the project root (`{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`, AUTHORED like `.roles`, never generated) and configured per tenant in the application shell's **Document Numbering** settings (`/services/core/numbering`). The `.numbers` synchronizer (`engine-numbering`, `NumberSeriesSynchronizer`, multitenant, `SynchronizersOrder.NUMBER_SERIES` = before anything allocating) INSERTs a missing series row per tenant and never updates one — the counter is live and the shape may be tenant-configured; an identical cross-module re-declaration is a skip (a shared legal range), a DIFFERING one fails that artefact loudly naming both locations; artefact DELETE never touches the series row. Sequences are CONTINUOUS and never auto-reset (BG law; an annual restart is an admin setting prefix + next in January). `per:` names a to-one relation (never an EntityStatus) whose value PARTITIONS the series — one row per (series, partition) in the per-tenant `DIRIGIBLE_DOCUMENT_NUMBERS` table, each partition its own sequence/prefix/width, materialized on first allocation from the series' base row (two legal entities in one tenant must not share a counter; identical numbers across partitions are correct). A partitioned series' declaration may additionally name its **partition source** — `"partitions": {"table": "CRM_COMPANY", "key": "COMPANY_ID", "label": "COMPANY_NAME"}` (authored physical coordinates, the `.csvim` precedent; identifiers parse-validated to plain SQL names) — which lets the Document Numbering settings label a partition row by the entity's display name ("Sales Invoice — ACME Ltd.") and list a VIRTUAL row for every partition value BEFORE its first allocation, so an operator seeds a company's starting number before its first document (saving a virtual row provisions it exactly as the first allocation would have). `stampOn: create` = the generated DAO allocates at insert via `sdk.numbering.DocumentNumbers.next(series[, partition])`; `stampOn: issue` = the field is created with a UUID placeholder (the `generatedUuid` auto-fill) and the generated `gen/events//NumberStamp.java` delegate replaces it at the issue step, idempotently. Allocating an UNDECLARED series fails loudly — never invent a shape. The REMOVED keys `format`/`scope`/`resetOn` are rejected on the raw YAML tree (`IntentParser.rejectRemovedNumberKeys`) because the typed Gson mapping would silently drop them — an intent still carrying `format:` must fail, not quietly lose its shape. `NumberingSupport` builds the `numbering` glue collection; `NumberingSdkIT` covers the SDK + synchronizer end-to-end. - **Calculated-field actions + entity `imports:` — server-side call-out for logic too custom to model.** Besides the neutral arithmetic `calculatedOnCreate`/`calculatedOnUpdate` expression (run by the SDK `Calc` evaluator, previewed live in the UI), a field may declare `calculatedActionOnCreate`/`calculatedActionOnUpdate` naming a Java class — a `@Component implements org.eclipse.dirigible.sdk.db.CalculatedField` (`T calculate(E entity)`). `EdmIntentGenerator.propertyMap` emits these as `calculatedActionOnCreate`/`OnUpdate` on the property (and `isCalculated()` now counts an action, so the property is marked calculated even with no expression); the **Java DAO template** (`template-application-dao-java/data/Repository.java.template`) gives the action **precedence** over the expression per slot and emits `entity. = Beans.get(.class).calculate(entity);`, importing `Beans` only when an action is present and `Calc` only when an expression is. An action runs **server-side only** (no client mirror). To reference the action by simple name, the entity declares `imports:` (a multi-line string of Java `import ...;` lines); `EdmIntentGenerator` Base64-encodes it into the `.model` entity's `importsCode` (matching the EDM editor's serialization), which the DAO template's `parameterUtils` decodes and emits into the repository's import block. The implementation is **hand-written under the project's `custom/` folder** (never `gen/`) — the intent layer emits no Java. The model-editor equivalents are the entity **Imports** tab and the property **Calculation** tab's *Action class* inputs (`editor-entity`). Worked example: `dirigiblelabs/sample-intent-multi-model` `sales-invoices` — `SalesInvoice.number` calls `custom/sales_invoices/SalesInvoiceNumberAction.java` (replacing the old inline `UUID.randomUUID()` expression). The SDK interface ships in `api-modules-java` (`org.eclipse.dirigible.sdk.db.CalculatedField`). - **Decision steps**: `if` + `then` are mandatory; `else` is optional and receives the gateway-default flow (so the conditioned branch can actually be skipped - without `else` the default falls through to the next step in the chain). `then`/`else` must name a declared step or the literal `end`; the parser validates this so a typo fails at parse time instead of producing BPMN Flowable rejects. - **`setField` service task + `next` step routing (declarative field-set glue).** A `serviceTask` with `args: { setField: , value: }` sets a `string`/`text` field of the process's **trigger entity** to a literal value, generated as a `gen/events//.java` `JavaDelegate` (`SetFieldSupport` → the `setters` glue collection → `SetField.java.template`) instead of scaffolding a hand-written `custom.` stub - it persists the set column via the targeted single-column `updateProperty(id, "", value)` (a workflow write, not a user edit, so it must not re-fire `onUpdate` reactions; only the set column is in the UPDATE statement, so a concurrent write to any other column cannot be reverted). The canonical use is an approve/reject outcome: the form completes the task with the chosen `action` as a process variable, a `decision` branches on `action == 'approve'`, and the two branches are `setField` tasks (`status=ACTIVE` / `status=REJECTED`). **`args: { next: }`** on any step overrides its linear successor - needed because the BPMN generator builds a **linear** chain, so without it the first branch (`activate`) would fall through into the second (`reject`); `next: done` makes the branches converge. The `then`/`else` fall-through is deliberately NOT auto-converted to a diamond (LoanApproval's `curatorReview` relies on falling through to `notifyMember`), so convergence is explicit via `next`. Scope: literal string values only (the parser validates `setField` is a string/text field of the trigger entity and that `value` is present; `next` must name a declared step or `end`). Non-string fields and expression values are future work. 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 bdfbeae928..38542fd5bd 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 @@ -325,7 +325,11 @@ gives the field a platform-allocated, gap-free document number. The intent decla business object**: its shape (a literal prefix + the sequence zero-padded to a total width, e.g. `SI00000042`) is declared once per module in a **`.numbers` artefact** at the project root (authored by hand, not generated - like `.roles`): - `{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`. The declaration only + `{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}` - a partitioned series + (`per:`) may add `"partitions": {"table": "
", "key": "", "label": ""}` + naming the physical table its partition values come from, so the tenant's Document Numbering + settings can label each partition row ("Sales Invoice - ACME Ltd.") and seed a partition's + starting number before its first document. The declaration only provisions a tenant that has no such series yet; each tenant then configures prefix, width and the next value in the application shell's **Document Numbering** settings. Sequences are continuous and never auto-reset - a jurisdiction that restarts numbering each January does it by setting the diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberEndpoint.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberEndpoint.java index 80f0a87524..0951ee2705 100644 --- a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberEndpoint.java +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberEndpoint.java @@ -10,7 +10,11 @@ package org.eclipse.dirigible.components.engine.numbering; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -43,9 +47,11 @@ public class DocumentNumberEndpoint extends BaseEndpoint { private final DocumentNumberService service; + private final NumberSeriesDeclarationService declarationService; - DocumentNumberEndpoint(DocumentNumberService service) { + DocumentNumberEndpoint(DocumentNumberService service, NumberSeriesDeclarationService declarationService) { this.service = service; + this.declarationService = declarationService; } /** The current tenant's series. */ @@ -53,20 +59,73 @@ public class DocumentNumberEndpoint extends BaseEndpoint { public ResponseEntity> list() { try { List rows = service.list(); - // A series is PARTITIONED once any of its partition rows exists. The base ("") row of a - // partitioned series is only the shape template partitions inherit at birth - allocation - // never draws from it - so the settings page must not offer its counter for editing. + // The declared PARTITION SOURCES: which physical table a series' partition values come + // from, keyed by series. What lets a partition row be labeled by the entity's display + // name and lets every value appear BEFORE its first allocation (a virtual row), so an + // operator can seed a company's starting number before its first document. + Map partitionSources = new LinkedHashMap<>(); + for (NumberSeriesDeclaration declaration : declarationService.getAll()) { + if (declaration.getPartitionTable() != null && !declaration.getPartitionTable() + .isBlank()) { + partitionSources.putIfAbsent(declaration.getName(), declaration); + } + } + Map> labelsBySeries = new LinkedHashMap<>(); + for (Map.Entry source : partitionSources.entrySet()) { + Map labels = new LinkedHashMap<>(); + for (DocumentNumberStore.PartitionValue value : service.partitionSource(source.getValue() + .getPartitionTable(), + source.getValue() + .getPartitionKey(), + source.getValue() + .getPartitionLabel())) { + labels.put(value.value(), value.label()); + } + labelsBySeries.put(source.getKey(), labels); + } + + // A series is PARTITIONED once any of its partition rows exists OR its declaration names + // a partition source. The base ("") row of a partitioned series is only the shape template + // partitions inherit at birth - allocation never draws from it - so the settings page must + // not offer its counter for editing. Set partitionedSeries = rows.stream() .filter(row -> !row.partition() .isEmpty()) .map(DocumentNumberStore.Series::series) - .collect(Collectors.toSet()); - return ResponseEntity.ok(rows.stream() - .map(row -> new SeriesView(row.series(), row.partition(), row.prefix(), row.size(), row.counter(), - row.counter() + 1, - DocumentNumberService.render(row.prefix(), row.size(), row.counter() + 1), - partitionedSeries.contains(row.series()))) - .toList()); + .collect(Collectors.toCollection(HashSet::new)); + partitionedSeries.addAll(labelsBySeries.keySet()); + + List views = new ArrayList<>(); + Map baseRows = new LinkedHashMap<>(); + Set materialized = new HashSet<>(); + for (DocumentNumberStore.Series row : rows) { + if (row.partition() + .isEmpty()) { + baseRows.put(row.series(), row); + } + materialized.add(row.series() + "|" + row.partition()); + Map labels = labelsBySeries.getOrDefault(row.series(), Map.of()); + views.add(new SeriesView(row.series(), row.partition(), labels.get(row.partition()), row.prefix(), row.size(), + row.counter(), row.counter() + 1, DocumentNumberService.render(row.prefix(), row.size(), row.counter() + 1), + partitionedSeries.contains(row.series()), false)); + } + // VIRTUAL rows: every declared partition value with no materialized row yet, rendered + // with the base row's shape and a fresh sequence - saving one provisions it. + for (Map.Entry> entry : labelsBySeries.entrySet()) { + DocumentNumberStore.Series base = baseRows.get(entry.getKey()); + if (base == null) { + continue; // the series is declared but not provisioned for this tenant yet + } + for (Map.Entry value : entry.getValue() + .entrySet()) { + if (materialized.contains(entry.getKey() + "|" + value.getKey())) { + continue; + } + views.add(new SeriesView(entry.getKey(), value.getKey(), value.getValue(), base.prefix(), base.size(), 0, 1, + DocumentNumberService.render(base.prefix(), base.size(), 1), true, true)); + } + } + return ResponseEntity.ok(views); } catch (SQLException ex) { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to list document-number series", ex); } @@ -138,10 +197,16 @@ record SetShapeRequest(String series, String partition, String prefix, int size) * @param next the value the next document will get * @param example the next number as it will actually render - so an administrator sees the effect * of a prefix or width change without issuing a document - * @param partitioned whether the series has partition rows - the base row of a partitioned series - * is only the shape template new partitions inherit, so its counter is not editable + * @param partitionLabel the partition's display label from the declared partition source, or + * {@code null} when the series declares none / the value is not in the source + * @param partitioned whether the series has partition rows or declares a partition source - the + * base row of a partitioned series is only the shape template new partitions inherit, so its + * counter is not editable + * @param virtual whether the row is not materialized yet - a declared partition value that has + * never allocated; saving it provisions the row (an operator seeds a partition's starting + * number BEFORE its first document) */ - record SeriesView(String series, String partition, String prefix, int size, long counter, long next, String example, - boolean partitioned) { + record SeriesView(String series, String partition, String partitionLabel, String prefix, int size, long counter, long next, + String example, boolean partitioned, boolean virtual) { } } diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberService.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberService.java index 8cea4fb6bd..f71601afc4 100644 --- a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberService.java +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberService.java @@ -120,7 +120,11 @@ public void provision(String series, String prefix, int size) throws SQLExceptio * @throws SQLException if the write fails */ public void setNext(String series, String partition, long next) throws SQLException { - store.setCounter(series, partition == null ? "" : partition, Math.max(0, next - 1)); + String partitionValue = partition == null ? "" : partition; + // A partition row an operator seeds BEFORE its first allocation does not exist yet - it is + // born here exactly as the first allocation would have born it (base shape, then the edit). + store.ensurePartition(series, partitionValue); + store.setCounter(series, partitionValue, Math.max(0, next - 1)); } /** @@ -136,7 +140,24 @@ public void setNext(String series, String partition, long next) throws SQLExcept public void setShape(String series, String partition, String prefix, int size) throws SQLException { String safePrefix = prefix == null ? "" : prefix; validateShape(safePrefix, size); - store.setShape(series, partition == null ? "" : partition, safePrefix, size); + String partitionValue = partition == null ? "" : partition; + store.ensurePartition(series, partitionValue); + store.setShape(series, partitionValue, safePrefix, size); + } + + /** + * Every value of a declared partition source with its display label, for the management surface: + * what lets the settings page label a partition row by the entity's name and list a (virtual) row + * for every value that has not allocated yet. + * + * @param table the partition-source table (a validated plain SQL identifier) + * @param key the column holding the partition value + * @param label the column holding the display label + * @return the values in label order (empty when the table does not exist for this tenant yet) + * @throws SQLException if the read fails + */ + public List partitionSource(String table, String key, String label) throws SQLException { + return store.readPartitionSource(table, key, label); } /** diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberStore.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberStore.java index 0695c5ca0e..d242605223 100644 --- a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberStore.java +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberStore.java @@ -263,6 +263,80 @@ private void insertRow(Connection connection, String series, String partition, S } } + /** + * One partition value with its display label, read from a declared partition source. + * + * @param value the partition value (the key column, as a string - partition keys are strings) + * @param label the display label + */ + record PartitionValue(String value, String label) { + } + + /** + * Every row of a declared partition source: the partition values a series CAN be partitioned by, + * with their display labels - what lets the management surface label the rows it has and list the + * values that have not allocated yet. The identifiers were validated to plain SQL identifiers at + * artefact parse and are quoted verbatim here; the table lives in the tenant-routed default + * datasource like everything else the numbering engine touches. + * + * @param table the partition-source table + * @param key the column holding the partition value + * @param label the column holding the display label + * @return the values in label order (possibly empty; empty also when the table does not exist yet - + * the owning module may not be deployed to this tenant) + * @throws SQLException if the read fails + */ + List readPartitionSource(String table, String key, String label) throws SQLException { + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection()) { + if (!SqlFactory.getNative(connection) + .existsTable(connection, table)) { + return List.of(); + } + String sql = SqlFactory.getNative(connection) + .select() + .column(key) + .column(label) + .from(table) + .order(label) + .build(); + List values = new ArrayList<>(); + try (PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String value = resultSet.getString(1); + if (value != null && !value.isBlank()) { + values.add(new PartitionValue(value, resultSet.getString(2))); + } + } + } + return values; + } + } + + /** + * Materializes the partition's row NOW when it does not exist yet (inheriting the base row's shape, + * zero counter) - the management surface's write path for a VIRTUAL row: an operator seeds a + * partition's counter BEFORE its first allocation, so the row must be born on save, exactly as the + * first allocation would have born it. + * + * @param series the series identity + * @param partition the partition value + * @throws SQLException if the write fails + * @throws IllegalStateException if the series has no base row - it was never declared + */ + void ensurePartition(String series, String partition) throws SQLException { + if (partition.isEmpty()) { + return; + } + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection()) { + ensureTableExists(connection); + if (!exists(connection, series, partition)) { + materializePartition(connection, series, partition); + } + } + } + /** * Set the last-allocated value (the management surface's "next" minus one). * diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclaration.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclaration.java index 673a3816a6..1048e9314d 100644 --- a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclaration.java +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclaration.java @@ -51,6 +51,27 @@ public class NumberSeriesDeclaration extends Artefact { @Expose private int size; + /** + * The PARTITION SOURCE of a partitioned series (intent {@code per:}): the physical table whose rows + * are the partition values, so the management surface can label a partition row by the entity's + * display name and list a (virtual) row for every value BEFORE its first allocation - an operator + * seeds a company's starting number before its first document. Authored physical coordinates, + * exactly like a {@code .csvim}'s table/columns. Empty for an unpartitioned series. + */ + @Column(name = "DECLARATION_PARTITION_TABLE", columnDefinition = "VARCHAR", nullable = true, length = 255) + @Expose + private String partitionTable; + + /** The partition-source column holding the partition VALUE (the {@code per} relation's key). */ + @Column(name = "DECLARATION_PARTITION_KEY", columnDefinition = "VARCHAR", nullable = true, length = 255) + @Expose + private String partitionKey; + + /** The partition-source column holding the human label the settings page shows. */ + @Column(name = "DECLARATION_PARTITION_LABEL", columnDefinition = "VARCHAR", nullable = true, length = 255) + @Expose + private String partitionLabel; + /** * Instantiates a new number series declaration. * @@ -126,6 +147,60 @@ public void setSize(int size) { this.size = size; } + /** + * Gets the partition-source table. + * + * @return the table, or {@code null} for an unpartitioned series + */ + public String getPartitionTable() { + return partitionTable; + } + + /** + * Sets the partition-source table. + * + * @param partitionTable the table + */ + public void setPartitionTable(String partitionTable) { + this.partitionTable = partitionTable; + } + + /** + * Gets the partition-source key column. + * + * @return the column, or {@code null} + */ + public String getPartitionKey() { + return partitionKey; + } + + /** + * Sets the partition-source key column. + * + * @param partitionKey the column + */ + public void setPartitionKey(String partitionKey) { + this.partitionKey = partitionKey; + } + + /** + * Gets the partition-source label column. + * + * @return the column, or {@code null} + */ + public String getPartitionLabel() { + return partitionLabel; + } + + /** + * Sets the partition-source label column. + * + * @param partitionLabel the column + */ + public void setPartitionLabel(String partitionLabel) { + this.partitionLabel = partitionLabel; + } + /** * To string. * diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizer.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizer.java index 4c4d132b81..d4075a17de 100644 --- a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizer.java +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizer.java @@ -114,6 +114,11 @@ protected List parseImpl(String location, byte[] conten validate(location, entry, seen); NumberSeriesDeclaration declaration = new NumberSeriesDeclaration(location, entry.name, entry.prefix == null ? "" : entry.prefix, entry.size); + if (entry.partitions != null) { + declaration.setPartitionTable(entry.partitions.table); + declaration.setPartitionKey(entry.partitions.key); + declaration.setPartitionLabel(entry.partitions.label); + } declaration.updateKey(); try { NumberSeriesDeclaration existing = getService().findByKey(declaration.getKey()); @@ -141,6 +146,23 @@ private static void validate(String location, SeriesEntry entry, Set see } catch (IllegalArgumentException ex) { throw new ParseException("Series [" + entry.name + "] in [" + location + "]: " + ex.getMessage(), 0); } + if (entry.partitions != null) { + validateIdentifier(location, entry.name, "partitions.table", entry.partitions.table); + validateIdentifier(location, entry.name, "partitions.key", entry.partitions.key); + validateIdentifier(location, entry.name, "partitions.label", entry.partitions.label); + } + } + + /** + * A partition-source coordinate must be a plain SQL identifier: the management surface queries the + * declared table with these names quoted verbatim, so anything else is either a typo or an + * injection attempt through a published artefact - both are rejected at parse. + */ + private static void validateIdentifier(String location, String series, String attribute, String value) throws ParseException { + if (value == null || !value.matches("[A-Za-z_][A-Za-z0-9_]*")) { + throw new ParseException("Series [" + series + "] in [" + location + "]: [" + attribute + + "] must be a plain SQL identifier (letters, digits, underscore), got [" + value + "]", 0); + } } /** @@ -265,7 +287,10 @@ private Optional findConflictingDeclaration(NumberSerie private static boolean sameShape(NumberSeriesDeclaration one, NumberSeriesDeclaration two) { return one.getSize() == two.getSize() && one.getPrefix() - .equals(two.getPrefix()); + .equals(two.getPrefix()) + && java.util.Objects.equals(one.getPartitionTable(), two.getPartitionTable()) + && java.util.Objects.equals(one.getPartitionKey(), two.getPartitionKey()) + && java.util.Objects.equals(one.getPartitionLabel(), two.getPartitionLabel()); } /** @@ -323,5 +348,17 @@ private static class SeriesEntry { String name; String prefix; int size; + PartitionsEntry partitions; + } + + /** + * The optional partition source of a partitioned series: the physical table whose rows are the + * partition values ({@code key} column) with their display labels ({@code label} column) - what + * lets the settings page label partition rows and list every value before its first allocation. + */ + private static class PartitionsEntry { + String table; + String key; + String label; } } diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js index 3eaa326221..9210fa29ed 100644 --- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js +++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js @@ -152,9 +152,15 @@ document.addEventListener('alpine:init', () => { numberingLoading: false, numberingError: null, - /** A readable label for a series row: the series, plus the partition in brackets when partitioned. */ + /** + * A readable label for a series row: the series, plus the partition's DISPLAY LABEL when the + * declaration names a partition source ("Sales Invoice - ACME Ltd."), falling back to the raw + * partition value in brackets when no label resolves. + */ numberingLabel(row) { - return row.partition ? row.series + ' [' + row.partition + ']' : row.series; + if (!row.partition) return row.series; + if (row.partitionLabel) return row.series + ' — ' + row.partitionLabel; + return row.series + ' [' + row.partition + ']'; }, /** @@ -205,10 +211,14 @@ document.addEventListener('alpine:init', () => { this.numbering = data.map((c) => ({ series: c.series, partition: c.partition || '', + partitionLabel: c.partitionLabel || '', prefix: c.prefix || '', size: c.size, next: c.next, partitioned: !!c.partitioned, + // A declared partition value that has never allocated: the row does not exist yet and is + // rendered from the base shape; saving it provisions it (seed a counter before first use). + virtual: !!c.virtual, saving: false, saved: false, orig: { prefix: c.prefix || '', size: c.size, next: c.next } diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/NumberingSdkIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/NumberingSdkIT.java index 005c5c58f8..9e3900099f 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/NumberingSdkIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/NumberingSdkIT.java @@ -22,6 +22,7 @@ import org.awaitility.Awaitility; import org.eclipse.dirigible.components.base.artefact.ArtefactLifecycle; import org.eclipse.dirigible.components.base.tenant.TenantContext; +import org.eclipse.dirigible.components.data.sources.manager.DataSourcesManager; import org.eclipse.dirigible.components.engine.numbering.DocumentNumberService; import org.eclipse.dirigible.components.engine.numbering.NumberSeriesDeclaration; import org.eclipse.dirigible.components.engine.numbering.NumberSeriesDeclarationService; @@ -56,8 +57,13 @@ class NumberingSdkIT extends IntegrationTest { private static final String CONTROLLER_PATH = IRepositoryStructure.PATH_REGISTRY_PUBLIC + CONTROLLER_LOCATION; private static final String ENDPOINT = "/services/java/" + PROJECT + "/api/NumberingTestController"; + private static final String PARTITIONED_SERIES = "NumberingPartIT"; + private static final String PARTITION_TABLE = "NUMBERING_IT_COMPANY"; + /** Declared shape: prefix {@code T-} in a total width of 6 → {@code T-0001}. */ - private static final String NUMBERS_CONTENT = "{\"series\": [{\"name\": \"" + SERIES + "\", \"prefix\": \"T-\", \"size\": 6}]}"; + private static final String NUMBERS_CONTENT = "{\"series\": [{\"name\": \"" + SERIES + "\", \"prefix\": \"T-\", \"size\": 6}," + + " {\"name\": \"" + PARTITIONED_SERIES + "\", \"prefix\": \"P-\", \"size\": 6," + " \"partitions\": {\"table\": \"" + + PARTITION_TABLE + "\", \"key\": \"COMPANY_ID\", \"label\": \"COMPANY_NAME\"}}]}"; /** The same series declared DIFFERENTLY by another module - must fail that artefact. */ private static final String RIVAL_NUMBERS_CONTENT = "{\"series\": [{\"name\": \"" + SERIES + "\", \"prefix\": \"X-\", \"size\": 8}]}"; @@ -76,6 +82,9 @@ class NumberingSdkIT extends IntegrationTest { @Autowired private NumberSeriesDeclarationService declarationService; + @Autowired + private DataSourcesManager dataSourcesManager; + @Autowired private DocumentNumberService documentNumberService; @@ -208,6 +217,56 @@ void theSettingsSurfaceSeedsPartitionCountersAndTheBaseRowIsOnlyTheShapeTemplate }, ASSERTION_TIMEOUT_SECONDS); } + @Test + void aDeclaredPartitionSourceLabelsRowsAndSeedsCountersBeforeFirstUse() throws Exception { + // The partition source: the table whose rows ARE the partition values (per: Company). + try (java.sql.Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection(); + java.sql.Statement statement = connection.createStatement()) { + statement.executeUpdate( + "CREATE TABLE \"" + PARTITION_TABLE + "\" (\"COMPANY_ID\" INTEGER PRIMARY KEY, \"COMPANY_NAME\" VARCHAR(100))"); + statement.executeUpdate("INSERT INTO \"" + PARTITION_TABLE + "\" VALUES (7, 'ACME Ltd.'), (9, 'Globex')"); + } + publishDeclarationAndController(); + + restAssuredExecutor.execute(() -> { + // Every declared partition value appears BEFORE its first allocation - a VIRTUAL row + // rendered from the base shape, labeled by the entity's display name. + given().when() + .get("/services/core/numbering") + .then() + .statusCode(200) + .body("find { it.series == '" + PARTITIONED_SERIES + "' && it.partition == '7' }.partitionLabel", equalTo("ACME Ltd.")) + .body("find { it.series == '" + PARTITIONED_SERIES + "' && it.partition == '7' }.virtual", equalTo(true)) + .body("find { it.series == '" + PARTITIONED_SERIES + "' && it.partition == '9' }.partitionLabel", equalTo("Globex")) + .body("find { it.series == '" + PARTITIONED_SERIES + "' && it.partition == '' }.partitioned", equalTo(true)); + + // Seeding the virtual row provisions it: the operator sets the company's starting number + // BEFORE its first document... + given().contentType(ContentType.JSON) + .body("{\"series\": \"" + PARTITIONED_SERIES + "\", \"partition\": \"7\", \"next\": 42}") + .when() + .put("/services/core/numbering") + .then() + .statusCode(204); + given().when() + .get("/services/core/numbering") + .then() + .statusCode(200) + .body("find { it.series == '" + PARTITIONED_SERIES + "' && it.partition == '7' }.virtual", equalTo(false)) + .body("find { it.series == '" + PARTITIONED_SERIES + "' && it.partition == '7' }.next", equalTo(42)); + + // ...and the FIRST issued document renders exactly it. + assertEquals("P-0042", given().when() + .get(ENDPOINT + "/nextPartitioned/7") + .then() + .statusCode(200) + .extract() + .asString(), + "the seeded Next is what the first document renders"); + }, ASSERTION_TIMEOUT_SECONDS); + } + @Test void aNewTenantGetsTheDeclaredSeriesWithItsOwnSequence() throws Exception { publishDeclarationAndController(); @@ -239,7 +298,12 @@ void aNewTenantGetsTheDeclaredSeriesWithItsOwnSequence() throws Exception { } @AfterEach - void cleanup() { + void cleanup() throws Exception { + try (java.sql.Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection(); + java.sql.Statement statement = connection.createStatement()) { + statement.executeUpdate("DROP TABLE IF EXISTS \"" + PARTITION_TABLE + "\""); + } boolean cleaned = false; for (String path : new String[] {CONTROLLER_PATH, NUMBERS_PATH, RIVAL_NUMBERS_PATH}) { if (repository.hasResource(path)) { @@ -294,6 +358,11 @@ public String nextFor(@PathParam("partition") String partition) { return DocumentNumbers.next("NumberingIT", partition); } + @Get("/nextPartitioned/{partition}") + public String nextPartitioned(@PathParam("partition") String partition) { + return DocumentNumbers.next("NumberingPartIT", partition); + } + @Get("/undeclared") public String undeclared() { return DocumentNumbers.next("NumberingUndeclaredIT");