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 @@ -11,7 +11,7 @@
"supportLevel": "Stable",
"groupId": "org.eclipse.dirigible",
"artifactId": "dirigible-components-engine-camel",
"version": "14.0.0-SNAPSHOT",
"version": "15.0.0-SNAPSHOT",
"scheme": "dirigible-java-script",
"extendsScheme": "",
"syntax": "dirigible-java-script:javaScriptPath",
Expand Down
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 @@ -702,17 +702,45 @@ static List<Map<String, Object>> buildGeneratesForTest(IntentModel model) {
private static List<Map<String, Object>> buildPostings(IntentModel model, Map<String, EntityIntent> byName,
Map<String, String> compositionParents, IntentSettings settings, IntentGenerationContext context) {
List<Map<String, Object>> out = new ArrayList<>();
// A reversal posting's storno link doubles as the discriminator between the reversed
// sibling's own documents (link empty) and reversals (link set) - the SIBLING's handler
// must filter its idempotency lookup by it too, so map: base posting name -> storno.
Map<String, String> stornoOfReversed = new LinkedHashMap<>();
for (org.eclipse.dirigible.components.intent.model.PostingIntent posting : model.getPostings()) {
if (posting.getReverses() != null && !posting.getReverses()
.isBlank()
&& posting.getStorno() != null) {
stornoOfReversed.put(posting.getReverses(), IntentNaming.pascalCase(posting.getStorno()));
}
}
for (org.eclipse.dirigible.components.intent.model.PostingIntent posting : model.getPostings()) {
boolean isReverse = posting.getReverses() != null && !posting.getReverses()
.isBlank();
// Reversal mode: creates/backReference/rule/map/items come from the reversed sibling;
// the reversal contributes its own event plus the storno link, and every item amount
// expression is negated (same sides - red storno).
org.eclipse.dirigible.components.intent.model.PostingIntent effective = posting;
if (isReverse) {
for (org.eclipse.dirigible.components.intent.model.PostingIntent candidate : model.getPostings()) {
if (candidate != posting && posting.getReverses()
.equals(candidate.getName())) {
effective = candidate;
}
}
if (effective == posting || posting.getStorno() == null) {
continue; // parser already reported it
}
}
if (posting.getName() == null || posting.getName()
.isBlank()
|| posting.getEvent() == null || posting.getCreates() == null) {
|| posting.getEvent() == null || effective.getCreates() == null) {
continue; // parser already reported it
}
if (!settings.shouldGenerate("postings", posting.getName())) {
LOGGER.info("Settings opt-out: keeping existing handler for posting [{}] (not generated)", posting.getName());
continue;
}
EntityIntent creates = byName.get(posting.getCreates());
EntityIntent creates = byName.get(effective.getCreates());
EntityIntent itemsEntity = creates == null ? null : compositionChild(creates, byName);
if (creates == null || itemsEntity == null) {
continue; // parser already reported it
Expand Down Expand Up @@ -769,22 +797,27 @@ private static List<Map<String, Object>> buildPostings(IntentModel model, Map<St
e.put("itemsEntity", itemsEntity.getName());
e.put("itemsPerspective", IntentEntities.resolvePerspective(itemsEntity.getName(), compositionParents));
e.put("itemsFk", IntentNaming.pascalCase(creates.getName()));
e.put("backRefProperty", IntentNaming.pascalCase(posting.getBackReference()));
e.put("backRefProperty", IntentNaming.pascalCase(effective.getBackReference()));
// Reversal coordinates: the reversal handler locates the original through the empty
// storno link and stamps it on its own creation; the reversed sibling's handler filters
// reversals OUT of its idempotency lookup through the same property.
e.put("stornoProperty", isReverse ? IntentNaming.pascalCase(posting.getStorno()) : "");
e.put("stornoFilterProperty", isReverse ? "" : stornoOfReversed.getOrDefault(posting.getName(), ""));
// Rule lookup: a single match selector, columns referenced from the items.
boolean hasRule = posting.getRule() != null && posting.getRule()
.get("entity") != null;
boolean hasRule = effective.getRule() != null && effective.getRule()
.get("entity") != null;
e.put("hasRule", hasRule);
java.util.Set<String> usedRuleColumns = new java.util.LinkedHashSet<>();
if (hasRule) {
String ruleEntityName = String.valueOf(posting.getRule()
.get("entity"));
String ruleEntityName = String.valueOf(effective.getRule()
.get("entity"));
e.put("ruleEntity", ruleEntityName);
// A setting rule entity (the normal case) lives under the global Settings perspective.
EntityIntent ruleEntityIntent = byName.get(ruleEntityName);
e.put("rulePerspective", ruleEntityIntent != null && ruleEntityIntent.isSetting() ? "Settings"
: IntentEntities.resolvePerspective(ruleEntityName, compositionParents));
Map<?, ?> match = (Map<?, ?>) posting.getRule()
.get("match");
Map<?, ?> match = (Map<?, ?>) effective.getRule()
.get("match");
Map.Entry<?, ?> selector = match.entrySet()
.iterator()
.next();
Expand All @@ -793,16 +826,16 @@ private static List<Map<String, Object>> buildPostings(IntentModel model, Map<St
}
// Header assignments: copy / literal / {placeholder} template - pre-rendered Java.
List<Map<String, Object>> headerAssignments = new ArrayList<>();
if (posting.getMap() != null) {
for (Map.Entry<String, String> entry : posting.getMap()
.entrySet()) {
if (effective.getMap() != null) {
for (Map.Entry<String, String> entry : effective.getMap()
.entrySet()) {
headerAssignments.add(postingAssignment(entry.getKey(), entry.getValue()));
}
}
e.put("headerAssignments", headerAssignments);
// Item rows: rule(...) refs read the rule row; expressions run through Calc on the source.
List<Map<String, Object>> itemRows = new ArrayList<>();
for (Map<String, String> row : posting.getItems() == null ? List.<Map<String, String>>of() : posting.getItems()) {
for (Map<String, String> row : effective.getItems() == null ? List.<Map<String, String>>of() : effective.getItems()) {
Map<String, Object> rendered = new LinkedHashMap<>();
List<Map<String, Object>> assigns = new ArrayList<>();
String rowGuard = "";
Expand Down Expand Up @@ -832,7 +865,9 @@ private static List<Map<String, Object>> buildPostings(IntentModel model, Map<St
} else {
FieldIntent target = fieldOf(itemsEntity, cell.getKey());
int scale = target != null && target.getScale() != null ? target.getScale() : 2;
assign.put("expr", "Calc.eval(\"" + value.replace("\"", "\\\"") + "\", source, " + scale + ")");
// Reversal: the SAME expression negated on the SAME side (red storno).
String expr = isReverse ? "-(" + value + ")" : value;
assign.put("expr", "Calc.eval(\"" + expr.replace("\"", "\\\"") + "\", source, " + scale + ")");
}
assigns.add(assign);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,19 @@ private static String buildWhere(IntentGenerationContext context, IntentModel mo
Matcher.quoteReplacement(baseAlias + "." + quote(column(source.getName(), field.getName()))));
}
}
// A bare to-one RELATION name filters by its FK column (`Status != 8` -> the status FK
// id column) - previously it passed through untranslated and broke the generated SQL.
// The negative lookahead skips join-alias usages (`Customer."CUSTOMER_NAME"` from the
// dotted-ref pass above); the lookbehind skips already-qualified column tokens.
if (source.getRelations() != null) {
for (RelationIntent relation : source.getRelations()) {
if (relation.getName() != null && !relation.getName()
.isBlank()) {
where = where.replaceAll("(?<![.\"\\w])" + Pattern.quote(relation.getName()) + "\\b(?!\\s*[.\"])",
Matcher.quoteReplacement(baseAlias + "." + quote(column(source.getName(), relation.getName()))));
}
}
}
// Authors used to the intent's guard syntax write `Status == 2`; SQL equality is a single
// `=` (H2 tolerates `==`, PostgreSQL rejects it), so normalize. `<=`/`>=`/`!=` are untouched.
// Normalize only OUTSIDE single-quoted string literals so a value literal that itself contains
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,37 @@ public class PostingIntent {
* {@code == 0} on a source field).
*/
private List<Map<String, String>> items;
/**
* Reversal (red storno) mode: names a SIBLING posting in this block whose created document this
* posting reverses. The reversal re-derives the sibling's header and items from the source with
* every amount expression NEGATED on the SAME side (never swapped - turnovers stay honest), links
* {@link #storno} to the original, and skips fail-soft when no original exists (the source was
* never posted). {@code creates}/{@code backReference}/{@code rule}/{@code map}/ {@code items} are
* inherited from the sibling and must not be declared here.
*/
private String reverses;
/**
* The created entity's to-one SELF-relation linked to the reversed (original) document - required
* with {@link #reverses}. Doubles as the discriminator between the sibling's own documents (link
* empty) and reversals (link set) for both handlers' idempotency guards.
*/
private String storno;

public String getReverses() {
return reverses;
}

public void setReverses(String reverses) {
this.reverses = reverses;
}

public String getStorno() {
return storno;
}

public void setStorno(String storno) {
this.storno = storno;
}

public String getName() {
return name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2280,6 +2280,47 @@ private static void validatePostings(IntentModel model, Set<String> usesAliases,
issues.add(subject + " event requires `when: \"<Property> == <status seed id>\"`");
}
}
// Reversal mode: creates/backReference/rule/map/items are inherited from the reversed
// sibling; the reversal declares only its own event + the storno self-link.
if (posting.getReverses() != null && !posting.getReverses()
.isBlank()) {
PostingIntent sibling = null;
for (PostingIntent candidate : model.getPostings()) {
if (candidate != posting && posting.getReverses()
.equals(candidate.getName())) {
sibling = candidate;
}
}
if (sibling == null) {
issues.add(subject + " reverses unknown posting [" + posting.getReverses() + "] - it must name a sibling"
+ " posting in this block");
continue;
}
if (posting.getCreates() != null || posting.getBackReference() != null || posting.getRule() != null
|| posting.getMap() != null || (posting.getItems() != null && !posting.getItems()
.isEmpty())) {
issues.add(subject + " is a reversal - creates/backReference/rule/map/items are inherited from ["
+ posting.getReverses() + "] and must not be declared");
}
EntityIntent reversed = sibling.getCreates() == null ? null : byName.get(sibling.getCreates());
if (posting.getStorno() == null || posting.getStorno()
.isBlank()) {
issues.add(subject + " requires `storno: <self relation>` - the created entity's link to the reversed document");
} else if (reversed != null) {
RelationIntent storno = toOneRelationByName(reversed, posting.getStorno());
if (storno == null || !reversed.getName()
.equals(storno.getTo())
|| storno.isCrossModel()) {
issues.add(subject + " storno [" + posting.getStorno() + "] must be a to-one SELF-relation of ["
+ reversed.getName() + "]");
}
}
continue;
}
if (posting.getStorno() != null && !posting.getStorno()
.isBlank()) {
issues.add(subject + " declares storno without reverses - the storno link belongs to the reversal posting");
}
// creates + items child + backReference
EntityIntent creates = posting.getCreates() == null ? null : byName.get(posting.getCreates());
if (creates == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,21 @@ composition is opt-in.
A missing rule row or null referenced column SKIPS the posting (the unposted worklist = final-status
documents with no back-referencing target), never throws. All writes go through the generated
repositories, so numbering/status-init/`checks:` fire on the created document.
**Reversal mode (red storno):** a posting with `reverses: <sibling posting name>` undoes the
sibling's document when the source is voided/cancelled - pair it with a `transitions:` void:
```yaml
- name: invoiceStorno
event: { onTransition: SalesInvoice, model: kf-billing, when: "Status == 8" } # the void status
reverses: salesInvoicePosting # sibling posting in this block
storno: Storno # the created entity's to-one SELF-relation to the original
```
`creates`/`backReference`/`rule`/`map`/`items` are inherited from the sibling and must not be
declared. Semantics: locate the ORIGINAL (back-reference = this source, storno link empty) - none
-> skip fail-soft; create the negated copy (every item amount expression negated on the SAME
debit/credit side - never swapped) with the `storno` link stamped; idempotent (rows carrying the
link are the reversal's own; the sibling's guard symmetrically counts only rows without it). The
reversal lands as a normal new document (DRAFT status init, numbering, checks), dated by its own
`map`-inherited header - corrections post into the open period.
- `calculatedOnCreate` / `calculatedOnUpdate` - an expression the generated repository assigns to the
property on insert / update. Prefer a **neutral arithmetic expression** for numeric totals
(`"Quantity * Price"`, `"round(Net * 0.2, 2)"`) - the SDK `Calc` evaluator runs it on the server and
Expand Down
Loading
Loading