Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ private static void validateFunctions(IntentModel model, List<String> 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()
Expand Down Expand Up @@ -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<String, String> compositionParent,
List<String> 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<String, String> compositionParent,
List<String> issues) {
boolean hasLanguage = entity.getLanguage() != null && !entity.getLanguage()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: <RelationName>` (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: <SameEntity> }`). The generated list renders as an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> model = EdmIntentGenerator.buildModelJsonForTest(parsed, "sales");
List<Map<String, Object>> 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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@
<div x-data="detailPanel({...d, returnTo: '/${name}/' + id + (isPreview ? '/preview' : '/edit')}, id)" x-h-card class="w-full">
<div x-h-card-header>
<h3 x-h-card-title x-text="T(d.tkey, d.label)"></h3>
<div x-h-card-action x-show="masterId && !isPreview && !def.files">
<div x-h-card-action x-show="masterId && (!isPreview || def.locksWithMaster === false) && !def.files">
<button x-h-button data-variant="primary" data-size="sm" @click="addRow()"><i role="img" x-h-lucide data-lucide="plus"></i><span x-text="T('$projectName:${tprefix}.defaults.add', 'Add')"></span></button>
</div>
</div>
Expand Down Expand Up @@ -401,7 +401,7 @@
<template x-for="col in def.columns" :key="col.name">
<th x-h-table-head scope="col" :class="col.number ? 'text-right' : ''" x-text="T(col.tkey, col.label || col.name)"></th>
</template>
<th x-h-table-head scope="col" x-show="!isPreview"></th>
<th x-h-table-head scope="col" x-show="!isPreview || def.locksWithMaster === false"></th>
</tr>
</thead>
<tbody x-h-table-body>
Expand All @@ -415,7 +415,7 @@
</template>
</td>
</template>
<td x-h-table-cell data-row-actions x-show="!isPreview">
<td x-h-table-cell data-row-actions x-show="!isPreview || def.locksWithMaster === false">
<button x-h-button data-variant="transparent" data-size="sm" aria-label="Delete" @click="askDelete(row)"><i role="img" x-h-lucide data-lucide="trash-2"></i></button>
</td>
</tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@
<div x-data="detailPanel({...d, returnTo: '/${name}/' + id + (isPreview ? '/preview' : '/edit')}, id)" x-h-card>
<div x-h-card-header>
<h3 x-h-card-title x-text="d.label"></h3>
<div x-h-card-action x-show="!isPreview && !def.files">
<div x-h-card-action x-show="(!isPreview || def.locksWithMaster === false) && !def.files">
<button type="button" x-h-button data-variant="primary" data-size="sm" @click="addRow()"><i role="img" x-h-lucide data-lucide="plus"></i><span x-text="T('$projectName:${tprefix}.defaults.add', 'Add')"></span></button>
</div>
</div>
Expand Down Expand Up @@ -299,7 +299,7 @@
<template x-for="col in def.columns" :key="col.name">
<th x-h-table-head scope="col" :class="col.number ? 'text-right' : ''" x-text="T(col.tkey, col.label || col.name)"></th>
</template>
<th x-h-table-head scope="col" x-show="!isPreview"></th>
<th x-h-table-head scope="col" x-show="!isPreview || def.locksWithMaster === false"></th>
</tr>
</thead>
<tbody x-h-table-body>
Expand All @@ -313,7 +313,7 @@
</template>
</td>
</template>
<td x-h-table-cell data-row-actions x-show="!isPreview">
<td x-h-table-cell data-row-actions x-show="!isPreview || def.locksWithMaster === false">
<button type="button" x-h-menu-trigger.dropdown x-h-table-cell-button aria-label="Row actions"><i role="img" x-h-lucide data-lucide="ellipsis"></i></button>
<ul x-h-menu data-align="bottom-end">
<li x-h-menu-item @click="previewRow(row)" x-text="T('$projectName:${tprefix}.defaults.preview', 'Preview')"></li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading