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
2 changes: 1 addition & 1 deletion components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: <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 @@ -73,6 +73,7 @@ static void process(Map<String, Object> model, Map<String, Object> parameters) {
processEntity(entity, entities, parameters);
}
if (truthy(parameters, "javaRuntime")) {
inheritMasterLock(entities, parameters);
inheritPersonalScope(entities, parameters);
inheritPartnerScope(entities, parameters);
collectSensitiveProperties(entities);
Expand Down Expand Up @@ -480,6 +481,59 @@ private static void resolveDropdown(Map<String, Object> property, Map<String, Ob
property.put("widgetDropdownAppUrl", "/services/web/" + targetProject + "/gen/" + targetGenFolder + "/index.html");
}

/**
* Propagates a composition master's immutability to its direct children, so that the REST surface
* forbids what the generated UI already withholds.
*
* <p>
* 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.
*
* <p>
* 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<Map<String, Object>> entities, Map<String, Object> parameters) {
for (Map<String, Object> entity : entities) {
if ("false".equals(str(entity, "locksWithMaster"))) {
continue;
}
Map<String, Object> parentFk = findCompositionProperty(entity);
if (parentFk == null) {
continue;
}
Map<String, Object> 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<String, Object> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> master = entity("Invoice", "Invoices", property("Id", "INTEGER"));
master.put("immutableStatusProperty", "Status");
master.put("immutableStatusValues", "2,3");
Map<String, Object> child = entity("InvoiceItem", "Invoices", compositionTo("Invoice", "Invoices"));

ModelParameterProcessor.process(model(master, child), javaParameters());

Map<String, Object> 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<String, Object> master = entity("Invoice", "Invoices", property("Id", "INTEGER"));
master.put("immutableAlways", "true");
Map<String, Object> 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<String, Object> master = entity("Invoice", "Invoices", property("Id", "INTEGER"));
master.put("immutableStatusProperty", "Status");
master.put("immutableStatusValues", "2");
Map<String, Object> 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<String, Object> master = entity("Invoice", "Invoices", property("Id", "INTEGER"));
Map<String, Object> child = entity("InvoiceItem", "Invoices", compositionTo("Invoice", "Invoices"));

ModelParameterProcessor.process(model(master, child), javaParameters());

assertNull(child.get("masterLock"));
}

/**
* Builds a model around the given entities.
*
Expand Down Expand Up @@ -338,6 +399,46 @@ private static Map<String, Object> 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<String, Object> masterLock(Map<String, Object> entity) {
return (Map<String, Object>) 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<String, Object> compositionTo(String master, String masterPerspective) {
Map<String, Object> 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<String, Object> javaParameters() {
Map<String, Object> parameters = parameters();
parameters.put("javaRuntime", Boolean.TRUE);
return parameters;
}

/**
* Builds the parameters a request would carry.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,15 @@ public class ${name}Controller {
private static final Set<String> 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
Expand Down Expand Up @@ -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
Expand All @@ -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}/<yyyy>/<MM>/<uuid>/<file> (the multipart/FileItem handling stays inside
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading