diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 1c9984ade9..a85b43d325 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -385,7 +385,7 @@ Semantics worth knowing: - **`postings:` (top-level) = declarative posting (source-document status → generated local document + computed items).** The accounting "documents → ledger" capability, generalized (spike-derived; see the driving suite's spike findings). `PostingIntent` + parser `validatePostings` (creates = local document owning a composition items child; backReference = its to-one to the source, the at-most-once guard; event trigger `onTransition` with a mandatory `when: " == "` status guard, or `onCreate` for a source with NO status lifecycle - a booked payment - binding the `-created` topic with the `when` guard optional (#6421); item cells = `rule()` refs into a single-selector rule entity or Calc arithmetic over the source; row `when: ==|!= `). `GlueIntentGenerator.buildPostings` pre-renders EVERYTHING as Java expressions (the expansions convention — the template stays shape-only): topic + re-load coordinates via `CrossModelSupport`, guard, header assignments (copy / literal / `{placeholder}` concat), `ruleRow.` refs, `Calc.eval("", source, )` amounts with the scale from the LOCAL item field, null-safe Calc row guards. `postings` glue collection → the pipeline's collection case (source gen folder = sanitized model alias, topic keeps the RAW perspective) → `Posting.java.template`: a `MessageHandler` on `---transitioned` (#6220's channel) that re-loads the source by id (the payload lacks later-step data — the stamped number), guards, resolves the rule row (missing row / null referenced column → SKIP, the unposted worklist), and writes target + items through the repositories — so numbering / status `init:` / `checks:` fire on the created document. **Idempotent + resumable, not transactional** (the cloud-native consistency model — there is NO cross-step DB rollback; each write commits on its own): the back-reference identifies an existing post, so a redelivery of a COMPLETE post (item count ≥ the derived `expectedItems`) is a no-op, and a redelivery of a HALF-post (an item write failed after the target was saved) clears the partial items and rebuilds the full set — it never throws or half-posts. Concurrent-redelivery de-duplication is best-effort (a check-then-act on the back-reference) until a real UNIQUE key on the back-reference lands with schema constraint emission. Storno/negation mode LANDED as **`reverses:`** (paired with the `transitions:` void primitive - the "void-document event" is a transition into the void status): a reversal posting inherits creates/backReference/rule/map/items from the reversed sibling, negates every item amount expression on the SAME side (`Calc.eval("-()", ...)` - red storno), locates the original through the empty `storno:` self-link (none -> fail-soft skip), stamps the link on its creation, and both handlers' idempotency guards discriminate by that link (reversal counts linked rows, the sibling counts unlinked ones - `stornoProperty`/`stornoFilterProperty` in the glue). The explicit manual Reverse action (no source void) remains a follow-up. Compensation, not a transaction, is how a bad post is unwound. - **Lifecycle-aware aggregates: seed-row `stage:` + report `scope:` + symbolic status names (#6645).** An aggregate over an entity carrying a `function: EntityStatus` was **wrong by default** - drafts nobody had issued, cancelled and voided (анулиране) rows all landed in the sum unless the author remembered a magic-number status predicate in `filter:`, and nothing said so (the motivating case: a voided invoice kept its 2000 in "Revenue this month" because the report declared dimensions + measures and no `filter`, so the emitted query had no `WHERE` at all). Four coordinated pieces, all in `LifecycleStages` + `ReportIntentGenerator.scopePredicate` + `StatusSymbolResolver`: (1) a status **seed row** classifies what the status MEANS with a closed-vocabulary `stage: draft|live|cancelled|void` - metadata, never a column (the CSV generator only emits declared fields + referenced FKs, and `CsvimIntentGeneratorTest` pins that); (2) a report declares `scope: all` or a stage name, emitted as `."" IN ()` ANDed onto the filter; (3) with the nomenclature classified, an **aggregating** report **defaults to `live`** - but only when its dimensions/`filter` do not already reference the status (a breakdown BY status must keep its draft rows, and an authored predicate is authoritative), so an existing model is byte-identical until it adopts `stage:`; (4) every site that names a status accepts the **seeded name** (`from: [ISSUED]`, `setStatus: VOIDED`, `init: DRAFT`, `setRelationField` `value:`, `abortOn.status`, a check's `status`/`setStatus`, `immutableWhen`, a posting's `event.when`, a report's `filter`) - resolved on the **raw YAML tree before the typed Gson mapping** (the `rejectRemovedNumberKeys` precedent), so every validator, generator and template keeps seeing plain integers. **Why names matter more than they look:** an id is positional, so inserting a status mid-nomenclature shifts every later id and silently retargets every guard authored against the old numbering - that is how a `reverses:` posting guarded `when: "Status == 8"` stopped matching a Void that now writes 9, leaving the ledger with a receivable for a document that no longer existed, with well-formed Java emitted throughout. **Boundaries, deliberate:** the nomenclature must be seeded IN THIS MODEL - the parser holds one file and no repository, so a **cross-model** status can neither be stage-scoped nor named (both fail loudly naming the numeric-id fallback; cross-model symbols need the name→id map on the generated `.model` and are follow-up work). A symbolic **ordering** comparison (`Status >= ISSUED`) is rejected - names have no order, that is what `scope:` is for. A nomenclature that declares its own `stage` property collides with the marker and is rejected rather than guessed. Nothing is emitted into the `.model` for `stage` - no consumer needs it yet (the Harmonia badge's `statusVariant` keyword guess is the obvious future one). **Part 3, the cheap half that catches everything the other three cannot:** when a report aggregates over a lifecycle entity and neither declares `scope:` nor filters on the status AND the nomenclature is unclassified, generation records a `context.addIssue` warning - surfaced in the generate response's `warnings` and now in the **Intent Editor**'s own amber strip (it used to discard them on success; the Builder shell already showed them). That warning, not the default, is what turns an invisible modelling omission into a visible one. - **`lifecycle:` on an entity = the declarative state machine (#6714).** The whole set of legal status edges, declared once over the entity's `function: EntityStatus` nomenclature (`edges: [{ from: DRAFT, to: [ISSUED, CANCELLED] }, ...]`, either side a seeded name or an id) and **enforced on every status write**. The gap it closes: the status machinery was a set of point constructs - `init:` names the start, a `transitions:` button guards the flips that go through THAT button, a workflow `setRelationField` writes one unguarded, a `checks:` rejection files another - and nothing declared which edges were legal at all, so any other writer (a workflow branch, a glue action, a plain REST call) could jump a document from any status to any other and nothing noticed. **Enforcement lives in the generated REPOSITORY, deliberately** (`Repository.java.template`: `LIFECYCLE_EDGES` + `enforceLifecycle` / `enforceLifecycleMove` / `enforceLifecycleStart`, `ValidationException` -> 400) - it is the ONE choke point every writer passes through: `update` (the REST payload), `updateWithoutEvent` (system writes), and `updateProperties` (which `updateProperty`, and therefore the transition controller, the workflow setters and `updateDerived`, all route through - so the targeted-write overrides are now emitted for a lifecycle entity too, not only for `documentChecks`/`hasLabel`). Guarding the transition endpoints instead would have left every other writer free, which is the whole defect. `enforceLifecycleStart` (emitted only when the status relation declares `init:`) additionally refuses a CREATE filed anywhere but at the start - entering the lifecycle mid-graph skips it rather than travelling it - and is placed BEFORE the aggregate-guard macros in `save()` so an `outcome: reject` can still file the record where the model says. Emission is three scalars on the entity map (`lifecycleStatusProperty`, `lifecycleEdges` as `1>2,1>9` pairs, `lifecycleStatusNames` as `1=DRAFT,...` so a rejection reads "cannot move from ISSUED to DRAFT" instead of quoting positional ids, plus `lifecycleInitialStatus`) - scalars, so they reach the `.edm` twin like `immutableStatusValues`. **Parse-time is where the other status sites are made to agree** (`validateLifecycles`): every `from` of a `transitions:` entry must reach its `setStatus` along an edge (a button is presentation over the graph), and a status written by a `setRelationField` step or forced by a check's rejection must be one some edge reaches - which is what catches a reject path transiting through an approved status when the file is read. **Deliberate boundaries:** no `on:` key - the graph is always over the EntityStatus relation, so naming it would be redundant, and YAML 1.1 reads a bare `on` as the boolean `true` (it would arrive as the key `true` and bind to nothing), so `rejectLifecycleOn` refuses it in the raw-tree preprocessing rather than dropping it silently; a cross-model nomenclature is seeded in its owner model and so is its lifecycle (refused, naming that); the nomenclature must be seeded here (the ids are validated against the seeds); no reachability check - one nomenclature may serve two entities with different graphs, so "unreachable here" is not an error. -- **`immutableWhen:` / `immutable:` on an entity = user-write immutability.** `immutableWhen: "Status == 2"` (a boolean expression over EntityStatus seed ids, terms joined with `||`) makes update/delete through the generated REST controller answer 409 CONFLICT while the record's `function: EntityStatus` FK satisfies it; `immutable: true` is the unconditional append-only variant (mutually exclusive with `immutableWhen`; a non-existent id still yields 404, not 409). Emitted as the entity-level `immutableStatusProperty` + `immutableStatusValues` (or `immutableAlways`) model attrs; `requireMutable` fetches the existing row before writing. Repository writes are deliberately unaffected — the workflow (storno generation, roll-ups, ProcessId write-back) keeps working; this guards the USER surface, per the accounting audit-trail requirement (corrections are reversals, never edits). **The UI is gated up front, not just on the 409:** each of the three generated controllers (power / partner / my) also exposes a **`GET /{id}/mutable`** pre-check (`{"mutable": true|false}` via the shared `isMutable`, scoped like its reads), and every Harmonia surface consumes it — the manage form and document pages ask it on edit load and force the read-only preview mode with a "Read-only" title badge (so a directly typed `/edit` URL opens read-only), the partner/my form + document pages disable their controls (`fieldset :disabled`) and hide Save/Delete/item actions, while the browse tables (manage list, master) gate row Edit/Delete through a **baked `isRowImmutable(row)`** computed from the row's status FK against the generation-time immutable ids — no per-row API call, same generated-from-the-same-attrs no-drift argument as the client `validationSchema`. The pre-check fails OPEN (an outage must not lock the UI); the PUT/DELETE 409 stays the authoritative guard. Covered by `IntentEmissionCoverageIT` (endpoint tokens + page tokens + mutable=false/true over REST). Parser requires an EntityStatus relation. Alongside it (no DSL): every generated controller now maps a **database constraint violation on DELETE to 409** ("referenced by other records") instead of a 500. Caveat discovered while verifying: the generated schema currently emits **no FK constraints at all** (`constraints: []` on every table — same-model included), so this mapping only engages once constraint emission lands; whether to emit them (a suite-wide data-integrity semantics change: deploy order, CSVIM import order, existing tables unaffected by ALTER) is a separate decision, raised with the accounting findings. Date-based period locking (records whose date falls in a Locked period) is deliberately NOT part of this — its shape needs the real fiscal-period module and follows as its own PR. +- **`immutableWhen:` / `immutable:` on an entity = user-write immutability.** `immutableWhen: "Status == 2"` (a boolean expression over EntityStatus seed ids, terms joined with `||`) makes update/delete through the generated REST controller answer 409 CONFLICT while the record's `function: EntityStatus` FK satisfies it; `immutable: true` is the unconditional append-only variant (mutually exclusive with `immutableWhen`; a non-existent id still yields 404, not 409). Emitted as the entity-level `immutableStatusProperty` + `immutableStatusValues` (or `immutableAlways`) model attrs; `requireMutable` fetches the existing row before writing. Repository writes are deliberately unaffected — the workflow (storno generation, roll-ups, ProcessId write-back) keeps working; this guards the USER surface, per the accounting audit-trail requirement (corrections are reversals, never edits). **The UI is gated up front, not just on the 409:** each of the three generated controllers (power / partner / my) also exposes a **`GET /{id}/mutable`** pre-check (`{"mutable": true|false}` via the shared `isMutable`, scoped like its reads), and every Harmonia surface consumes it — the manage form and document pages ask it on edit load and force the read-only preview mode with a "Read-only" title badge (so a directly typed `/edit` URL opens read-only), the partner/my form + document pages disable their controls (`fieldset :disabled`) and hide Save/Delete/item actions, while the browse tables (manage list, master) gate row Edit/Delete through a **baked `isRowImmutable(row)`** computed from the row's status FK against the generation-time immutable ids — no per-row API call, same generated-from-the-same-attrs no-drift argument as the client `validationSchema`. The pre-check fails OPEN (an outage must not lock the UI); the PUT/DELETE 409 stays the authoritative guard. Covered by `IntentEmissionCoverageIT` (endpoint tokens + page tokens + mutable=false/true over REST). Parser requires an EntityStatus relation. Alongside it (no DSL): every generated controller now maps a **database constraint violation on DELETE to 409** ("referenced by other records") instead of a 500. Caveat discovered while verifying: the generated schema currently emits **no FK constraints at all** (`constraints: []` on every table — same-model included), so this mapping only engages once constraint emission lands; whether to emit them (a suite-wide data-integrity semantics change: deploy order, CSVIM import order, existing tables unaffected by ALTER) is a separate decision, raised with the accounting findings. Date-based period locking (records whose date falls in a Locked period) is deliberately NOT part of this — its shape needs the real fiscal-period module and follows as its own PR. **The lock reaches the master's composition CHILDREN (#6695).** It was per-entity, and a child declares no immutability of its own — while its generated repository writes THROUGH to the master, recomputing `net`/`vat`/`total` on every `save`/`update`/`delete`. So `POST`/`PUT`/`DELETE` on a line of an ISSUED invoice succeeded over REST and silently rewrote the document's totals after the number was stamped, the immutable snapshot taken and the ledger posted — the UI forbade it, REST permitted it, and the permitted operation was the one `immutableWhen` exists to prevent. `ModelParameterProcessor.inheritMasterLock` now propagates the master's `immutableAlways` / `immutableStatusProperty` + values onto each direct composition child as a `masterLock` map (master entity + FK property + its `…Entity`/`…Repository` classes, resolved through the composition FK's perspective exactly as the personal/partner inheritance does), and all three generated controllers (power / partner / my) emit a `requireMasterMutable` that loads the master and answers the same 409 — on create (the payload's FK), on update (the STORED master *and* the incoming one, so a line cannot be moved into a locked document either), on delete, and on an attachment upload. Engine writers stay exempt by construction: they go through the repository, not the controller — which is why the issue-time snapshot generator (`Attachments.store` + `repository.save`) is untouched. The opt-out is the flag #6700 already introduced: `locksWithMaster: false` on the child (settlement is a different lifecycle from content), so the affordance and the REST guard are governed by one declaration and cannot drift apart. Only the DIRECT child is covered — that is the shape that writes through to the master. It composes with the prompted `generates` action (#6685): that create runs through the TARGET's repository, not a controller, so a guided create against a post-issue child keeps working on a locked document exactly as its per-record button (deliberately not gated on mutability) implies — the panel and the action remain the two separate answers to "this collection must go on being recorded". `IntentEmissionCoverageIT` carries both controls: `EntryLine` (silent → inherits) is refused create/update/delete on a POSTED entry and the master's total is asserted UNMOVED, while `CampaignNote` (`locksWithMaster: false`) still posts to a locked campaign. - **`checks:` on an entity = declarative cross-field / cross-line validations (the double-entry shape).** Three kinds (`CheckIntent`): row-level `exactlyOne` (`fields:` — exactly one non-null; emitted PascalCased into the `.model` `checks` list and enforced in the generated REST `validate()` with 400) and document-level `itemsSumEqual` (`over:` two item fields whose sums must match) / `itemsMin` (`count:`), both REQUIRING a `status:` gate (an EntityStatus seed id) — parser-enforced, because an ungated sum check would forbid drafting a document item by item. The EDM generator precomputes everything template-side (`buildChecks`: items entity + back-FK via the composition child, `statusProperty`, PascalCased fields); `ModelParameterProcessor` splits `rowChecks`/`documentChecks`; the **DAO repository** enforces document checks in `save`/`update`/**`updateWithoutEvent`** whenever the persisted entity carries the gate status — so the workflow setter flipping DRAFT→POSTED hits `enforceChecks` and an unbalanced document FAILS the write instead of silently posting: it throws the SDK `org.eclipse.dirigible.sdk.db.ValidationException`, which the client-controller dispatcher (`ControllerInvoker`) maps to **HTTP 400** with the authored message on a REST create/update, and which rolls back the task completion on the BPMN path (the capacity guard on roll-ups throws the same). `recalculate()` deliberately bypasses it (it persists the recomputed totals through the BASE targeted write, `super.updateProperties(id, totals)`, so a document still being assembled line by line never fails its own gate). No Harmonia-side mirror in v1 — the task-completion error surfaces the authored message. - **`resolves:` = the effective-dated register lookup (#6712).** The enterprise shape with no declarative form before it: a register says "X applied to Y from A to B" (a vehicle assignment, a price list, a contract in force, an org assignment), a record carries the match key(s) and a date, and a to-one must be filled from the row whose period covers that date. Nothing else in the DSL reaches it - `dependsOn` is a UI-time copy with equality matching only, a `decision` condition is a single comparison, and `setField` writes constants - so every application hand-wrote the same delegate. Authored as `{ event: { onCreate|onUpdate: , when? }, set: , from: , match: { : , ... }, between: { start?, end?, value }, outcome?: , found?/notFound?/ambiguous?: { setStatus } }`; `ResolveIntent` -> `GlueIntentGenerator.buildResolves` -> the `resolves` glue collection -> `Resolve.java.template`, a `@Component MessageHandler` on the record's event topic. **All three outcomes are first-class, and that is the point of the construct:** exactly one covering row fills the relation, NO covering row and MORE THAN ONE covering row both leave it unset (an automation that silently picks one of two candidates is worse than none - the ambiguous register goes back to a human). Each outcome may route the record by `setStatus` (seed id or seeded name, resolved by `StatusSymbolResolver` like every other status site), and the attempt is **observable**: `outcome:` stamps `found`/`notFound`/`ambiguous` into a string field of the record - queryable, filterable in a list view, and readable by a process `decision` - and the handler logs the keys and the date it checked. **Decisions worth keeping:** the value copied is derived, not authored - the register must carry exactly ONE to-one to the same target as `set:`, and zero or two is a validation error rather than a guess (the same refusal, one altitude up); a record that already carries the relation is skipped, so a manual correction is never overwritten and a re-delivered event is a no-op; the write is a single targeted `updateProperties` of the relation + the outcome + the status, so no `-updated` re-fires and no concurrent write to another column is reverted; period bounds are optional on either side (open-ended = still valid), the end is INCLUSIVE, and a date-only bound covers its whole day (the generated `millis`/`endExclusive` helpers put a `LocalDate` and an `Instant` on one epoch-milli axis, UTC). v1 is same-model (`from:` must be declared here) and binds to `onCreate`/`onUpdate` only - `onDelete` is refused, there is nothing left to fill. The parser refuses a `when` guard it cannot render rather than degrading it to an always-open guard. - **Every DERIVED write is targeted (document totals, `rollups:`, `aggregates:`) — the last member of the lost-update family.** A recompute reads a row, changes the one or two columns it computes, and persists. Persisting the WHOLE row silently reverts any concurrent write to another column of that row: the trigger `ProcessId` variant was fixed in #6226 and the workflow setter/writer variant in #6306, and the recompute variant was live-reproduced against a roll-up (REST-create a parent, PUT another column immediately after → 200, but a re-read shows the OLD value; the recompute had read the row before the PUT and wrote its stale snapshot after it). All three recompute sites now write only what they computed: `Repository.recalculate(Object)` collects the document totals into a map and calls the base `super.updateProperties` (no gate checks, no `-updated` — exactly the previous `super.update` semantics minus the merge); `Rollup.java.template` and `Aggregate.java.template` collect each recomputed column into a `derived` map and persist through the generated **`updateDerived(id, values)`**, which routes through `updateProperties` (so a `checks:` entity still runs its gate and a labelled entity still refreshes its `Name`) and then re-publishes `---updated` — the event contract the old full-row `update()` provided, which TRANSITIVE roll-ups above the row depend on. Two invariants when touching these: a column assigned in the recompute must also be put into `derived` (a capacity roll-up writes count + balance + status), and an EMPTY `derived` map means nothing is persisted, so the map is what the emission oracle asserts. Covered by the `IntentEmissionCoverageIT` derived-write assertions (Bill document totals, `ClaimLineClaimRollupOnCreate`, `LedgerTotalAggregateOnCreate`). 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 3d1b7a50f6..c53671af44 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 @@ -273,7 +273,12 @@ field may declare: `||`). Every term must reference the EntityStatus relation by its authored name. Workflow/system writes through the repository stay possible - corrections to an immutable record are reversals generated by the flow, never edits. Requires an EntityStatus relation. (`immutableIn:` is the - pre-rename spelling and is rejected with a migration message.) + pre-rename spelling and is rejected with a migration message.) **The lock reaches the entity's + composition CHILDREN**: a child declares no immutability of its own, but its writes recompute the + master's totals, so creating, editing or deleting a line of a locked document is rejected with 409 + by the child's own controller too - otherwise the one operation the lock exists to prevent stayed + reachable over REST while the UI already withheld it. Opt a collection out with + `locksWithMaster: false` (below). - `immutable: true` (entity-level) - **append-only**: every record is read-only for user writes from the moment it is created - update and delete always return 409. The canonical case is a snapshot entity (e.g. the frozen copy stored when an invoice is SENT): written once by the flow, @@ -315,7 +320,11 @@ field may declare: **panel**; a document's own line items are the document (they stay locked, and the flag would be inert there). Requires a composition parent that actually declares `immutableWhen` / `immutable` - both are validated, so an inert declaration fails at authoring time instead of quietly doing - nothing. + nothing. The flag governs BOTH halves: without it the child inherits the master's lock in the UI + *and* at the REST layer (409 from the child's own controller); with it, both stay open. Engine + writers are unaffected either way - they go through the repository, not the controller, so + auto-settlement, roll-ups, workflow delegates and the void transition keep writing to children of + a locked master. - `hierarchy: ` (entity-level) - **tree entities**: names the entity's own optional to-one SELF-relation forming the tree edge (`hierarchy: Parent` with `- { name: Parent, kind: manyToOne, to: }`). The generated list renders as an diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java index 52f08928a5..4eadef8783 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java @@ -73,6 +73,7 @@ static void process(Map model, Map parameters) { processEntity(entity, entities, parameters); } if (truthy(parameters, "javaRuntime")) { + inheritMasterLock(entities, parameters); inheritPersonalScope(entities, parameters); inheritPartnerScope(entities, parameters); collectSensitiveProperties(entities); @@ -480,6 +481,59 @@ private static void resolveDropdown(Map property, Map + * A child declares no immutability of its own - the lock belongs to the document - yet its writes + * synchronously recompute the master's aggregate columns. Without this, creating, editing or + * deleting a line of a locked document succeeded over REST and silently rewrote the very totals the + * lock protects, after the number was stamped, the snapshot taken and the ledger posted. + * + *

+ * Only the direct child is covered, which is the shape that writes through to the master. Engine + * writers stay exempt by construction: they go through the repository rather than the controller, + * exactly as the master's own guard already assumes. A child that declares + * {@code locksWithMaster: false} keeps its user writes - the deliberately post-lock collection, + * such as the payments settling an issued invoice. + * + * @param entities every entity in the model + * @param parameters the generation parameters + */ + private static void inheritMasterLock(List> entities, Map parameters) { + for (Map entity : entities) { + if ("false".equals(str(entity, "locksWithMaster"))) { + continue; + } + Map parentFk = findCompositionProperty(entity); + if (parentFk == null) { + continue; + } + Map parent = findEntity(entities, str(parentFk, "relationshipEntityName")); + if (parent == null) { + continue; + } + boolean always = truthy(parent, "immutableAlways"); + String statusProperty = str(parent, "immutableStatusProperty"); + if (!always && (statusProperty == null || statusProperty.isEmpty())) { + continue; + } + String parentPerspective = NamingHelper.sanitizeJavaIdentifier(str(parentFk, "relationshipEntityPerspectiveName")); + String parentPackage = "gen." + str(parameters, "javaGenFolderName") + ".data." + parentPerspective + "."; + Map masterLock = new LinkedHashMap<>(); + masterLock.put("fkProperty", parentFk.get("name")); + masterLock.put("fkJavaClass", parentFk.get("dataTypeJavaClass")); + masterLock.put("entity", parent.get("name")); + masterLock.put("entityClass", parentPackage + str(parent, "name") + "Entity"); + masterLock.put("repositoryClass", parentPackage + str(parent, "name") + "Repository"); + masterLock.put("always", always); + masterLock.put("statusProperty", statusProperty); + masterLock.put("statusValues", str(parent, "immutableStatusValues")); + entity.put("masterLock", masterLock); + } + } + /** * Propagates the personal scope from a composition parent to its direct children - one hop only, * which is what the generated surfaces support. A deeper child simply has no personal surface. diff --git a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java index fb37d1eb32..e256f06c90 100644 --- a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java +++ b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java @@ -295,6 +295,67 @@ void marksAHierarchicalEntityAsNeedingReferenceValidation() { assertTrue(Boolean.TRUE.equals(entity.get("hasReferenceValidations"))); } + /** + * The line of a locked document must inherit the lock: its writes recompute the master's totals, so + * leaving the child unguarded leaves the master's own guard with an open back door. + */ + @Test + void aCompositionChildInheritsItsMastersStatusLock() { + Map master = entity("Invoice", "Invoices", property("Id", "INTEGER")); + master.put("immutableStatusProperty", "Status"); + master.put("immutableStatusValues", "2,3"); + Map child = entity("InvoiceItem", "Invoices", compositionTo("Invoice", "Invoices")); + + ModelParameterProcessor.process(model(master, child), javaParameters()); + + Map lock = masterLock(child); + assertEquals("Invoice", lock.get("fkProperty")); + assertEquals("Invoice", lock.get("entity")); + assertEquals("gen.sales_order.data.invoices.InvoiceEntity", lock.get("entityClass")); + assertEquals("gen.sales_order.data.invoices.InvoiceRepository", lock.get("repositoryClass")); + assertEquals("Status", lock.get("statusProperty")); + assertEquals("2,3", lock.get("statusValues")); + assertEquals(Boolean.FALSE, lock.get("always")); + } + + @Test + void aCompositionChildInheritsAnAppendOnlyMaster() { + Map master = entity("Invoice", "Invoices", property("Id", "INTEGER")); + master.put("immutableAlways", "true"); + Map child = entity("InvoiceItem", "Invoices", compositionTo("Invoice", "Invoices")); + + ModelParameterProcessor.process(model(master, child), javaParameters()); + + assertEquals(Boolean.TRUE, masterLock(child).get("always")); + } + + /** + * The deliberate post-lock collection (intent {@code locksWithMaster: false}) - money keeps + * arriving against an issued invoice long after its content is frozen. + */ + @Test + void aChildOptedOutOfTheLockCarriesNoGuard() { + Map master = entity("Invoice", "Invoices", property("Id", "INTEGER")); + master.put("immutableStatusProperty", "Status"); + master.put("immutableStatusValues", "2"); + Map child = entity("InvoicePayment", "Invoices", compositionTo("Invoice", "Invoices")); + child.put("locksWithMaster", "false"); + + ModelParameterProcessor.process(model(master, child), javaParameters()); + + assertNull(child.get("masterLock")); + } + + @Test + void aChildOfAnUnlockedMasterCarriesNoGuard() { + Map master = entity("Invoice", "Invoices", property("Id", "INTEGER")); + Map child = entity("InvoiceItem", "Invoices", compositionTo("Invoice", "Invoices")); + + ModelParameterProcessor.process(model(master, child), javaParameters()); + + assertNull(child.get("masterLock")); + } + /** * Builds a model around the given entities. * @@ -338,6 +399,46 @@ private static Map property(String name, String dataType) { return property; } + /** + * The inherited-lock metadata of a child entity. + * + * @param entity the child entity + * @return the metadata + */ + @SuppressWarnings("unchecked") + private static Map masterLock(Map entity) { + return (Map) entity.get("masterLock"); + } + + /** + * Builds the composition FK a child carries to its master - the property the whole master-detail + * derivation keys on. + * + * @param master the master entity name + * @param masterPerspective the master's perspective + * @return the property + */ + private static Map compositionTo(String master, String masterPerspective) { + Map property = property(master, "INTEGER"); + property.put("relationshipType", "COMPOSITION"); + property.put("relationshipCardinality", "1_n"); + property.put("relationshipEntityName", master); + property.put("relationshipEntityPerspectiveName", masterPerspective); + return property; + } + + /** + * The parameters of a generation targeting the Java runtime - the only one the cross-entity + * derivations run for. + * + * @return the parameters + */ + private static Map javaParameters() { + Map parameters = parameters(); + parameters.put("javaRuntime", Boolean.TRUE); + return parameters; + } + /** * Builds the parameters a request would carry. * diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template index f6d2123083..b9967a30fe 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template @@ -41,9 +41,15 @@ public class ${name}Controller { private static final Set FILTER_FIELDS = Set.of(#foreach($property in $properties)"${property.name}"#if($foreach.hasNext), #end#end); private final ${name}Repository repository; +#if($masterLock) + private final ${masterLock.repositoryClass} masterRepository; +#end - public ${name}Controller(${name}Repository repository) { + public ${name}Controller(${name}Repository repository#if($masterLock), ${masterLock.repositoryClass} masterRepository#end) { this.repository = repository; +#if($masterLock) + this.masterRepository = masterRepository; +#end } @Get @@ -155,6 +161,9 @@ public class ${name}Controller { #if($needsRoles) checkPermissions("write"); #end +#if($masterLock) + requireMasterMutable(entity.${masterLock.fkProperty}); +#end #if($isEntityPropertySecurityEnabled) applyOnCreate(entity); #end @@ -177,6 +186,9 @@ public class ${name}Controller { if (!org.eclipse.dirigible.sdk.http.Upload.isMultipartContent()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The request must be multipart/form-data"); } +#if($masterLock) + requireMasterMutable(${masterEntityId}); +#end List<${name}Entity> uploaded = new java.util.ArrayList<>(); // The SDK parses the multipart request and stores each file in the tenant CMS at // /Attachments/${name}//// (the multipart/FileItem handling stays inside @@ -226,6 +238,11 @@ public class ${name}Controller { #if($immutableStatusProperty || $immutableAlways) requireMutable(id); #end +#if($masterLock) + // Neither an edit inside a locked ${masterLock.entity} nor a move into one. + repository.findOne(id).ifPresent(stored -> requireMasterMutable(stored.${masterLock.fkProperty})); + requireMasterMutable(entity.${masterLock.fkProperty}); +#end #if($isEntityPropertySecurityEnabled) ${name}Entity existing = repository.findOne(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "${name} not found")); @@ -257,6 +274,9 @@ public class ${name}Controller { #if($immutableStatusProperty || $immutableAlways) requireMutable(id); #end +#if($masterLock) + repository.findOne(id).ifPresent(stored -> requireMasterMutable(stored.${masterLock.fkProperty})); +#end #if($attachmentEntity) String storagePath = repository.findOne(id) .map(a -> a.StoragePath) @@ -483,6 +503,47 @@ public class ${name}Controller { return true; } +#end +#if($masterLock) + // Inherited immutability: this ${name} is a line of a ${masterLock.entity}, and its writes + // recompute that document's totals - so while the document is locked its lines are locked with it, + // which is what the generated UI already shows (no Add, no row actions). Declare + // `locksWithMaster: false` on the child to keep a collection writable past the master's lock. + // Workflow / system writes through the repository stay unaffected, as for the master's own guard. + private void requireMasterMutable(${masterLock.fkJavaClass} masterId) { + // No master to consult: an unset FK is the required-field check's business, not the lock's. + if (masterId == null) { + return; + } + ${masterLock.entityClass} master = masterRepository.findOne(masterId).orElse(null); + if (master == null || isMasterMutable(master)) { + return; + } +#if($masterLock.always) + throw new ResponseStatusException(HttpStatus.CONFLICT, + "This ${name} belongs to an append-only ${masterLock.entity} and can no longer be changed"); +#else + throw new ResponseStatusException(HttpStatus.CONFLICT, + "The ${masterLock.entity} of this ${name} is immutable in its current status - corrections go through the workflow"); +#end + } + + private static boolean isMasterMutable(${masterLock.entityClass} master) { +#if($masterLock.always) + return false; +#else + if (master.${masterLock.statusProperty} == null) { + return true; + } + for (String immutable : "${masterLock.statusValues}".split(",")) { + if (immutable.equals(String.valueOf(master.${masterLock.statusProperty}))) { + return false; + } + } + return true; +#end + } + #end private static void validate(${name}Entity entity) { #if($rowChecks && $rowChecks.size() > 0) diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template index 49c03bc0c5..e990dc8ca1 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template @@ -22,6 +22,18 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +## A see-only personal surface refuses every write with 403 already, so the inherited lock would +## only add an unreachable guard and an unused repository: drop it for that variant. +#if($personalReadOnly) +#set($masterLock = false) +#end +#if($masterLock) +#if($personalParent) +#set($masterRepositoryRef = "parentRepository") +#else +#set($masterRepositoryRef = "masterRepository") +#end +#end /** * The PERSONAL (my) surface of ${name}: every read is scoped to the logged-in user's identity * record and every write is forced onto it - the client can neither see nor touch anybody else's @@ -41,13 +53,20 @@ public class ${name}MyController { #if($personalParent) private final ${personalParent.repositoryClass} parentRepository; #end +#if($masterLock && !$personalParent) + private final ${masterLock.repositoryClass} masterRepository; +#end public ${name}MyController(${name}Repository repository, ${personalIdentityRepositoryClass} identityRepository#if($personalParent), - ${personalParent.repositoryClass} parentRepository#end) { + ${personalParent.repositoryClass} parentRepository#end#if($masterLock && !$personalParent), + ${masterLock.repositoryClass} masterRepository#end) { this.repository = repository; this.identityRepository = identityRepository; #if($personalParent) this.parentRepository = parentRepository; +#end +#if($masterLock && !$personalParent) + this.masterRepository = masterRepository; #end } @@ -138,6 +157,9 @@ public class ${name}MyController { // The owner is the server's decision - whatever the client sent is overwritten. entity.${personalProperty} = me; #end +#if($masterLock) + requireMasterMutable(entity.${masterLock.fkProperty}); +#end #foreach($sensitive in $sensitiveProperties) entity.${sensitive} = null; #end @@ -150,6 +172,9 @@ public class ${name}MyController { ${name}Entity existing = requireMine(id); #if($immutableAlways || $immutableStatusProperty) requireMutable(existing); +#end +#if($masterLock) + requireMasterMutable(existing.${masterLock.fkProperty}); #end entity.#foreach($property in $properties)#if($property.dataPrimaryKey)${property.name}#end#end = id; #if($personalParent) @@ -171,6 +196,9 @@ public class ${name}MyController { ${name}Entity existing = requireMine(id); #if($immutableAlways || $immutableStatusProperty) requireMutable(existing); +#end +#if($masterLock) + requireMasterMutable(existing.${masterLock.fkProperty}); #end try { repository.deleteById(id); @@ -255,6 +283,45 @@ public class ${name}MyController { } } #end +#if($masterLock) + + // Inherited immutability: this ${name} is a line of a ${masterLock.entity}, and its writes + // recompute that document's totals - so while the document is locked its lines are locked with + // it. Declare `locksWithMaster: false` on the child to keep a collection writable past the + // master's lock. Workflow / system writes through the repository stay unaffected. + private void requireMasterMutable(${masterLock.fkJavaClass} masterId) { + if (masterId == null) { + return; + } + ${masterLock.entityClass} master = ${masterRepositoryRef}.findOne(masterId).orElse(null); + if (master == null || isMasterMutable(master)) { + return; + } +#if($masterLock.always) + throw new ResponseStatusException(HttpStatus.CONFLICT, + "This ${name} belongs to an append-only ${masterLock.entity} and can no longer be changed"); +#else + throw new ResponseStatusException(HttpStatus.CONFLICT, + "The ${masterLock.entity} of this ${name} is immutable in its current status - corrections go through the workflow"); +#end + } + + private static boolean isMasterMutable(${masterLock.entityClass} master) { +#if($masterLock.always) + return false; +#else + if (master.${masterLock.statusProperty} == null) { + return true; + } + for (String immutable : "${masterLock.statusValues}".split(",")) { + if (immutable.equals(String.valueOf(master.${masterLock.statusProperty}))) { + return false; + } + } + return true; +#end + } +#end #if($immutableAlways) private void requireMutable(${name}Entity existing) { diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template index 06af880d4e..9371cea4ad 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template @@ -22,6 +22,13 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +#if($masterLock) +#if($partnerParent) +#set($masterRepositoryRef = "parentRepository") +#else +#set($masterRepositoryRef = "masterRepository") +#end +#end /** * The PARTNER surface of ${name}: every read is scoped to the logged-in EXTERNAL partner's identity * record (Customer / Supplier, matched by email) and every write is forced onto it - a partner can @@ -42,13 +49,20 @@ public class ${name}PartnerController { #if($partnerParent) private final ${partnerParent.repositoryClass} parentRepository; #end +#if($masterLock && !$partnerParent) + private final ${masterLock.repositoryClass} masterRepository; +#end public ${name}PartnerController(${name}Repository repository, ${partnerIdentityRepositoryClass} identityRepository#if($partnerParent), - ${partnerParent.repositoryClass} parentRepository#end) { + ${partnerParent.repositoryClass} parentRepository#end#if($masterLock && !$partnerParent), + ${masterLock.repositoryClass} masterRepository#end) { this.repository = repository; this.identityRepository = identityRepository; #if($partnerParent) this.parentRepository = parentRepository; +#end +#if($masterLock && !$partnerParent) + this.masterRepository = masterRepository; #end } @@ -117,6 +131,9 @@ public class ${name}PartnerController { // The owner is the server's decision - whatever the client sent is overwritten. entity.${partnerProperty} = me; #end +#if($masterLock) + requireMasterMutable(entity.${masterLock.fkProperty}); +#end #foreach($sensitive in $sensitiveProperties) entity.${sensitive} = null; #end @@ -129,6 +146,9 @@ public class ${name}PartnerController { ${name}Entity existing = requireMine(id); #if($immutableAlways || $immutableStatusProperty) requireMutable(existing); +#end +#if($masterLock) + requireMasterMutable(existing.${masterLock.fkProperty}); #end entity.#foreach($property in $properties)#if($property.dataPrimaryKey)${property.name}#end#end = id; #if($partnerParent) @@ -150,6 +170,9 @@ public class ${name}PartnerController { ${name}Entity existing = requireMine(id); #if($immutableAlways || $immutableStatusProperty) requireMutable(existing); +#end +#if($masterLock) + requireMasterMutable(existing.${masterLock.fkProperty}); #end try { repository.deleteById(id); @@ -230,6 +253,45 @@ public class ${name}PartnerController { } } #end +#if($masterLock) + + // Inherited immutability: this ${name} is a line of a ${masterLock.entity}, and its writes + // recompute that document's totals - so while the document is locked its lines are locked with + // it. Declare `locksWithMaster: false` on the child to keep a collection writable past the + // master's lock. Workflow / system writes through the repository stay unaffected. + private void requireMasterMutable(${masterLock.fkJavaClass} masterId) { + if (masterId == null) { + return; + } + ${masterLock.entityClass} master = ${masterRepositoryRef}.findOne(masterId).orElse(null); + if (master == null || isMasterMutable(master)) { + return; + } +#if($masterLock.always) + throw new ResponseStatusException(HttpStatus.CONFLICT, + "This ${name} belongs to an append-only ${masterLock.entity} and can no longer be changed"); +#else + throw new ResponseStatusException(HttpStatus.CONFLICT, + "The ${masterLock.entity} of this ${name} is immutable in its current status - corrections go through the workflow"); +#end + } + + private static boolean isMasterMutable(${masterLock.entityClass} master) { +#if($masterLock.always) + return false; +#else + if (master.${masterLock.statusProperty} == null) { + return true; + } + for (String immutable : "${masterLock.statusValues}".split(",")) { + if (immutable.equals(String.valueOf(master.${masterLock.statusProperty}))) { + return false; + } + } + return true; +#end + } +#end #if($immutableAlways) private void requireMutable(${name}Entity existing) { diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/ChildLockControllerTemplateIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/ChildLockControllerTemplateIT.java new file mode 100644 index 0000000000..5663fe8f4d --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/ChildLockControllerTemplateIT.java @@ -0,0 +1,218 @@ +/* + * 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.integration.tests.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.engine.template.velocity.VelocityGenerationEngine; +import org.junit.jupiter.api.Test; + +/** + * Renders the three generated REST controllers through the platform's + * {@link VelocityGenerationEngine} to cover the inherited-lock branches a published application + * cannot reach on its own. + * + *

+ * A composition child of an immutable master must refuse user writes on EVERY generated surface - + * the power controller, and the partner / personal ones, where a customer or the record's own owner + * would otherwise rewrite a locked document's totals through their own endpoint. Only the power + * controller's branch is exercised end-to-end by {@code IntentEmissionCoverageIT}: reaching the + * other two from an intent needs a personal (or partner) root that also locks, and the personal + * fixture there deliberately tests scoping on an unlocked one. + * + *

+ * An unrendered Velocity branch is the silent-degradation case this suite exists for: a typo in a + * variable reference emits itself literally and the generated Java only fails later, in a user's + * project, at compile time. So these assertions also check that nothing of the form + * ${...} survives into the emitted guard. + * + *

+ * Rendering needs nothing from a running instance, so this boots no application context and uses + * its OWN engine instance rather than the shared bean - the same class, configured the same way, + * with no chance of leaving anything behind for the tests that publish real projects. + */ +class ChildLockControllerTemplateIT { + + private static final String BASE = "/META-INF/dirigible/template-application-rest-java/api/"; + + private final VelocityGenerationEngine velocityGenerationEngine = new VelocityGenerationEngine(); + + @Test + void thePowerControllerGuardsEveryWriteVerbAgainstTheMastersLock() throws Exception { + String rendered = render("EntityController.java.template", context(statusLock())); + + assertTrue(rendered.contains("gen.sales.data.invoices.InvoiceRepository masterRepository"), + "the guard must inject the MASTER's repository: " + rendered); + assertTrue(rendered.contains("requireMasterMutable(entity.Invoice);"), "create must consult the payload's master"); + assertTrue(rendered.contains("repository.findOne(id).ifPresent(stored -> requireMasterMutable(stored.Invoice));"), + "update and delete must consult the STORED master"); + assertTrue(rendered.contains("\"2,3\".split(\",\")"), "the guard must carry the master's immutable status ids"); + assertTrue(rendered.contains("HttpStatus.CONFLICT"), "a write against a locked master must be a 409"); + assertNoUnresolvedReferences(rendered); + } + + @Test + void anAppendOnlyMasterLocksItsChildrenUnconditionally() throws Exception { + String rendered = render("EntityController.java.template", context(appendOnlyLock())); + + assertTrue(rendered.contains("append-only Invoice"), "an append-only master must say so in the refusal"); + assertFalse(rendered.contains("master.Status"), "an append-only master has no status to consult: " + rendered); + assertNoUnresolvedReferences(rendered); + } + + /** + * The personal and partner controllers already hold their composition parent's repository when the + * scope is inherited through it, so the guard reuses that field rather than injecting the same + * repository twice. + */ + @Test + void thePersonalAndPartnerControllersReuseTheParentRepositoryTheyAlreadyHold() throws Exception { + for (String template : List.of("EntityMyController.java.template", "EntityPartnerController.java.template")) { + Map context = context(statusLock()); + context.put("personalParent", parent()); + context.put("partnerParent", parent()); + String rendered = render(template, context); + + assertTrue(rendered.contains("parentRepository.findOne(masterId)"), + template + " must reuse the parent repository: " + rendered); + assertFalse(rendered.contains("masterRepository"), template + " must not inject a second repository of the same type"); + assertTrue(rendered.contains("requireMasterMutable(existing.Invoice);"), + template + " must guard its update and delete against the stored master"); + assertNoUnresolvedReferences(rendered); + } + } + + /** + * With no scope inherited through the parent (the child carries its own owner), the same + * controllers have no parent repository to reuse and must inject the master's. + */ + @Test + void thePersonalAndPartnerControllersInjectTheMasterRepositoryWhenTheyHoldNoParent() throws Exception { + for (String template : List.of("EntityMyController.java.template", "EntityPartnerController.java.template")) { + String rendered = render(template, context(statusLock())); + + assertTrue(rendered.contains("gen.sales.data.invoices.InvoiceRepository masterRepository"), + template + " must inject the master's repository: " + rendered); + assertTrue(rendered.contains("masterRepository.findOne(masterId)"), template + " must consult the master through it"); + assertNoUnresolvedReferences(rendered); + } + } + + private String render(String templateName, Map parameters) throws Exception { + String location = BASE + templateName; + String template; + try (InputStream in = getClass().getResourceAsStream(location)) { + assertNotNull(in, "template resource not found on classpath: " + location); + template = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + byte[] out = velocityGenerationEngine.generate(parameters, location, template.getBytes(StandardCharsets.UTF_8)); + return new String(out, StandardCharsets.UTF_8); + } + + /** Asserts the emitted guard resolved every reference - an unresolved one renders literally. */ + private static void assertNoUnresolvedReferences(String rendered) { + for (String line : rendered.split("\n")) { + if (line.contains("MasterMutable") || line.contains("masterRepository") || line.contains("parentRepository.findOne")) { + assertFalse(line.contains("${"), "an unresolved template reference survived into the guard: " + line); + } + } + } + + private static Map context(Map masterLock) { + Map parameters = new HashMap<>(); + parameters.put("name", "InvoiceItem"); + parameters.put("projectName", "sales"); + parameters.put("perspectiveName", "Invoices"); + parameters.put("javaGenFolderName", "sales"); + parameters.put("javaPerspectiveName", "invoices"); + parameters.put("properties", List.of(primaryKey(), amount())); + parameters.put("sensitiveProperties", new ArrayList<>()); + parameters.put("personalProperty", "Person"); + parameters.put("personalFkJavaClass", "Integer"); + parameters.put("personalIdentityProperty", "Email"); + parameters.put("personalIdentityLabel", "Name"); + parameters.put("personalIdentityRepositoryClass", "gen.sales.data.people.PersonRepository"); + parameters.put("partnerProperty", "Customer"); + parameters.put("partnerFkJavaClass", "Integer"); + parameters.put("partnerIdentityProperty", "Email"); + parameters.put("partnerIdentityLabel", "Name"); + parameters.put("partnerIdentityRepositoryClass", "gen.sales.data.people.CustomerRepository"); + parameters.put("masterLock", masterLock); + return parameters; + } + + private static Map statusLock() { + Map masterLock = baseLock(); + masterLock.put("always", Boolean.FALSE); + masterLock.put("statusProperty", "Status"); + masterLock.put("statusValues", "2,3"); + return masterLock; + } + + private static Map appendOnlyLock() { + Map masterLock = baseLock(); + masterLock.put("always", Boolean.TRUE); + return masterLock; + } + + private static Map baseLock() { + Map masterLock = new LinkedHashMap<>(); + masterLock.put("fkProperty", "Invoice"); + masterLock.put("fkJavaClass", "Integer"); + masterLock.put("entity", "Invoice"); + masterLock.put("entityClass", "gen.sales.data.invoices.InvoiceEntity"); + masterLock.put("repositoryClass", "gen.sales.data.invoices.InvoiceRepository"); + return masterLock; + } + + private static Map parent() { + Map parent = new LinkedHashMap<>(); + parent.put("fkProperty", "Invoice"); + parent.put("fkJavaClass", "Integer"); + parent.put("entity", "Invoice"); + parent.put("entityClass", "gen.sales.data.invoices.InvoiceEntity"); + parent.put("repositoryClass", "gen.sales.data.invoices.InvoiceRepository"); + parent.put("personalProperty", "Person"); + parent.put("personalFkJavaClass", "Integer"); + parent.put("partnerProperty", "Customer"); + parent.put("partnerFkJavaClass", "Integer"); + return parent; + } + + private static Map primaryKey() { + Map property = new LinkedHashMap<>(); + property.put("name", "Id"); + property.put("dataName", "INVOICEITEM_ID"); + property.put("dataType", "INTEGER"); + property.put("dataTypeJavaClass", "Integer"); + property.put("dataPrimaryKey", Boolean.TRUE); + return property; + } + + private static Map amount() { + Map property = new LinkedHashMap<>(); + property.put("name", "Amount"); + property.put("dataName", "INVOICEITEM_AMOUNT"); + property.put("dataType", "DECIMAL"); + property.put("dataTypeJavaClass", "java.math.BigDecimal"); + property.put("dataPrimaryKey", Boolean.FALSE); + return property; + } +} diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 28283abad6..ee4351a02e 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -53,23 +53,25 @@ * row), and a stale registry template generates feature-less code - all with every pipeline step * returning success. A feature's test must therefore assert the OUTERMOST observable layer (the * generated token at minimum, the runtime behavior where reachable), never only the parsed model. - * Covered here: {@code immutableWhen} / {@code immutable} (409 on write/delete), {@code checks} - * (exactlyOne / itemsMin / itemsSumEqual), {@code hierarchy}/{@code leafOnly}, {@code multilingual} - * (read-time overlay), seed rows carrying a RELATION column, aggregate totals, first-class - * {@code number:} stamping from an authored {@code .numbers} series declaration, - * {@code transitions} (the guarded on-demand status flip: allowed-status 200, wrong-status/guard - * 409), {@code lifecycle} (the declarative state machine: the graph walked through its transitions, - * an unmodeled flip and a create filed mid-lifecycle both refused through the plain REST surface no - * transition guard covers), {@code postings} with {@code reverses} (post on a transition; - * red-storno reversal on void - negated amounts, storno link, fail-soft), the {@code notify} block - * with {@code attach: print} (send the document itself by e-mail - on a transition and on a process - * step; the fail-soft contract), {@code calculatedActionOnCreate} on a to-one RELATION (the FK - * resolved server-side by a hand-written {@code custom/} action: assigned in the repository, and at - * runtime both defaulted when omitted and left alone when the caller supplied one), the - * event-driven {@code generates} (posting the source mints the whole document with nobody clicking, - * and a click afterwards returns that same document - the at-most-once back-reference guard), and - * the personal (my) surface ({@code identity}/{@code personal}/{@code sensitive}: scoped reads, - * forced owner, stripped fields). + * Covered here: {@code immutableWhen} / {@code immutable} (409 on write/delete, and the lock + * inherited by a composition child - a line of a locked document is refused while a child that + * declared {@code locksWithMaster: false} keeps its writes), {@code checks} (exactlyOne / itemsMin + * / itemsSumEqual), {@code hierarchy}/{@code leafOnly}, {@code multilingual} (read-time overlay), + * seed rows carrying a RELATION column, aggregate totals, first-class {@code number:} stamping from + * an authored {@code .numbers} series declaration, {@code transitions} (the guarded on-demand + * status flip: allowed-status 200, wrong-status/guard 409), {@code lifecycle} (the declarative + * state machine: the graph walked through its transitions, an unmodeled flip and a create filed + * mid-lifecycle both refused through the plain REST surface no transition guard covers), + * {@code postings} with {@code reverses} (post on a transition; red-storno reversal on void - + * negated amounts, storno link, fail-soft), the {@code notify} block with {@code attach: print} + * (send the document itself by e-mail - on a transition and on a process step; the fail-soft + * contract), {@code calculatedActionOnCreate} on a to-one RELATION (the FK resolved server-side by + * a hand-written {@code custom/} action: assigned in the repository, and at runtime both defaulted + * when omitted and left alone when the caller supplied one), the event-driven {@code generates} + * (posting the source mints the whole document with nobody clicking, and a click afterwards returns + * that same document - the at-most-once back-reference guard), and the personal (my) surface + * ({@code identity}/{@code personal}/{@code sensitive}: scoped reads, forced owner, stripped + * fields). */ class IntentEmissionCoverageIT extends IntegrationTest { @@ -244,13 +246,18 @@ class IntentEmissionCoverageIT extends IntegrationTest { # layout must resolve the status FK to a label lookup and render it as a badge in # the table column and the detail pane, exactly like the list layout does. - name: Campaign + immutableWhen: "Status == 2" fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: name, type: string, required: true, length: 100 } relations: - { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 } + # locksWithMaster: false - the deliberate post-lock collection (#6700). It is the + # negative control for the inherited lock below: notes keep their writes after the + # campaign froze, while EntryLine (which says nothing) inherits the lock. - name: CampaignNote + locksWithMaster: false fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: note, type: string, length: 200 } @@ -525,6 +532,9 @@ class IntentEmissionCoverageIT extends IntegrationTest { # `attach: print` is authored on a transition AND on a process step (below). - name: Bill function: Document + # SENT (status 2) freezes the document: the assertions below add the line write that + # would otherwise rewrite the totals the sent PDF was rendered from (#6695). + immutableWhen: "Status == 2" fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: note, type: string, length: 200 } @@ -1055,6 +1065,21 @@ private void assertEmission() { String lineController = contentOf("gen/emission/api/entry/EntryLineController.java"); assertTrue(lineController.contains("Exactly one of debit/credit"), "checks: exactlyOne must emit its authored message into the row-level REST validation"); + // #6695: the master's immutability reaches its LINES. A child declares no immutability of its + // own, yet its writes recompute the master's totals - so without this the lock had an + // unguarded back door through the child's controller, which the UI never offers but REST did. + assertTrue(lineController.contains("requireMasterMutable"), + "a composition child of an immutable master must emit the inherited lock into its REST controller"); + assertTrue(lineController.contains("EntryRepository masterRepository"), + "the inherited lock must consult the MASTER's repository, got: " + lineController); + // The document's own line items are the same story through a different layout - and it is the + // one where the child literally resums the master (BillLineRepository -> BillRepository). + assertTrue(contentOf("gen/emission/api/bill/BillLineController.java").contains("requireMasterMutable"), + "a document ITEM of an immutable master must emit the inherited lock into its REST controller"); + // ...and the opt-out really opts out: the settlement-style collection keeps its writes. + String campaignNoteController = contentOf("gen/emission/api/campaign/CampaignNoteController.java"); + assertFalse(campaignNoteController.contains("requireMasterMutable"), + "locksWithMaster: false must leave the child's REST writes unguarded"); String schema = contentOf("gen/emission/schema/" + PROJECT + ".schema"); assertTrue(schema.contains("EMISSION_UNIT_LANG"), "multilingual must emit the _LANG translation table into the schema"); @@ -2082,12 +2107,15 @@ private void assertRuntimeEnforcement() { .statusCode(400)); // Balance the entry -> POSTED is accepted... - restAssuredExecutor.execute(() -> given().contentType("application/json") - .body("{\"Entry\":" + entryId + ",\"Credit\":100}") - .when() - .post(API + "/entry/EntryLineController") - .then() - .statusCode(200)); + AtomicInteger creditLine = new AtomicInteger(); + restAssuredExecutor.execute(() -> creditLine.set(given().contentType("application/json") + .body("{\"Entry\":" + entryId + ",\"Credit\":100}") + .when() + .post(API + "/entry/EntryLineController") + .then() + .statusCode(200) + .extract() + .path("Id"))); restAssuredExecutor.execute(() -> given().contentType("application/json") .body("{\"Id\":" + entryId + ",\"Date\":\"2026-01-15\",\"Account\":2,\"Status\":2}") .when() @@ -2114,6 +2142,65 @@ private void assertRuntimeEnforcement() { .statusCode(200) .body("mutable", equalTo(false))); + // ...and so are the document's LINES (#6695): a line write recomputes the master's totals, so + // the lock has to reach the child's own controller too - otherwise the one operation the lock + // exists to prevent stays reachable, and rewrites the totals after the ledger posted from them. + int lineId = creditLine.get(); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Entry\":" + entryId + ",\"Debit\":50}") + .when() + .post(API + "/entry/EntryLineController") + .then() + .statusCode(409)); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Id\":" + lineId + ",\"Entry\":" + entryId + ",\"Credit\":500}") + .when() + .put(API + "/entry/EntryLineController/" + lineId) + .then() + .statusCode(409)); + restAssuredExecutor.execute(() -> given().when() + .delete(API + "/entry/EntryLineController/" + lineId) + .then() + .statusCode(409)); + // ...and the collection is exactly as the entry left it - the refusals wrote nothing. + restAssuredExecutor.execute(() -> given().when() + .get(API + "/entry/EntryLineController?Entry=" + entryId) + .then() + .statusCode(200) + .body("$", hasSize(2)) + .body("find { it.Id == " + lineId + " }.Credit", equalTo(100.0f))); + + // ...while the collection that declared `locksWithMaster: false` keeps its writes past the + // master's lock - the settlement case (#6700), which is why the inherited lock has an opt-out + // rather than being unconditional. + AtomicInteger campaign = new AtomicInteger(); + restAssuredExecutor.execute(() -> campaign.set(given().contentType("application/json") + .body("{\"Name\":\"Spring\"}") + .when() + .post(API + "/campaign/CampaignController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Id\":" + campaign.get() + ",\"Name\":\"Spring\",\"Status\":2}") + .when() + .put(API + "/campaign/CampaignController/" + campaign.get()) + .then() + .statusCode(200)); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Id\":" + campaign.get() + ",\"Name\":\"Summer\",\"Status\":2}") + .when() + .put(API + "/campaign/CampaignController/" + campaign.get()) + .then() + .statusCode(409)); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Campaign\":" + campaign.get() + ",\"Note\":\"called the sponsor\"}") + .when() + .post(API + "/campaign/CampaignNoteController") + .then() + .statusCode(200)); + // history: the whole life of the record is readable from one endpoint - the create, and the // status hop the user made with both sides of it recorded, so "who changed this from what" // has an answer. The audit columns and the key stay out: they restate what the entry carries. @@ -2254,6 +2341,25 @@ private void assertRuntimeEnforcement() { .then() .statusCode(200) .body("Status", equalTo(2))); + // ...and the send froze the document, LINES INCLUDED (#6695). This is the whole point of the + // inherited lock: a line write recomputes the header's totals, so accepting one here would + // move the amount the mailed PDF was rendered from, on a document nobody may edit any more. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Amount\":999,\"Bill\":" + bill.get() + "}") + .when() + .post(API + "/bill/BillLineController") + .then() + .statusCode(409)); + restAssuredExecutor.execute(() -> { + Object amount = given().when() + .get(API + "/bill/BillController/" + bill.get()) + .then() + .statusCode(200) + .extract() + .path("Amount"); + assertTrue(amount instanceof Number && Math.abs(((Number) amount).doubleValue() - 250.0) < 0.001, + "a refused line write must leave the locked document's total untouched, got: " + amount); + }); // postings: posting a Doc creates the balanced Entry (async handler - poll)... AtomicInteger doc = new AtomicInteger();