From 66a250a0f1cf70ad0891aa1abb315b2b7400f0d7 Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 13 Aug 2026 10:07:47 +0300 Subject: [PATCH] feat(intent): a child collection can outlive its master's lock An issued invoice could not be settled by hand. `immutableWhen` on the master put the whole document page into read-only mode, and the generated UI extended that lock over every CHILD PANEL - so the Add button and the row actions on "Sales Invoice Customer Payments" existed only while the invoice was DRAFT. Manual allocation was therefore unavailable in exactly the state where allocations mean something, and a payment record has no allocation screen of its own, so there was no other route. The UI was enforcing a rule the model never declared. Verified against a live instance: with the invoice ISSUED (`/{id}/mutable` false, a header PUT 409), POSTing an allocation to the child controller returns 200, the Paid/Balance roll-up recomputes (0 -> 20, balance 80) and the status advances to PARTIAL. The child is a different entity with its own controller and no immutability of its own; only the affordance was missing. `locksWithMaster: false` on a composition child says so in the model: the master's lock covers the document's own content, this collection keeps its user-write affordances. Settlement is a different lifecycle from content - an issued invoice's lines are frozen, money keeps arriving against it for months. Parser validates both halves rather than ignoring a misplaced flag: it must be a composition child, and its master must actually lock. An inert declaration reads exactly like a working one, and the author would only discover it when the affordance was still missing in production. Emitted (and consumed) only when false, so every existing model regenerates byte-identically. Relaxes the three gates on the document view and the same three on the manage form view; a document's own line items are unaffected - they render in the items pane, not a child panel, and stay locked. Tests: EdmIntentGeneratorTest asserts the marker lands on the child and on neither the master nor the line items; IntentParserTest covers both rejections and the valid shape. Full engine-intent suite 148 green. The registry emission was rendered through a real Velocity engine both ways (absent -> nothing, false -> `locksWithMaster: false`). Co-Authored-By: Claude Opus 5 --- .../generator/edm/EdmIntentGenerator.java | 8 ++ .../components/intent/model/EntityIntent.java | 25 ++++++ .../intent/parser/IntentParser.java | 30 +++++++ .../main/resources/intent-assistant-guide.md | 12 +++ .../generator/edm/EdmIntentGeneratorTest.java | 47 ++++++++++ .../intent/parser/IntentParserTest.java | 88 +++++++++++++++++++ .../document/document-view.html.template | 6 +- .../manage/form-view.html.template | 6 +- .../master/detail-register.js.template | 7 ++ 9 files changed, 223 insertions(+), 6 deletions(-) diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index 2e1c363eec..95db243027 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -403,6 +403,14 @@ else if (!extension && !dependent && !setting && !compositionParents.containsVal entityMap.put("attachmentReadOnly", "true"); } } + // A child collection that does NOT freeze with its master. The master's immutability locks + // the document's own content; this child is a different entity with its own controller, + // which already accepts the writes - only the generated UI was extending the master's lock + // over it. Emitted (and consumed by the detail registration) only when false, so a model + // that says nothing keeps byte-identical output. + if (!entity.locksWithMaster()) { + entityMap.put("locksWithMaster", "false"); + } // Custom Java imports for the generated entity Repository (e.g. a calculated-field action's // CalculatedField class). Base64-encoded to match the EDM editor's serialization, which the // DAO template's parameterUtils decodes before emitting them into the import block. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java index b6aa5f7693..5cc27080c3 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java @@ -149,6 +149,15 @@ public class EntityIntent { * {@code immutableWhen} - always-immutable subsumes any status scope. */ private Boolean immutable; + /** + * Whether this composition child freezes together with its master ({@code locksWithMaster}, default + * true). A master's {@code immutableWhen} locks the document's own CONTENT; it says nothing about a + * child collection that is a different entity with its own controller and its own rules. Declaring + * {@code locksWithMaster: false} keeps this collection's user-write affordances alive while the + * master is locked - the settlement case: an issued invoice's lines are frozen, but the payment + * allocations against it go on being recorded for months. + */ + private Boolean locksWithMaster; /** * Optional hierarchy declaration: names this entity's to-one SELF-relation that forms the tree edge * (e.g. {@code hierarchy: Parent} on a chart-of-accounts Account). The generated list renders as a @@ -484,6 +493,22 @@ public Boolean getDuplicable() { return duplicable; } + /** + * Whether this child collection freezes when its master becomes immutable. Defaults to TRUE, so an + * entity that says nothing keeps the behaviour it has always had. + */ + public boolean locksWithMaster() { + return !Boolean.FALSE.equals(locksWithMaster); + } + + public Boolean getLocksWithMaster() { + return locksWithMaster; + } + + public void setLocksWithMaster(Boolean locksWithMaster) { + this.locksWithMaster = locksWithMaster; + } + public void setDuplicable(Boolean duplicable) { this.duplicable = duplicable; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index a12c2593c3..0ef4618313 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -247,6 +247,7 @@ private static void validateFunctions(IntentModel model, List issues) { } } validateSnapshotLanguage(entity, model, compositionParent, issues); + validateLocksWithMaster(entity, model, compositionParent, issues); for (FieldIntent field : entity.getFields()) { String ff = field.getFunction(); if (ff != null && !ff.isBlank() && !FIELD_FUNCTIONS.contains(ff.trim() @@ -1417,6 +1418,35 @@ private static void validateNotifyBlock(NotificationIntent notify, String subjec * document whose copy is minted) - mutually exclusive, meaningless anywhere else. Absent both, the * mint falls back to the first entry of the tenant-resolved application language set at run time. */ + /** + * Validate {@code locksWithMaster: false} - the declaration that a child collection does NOT freeze + * when its master becomes immutable (the settlement case: an issued invoice's lines are frozen, its + * payment allocations are not). It is only meaningful on a composition child OF a master that + * actually locks, so both are required rather than silently ignored: an inert declaration reads as + * a working one, and the author only finds out when the affordance is still missing in production. + */ + private static void validateLocksWithMaster(EntityIntent entity, IntentModel model, Map compositionParent, + List issues) { + if (entity.locksWithMaster()) { + return; + } + String name = entity.getName(); + String master = compositionParent.get(name); + if (master == null) { + issues.add("entity [" + name + "] declares locksWithMaster: false but is not a composition child" + + " - only a child collection can outlive its master's lock"); + return; + } + EntityIntent parent = entityByName(model, master); + boolean masterLocks = parent == null || Boolean.TRUE.equals(parent.getImmutable()) + || (parent.getImmutableWhen() != null && !parent.getImmutableWhen() + .isBlank()); + if (!masterLocks) { + issues.add("entity [" + name + "] declares locksWithMaster: false but its master [" + master + + "] never locks (no immutableWhen / immutable) - the declaration would have no effect"); + } + } + private static void validateSnapshotLanguage(EntityIntent entity, IntentModel model, Map compositionParent, List issues) { boolean hasLanguage = entity.getLanguage() != null && !entity.getLanguage() 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 7d96ae8879..3117238b78 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 @@ -227,6 +227,18 @@ field may declare: snapshot entity (e.g. the frozen copy stored when an invoice is SENT): written once by the flow, never editable. System writes through the repository stay possible. Mutually exclusive with `immutableWhen` (always-immutable subsumes any status scope); needs no EntityStatus relation. +- `locksWithMaster: false` (entity-level, on a **composition child**) - **this collection does not + freeze with its master**. A master's immutability locks the document's own CONTENT; a child + collection is a different entity with its own controller and its own rules, and the generated UI + used to extend the master's lock over it - hiding Add and the row actions on every child panel of + a locked document. Declare it on the child that must go on being recorded: the canonical case is + **payment allocations**, where an issued invoice's lines are frozen but money keeps arriving + against it for months (settlement is a different lifecycle from content). The server already + permitted these writes - only the affordance was missing. Applies to a child rendered as its own + **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. - `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/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index 2e75bc8177..79c1ee5c61 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java @@ -1626,4 +1626,51 @@ void fieldFormatEmailEmitsTheEmailWidgetAndTheCanonicalPattern() { assertEquals("TEXTBOX", propertyByName(contact, "Note").get("widgetType")); assertNull(propertyByName(contact, "Note").get("widgetPattern")); } + + /** + * A child collection that does not freeze with its master carries the marker the detail + * registration reads; every other entity keeps byte-identical output (the attribute is emitted only + * when declared false). + */ + @Test + void locksWithMasterFalseMarksTheChildThatOutlivesItsMastersLock() { + String yaml = """ + name: sales + entities: + - name: Invoice + function: Document + immutableWhen: "Status == 3" + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, length: 50 } + - name: InvoiceItem + function: DocumentItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + - name: InvoiceAllocation + locksWithMaster: false + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + """; + IntentModel parsed = IntentParser.parse(yaml); + Map model = EdmIntentGenerator.buildModelJsonForTest(parsed, "sales"); + List> entities = entities(model); + + assertEquals("false", entityByName(entities, "InvoiceAllocation").get("locksWithMaster")); + // The document's own line items keep freezing with it - that is what immutability is for. + assertNull(entityByName(entities, "InvoiceItem").get("locksWithMaster")); + assertNull(entityByName(entities, "Invoice").get("locksWithMaster")); + } } diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java index c7fdc47ec9..c33b3e6c68 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java @@ -10,6 +10,7 @@ package org.eclipse.dirigible.components.intent.parser; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -2299,4 +2300,91 @@ void parallelUnknownNextIsRejected() { .anyMatch(i -> i.contains("next [nowhere] is not a declared step or `end`")), "expected an unknown-next issue, got: " + ex.getIssues()); } + + /** + * `locksWithMaster: false` only means something on a composition child - a top-level entity has no + * master whose lock it could outlive, so the declaration is rejected instead of silently ignored. + */ + @Test + void locksWithMasterIsRejectedOnAnEntityThatIsNotACompositionChild() { + String yaml = """ + name: sales + entities: + - name: Invoice + immutableWhen: "Status == 3" + locksWithMaster: false + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("not a composition child")), + "a non-child locksWithMaster should be rejected, got: " + ex.getIssues()); + } + + /** + * A master that never locks makes the declaration inert. That is the + * authored-but-silently-unconsumed failure mode, so it fails at authoring time rather than in + * production. + */ + @Test + void locksWithMasterIsRejectedWhenTheMasterNeverLocks() { + String yaml = """ + name: sales + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - name: InvoiceAllocation + locksWithMaster: false + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("never locks")), + "an inert locksWithMaster should be rejected, got: " + ex.getIssues()); + } + + /** The valid shape parses: a composition child of a master that does lock. */ + @Test + void locksWithMasterParsesOnACompositionChildOfALockingMaster() { + String yaml = """ + name: sales + entities: + - name: Invoice + immutableWhen: "Status == 3" + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - name: InvoiceAllocation + locksWithMaster: false + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + """; + IntentModel model = IntentParser.parse(yaml); + assertFalse(model.getEntities() + .stream() + .filter(e -> "InvoiceAllocation".equals(e.getName())) + .findFirst() + .orElseThrow() + .locksWithMaster()); + } } diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template index 1acb7d6c38..29b6ec433c 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template @@ -329,7 +329,7 @@

-
+
@@ -401,7 +401,7 @@ - + @@ -415,7 +415,7 @@ - + diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template index 127456c75d..24a6e78ab2 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template @@ -227,7 +227,7 @@

-
+
@@ -299,7 +299,7 @@ - + @@ -313,7 +313,7 @@ - +
  • diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template index 1823758b99..2943d3a73e 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template @@ -56,6 +56,13 @@ App.registerDetail('${masterEntity}', { // uploaded files with download, and — unless read-only — an upload control + per-file remove. // Read-only (a generated Snapshot child) shows download only. See detailPanel `files`. files: { readOnly: #if($attachmentReadOnly)true#{else}false#end }, +#end +#if($locksWithMaster == "false") + // This collection does NOT freeze with its master (intent `locksWithMaster: false`): the master's + // immutability locks the document's own content, while these rows go on being recorded against it + // (an issued invoice's payment allocations). The master's read-only mode therefore does not hide + // this panel's Add / row actions. The server always allowed it - the child has its own controller. + locksWithMaster: false, #end columns: [ #foreach($property in $properties)