diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 6f14b5c568..1d662e4577 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -773,6 +773,53 @@ static List> buildTransitionsForTest(IntentModel model) { return buildTransitions(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}")); } + /** + * Build the {@code posts} glue collection: one descriptor per {@code posts:} rule + * ({@link org.eclipse.dirigible.components.intent.model.PostIntent}). Each descriptor drives a + * generated event handler that, on the source document's {@code event:} event, emits mapped rows + * into the {@code into:} target (per {@code forEach:} line item), idempotently by + * {@code idempotentBy}. See kf-catalog PROPOSAL_EVENT_POSTING.md. This is the structural glue; the + * handler template + BPMN/listener wiring is the next stage. + */ + private static List> buildPosts(IntentModel model, Map byName) { + List> out = new ArrayList<>(); + for (org.eclipse.dirigible.components.intent.model.PostIntent p : model.getPosts()) { + if (p.getName() == null || p.getName() + .isBlank() + || p.getForEntity() == null || p.getInto() == null || p.getEvent() == null) { + continue; // malformed: name/forEntity/into/on are required + } + if (byName.get(p.getForEntity()) == null) { + continue; // bad source reference + } + Map e = new LinkedHashMap<>(); + e.put("name", p.getName()); + e.put("className", IntentNaming.pascalIdentifier(p.getName())); + e.put("entity", p.getForEntity()); + e.put("event", p.getEvent()); + e.put("forEach", p.getForEach() == null ? "" : p.getForEach()); + e.put("into", p.getInto()); + e.put("idempotentBy", p.getIdempotentBy() == null ? "" : p.getIdempotentBy()); + e.put("guard", p.getGuard() == null ? "" : p.getGuard()); + List> set = new ArrayList<>(); + for (Map.Entry f : p.getSet() + .entrySet()) { + Map pair = new LinkedHashMap<>(); + pair.put("field", IntentNaming.pascalCase(f.getKey())); + pair.put("value", f.getValue()); + set.add(pair); + } + e.put("set", set); + out.add(e); + } + return out; + } + + /** Test hook: build the {@code posts} glue collection without a repository. */ + static List> buildPostsForTest(IntentModel model) { + return buildPosts(model, IntentEntities.byName(model)); + } + /** * Test hook: build the {@code generates} glue collection without a repository. With a null context * a cross-model target falls back to {@link CrossModelSupport}'s naming-convention defaults diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java index 5cbe739f76..cafa225e68 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java @@ -67,11 +67,23 @@ public class IntentModel { private List postings = new ArrayList<>(); /** Declarative on-demand status transitions - guarded per-record buttons (void/cancel/close). */ private List transitions = new ArrayList<>(); + /** + * Declarative event-driven row posts - emit mapped rows into a target entity on a document event. + */ + private List posts = new ArrayList<>(); public List getActions() { return actions; } + public List getPosts() { + return posts; + } + + public void setPosts(List posts) { + this.posts = posts == null ? new ArrayList<>() : posts; + } + public List getTransitions() { return transitions; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PostIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PostIntent.java new file mode 100644 index 0000000000..a893b24853 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PostIntent.java @@ -0,0 +1,142 @@ +/* + * 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.components.intent.model; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A declarative event-driven row post: on a document event, emit mapped rows into a target entity + * (per line item), idempotently by a back-reference. Replaces the hand-written ledger-posting glue + * (goods docs -> StockMovement, document -> journal line). See kf-catalog + * PROPOSAL_EVENT_POSTING.md. + * + *

+ * Example (a goods receipt posting one signed movement per line on the POSTED transition): + * + *

+ * posts:
+ *   - name: goodsReceiptLedger
+ *     forEntity: GoodsReceipt
+ *     event: POSTED               # a document status value, or `create`
+ *     forEach: items           # the composition-child collection (omit = one row per master)
+ *     into: StockMovement      # the target entity (local or cross-model)
+ *     idempotentBy: GoodsReceipt   # the back-reference FK on the target; skip if rows already exist
+ *     guard: negativeStock     # optional precondition; a violation aborts the event
+ *     set:
+ *       Product:  item.Product     # item.<field>
+ *       Store:    Receipt.Store     # <Master>.<field>
+ *       Quantity: item.Quantity     # or a Calc expression (e.g. -item.Quantity)
+ *       GoodsReceipt: Receipt.Id    # the back-reference (matches idempotentBy)
+ * 
+ */ +public class PostIntent { + + /** Stable name of this post rule (also the generated handler class stem). */ + private String name; + + /** The source document entity this post fires for. */ + private String forEntity; + + /** + * The trigger EVENT: a document status value (e.g. {@code POSTED}) after that transition, or + * {@code create}. (Named `event` not `on` - `on` is a YAML 1.1 boolean keyword.) + */ + private String event; + + /** + * The composition-child collection to iterate; {@code null} = emit one row per the source record. + */ + private String forEach; + + /** The target entity rows are emitted into (local or cross-model). */ + private String into; + + /** + * The back-reference FK on the target: the generated handler writes it and skips when target rows + * already reference this source. + */ + private String idempotentBy; + + /** + * Optional named precondition evaluated before any row is written; a violation aborts the event. + */ + private String guard; + + /** + * Field map for each emitted row: target field -> value (a constant, {@code Master.field}, + * {@code item.field}, or a Calc expression). + */ + private Map set = new LinkedHashMap<>(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getForEntity() { + return forEntity; + } + + public void setForEntity(String forEntity) { + this.forEntity = forEntity; + } + + public String getEvent() { + return event; + } + + public void setEvent(String event) { + this.event = event; + } + + public String getForEach() { + return forEach; + } + + public void setForEach(String forEach) { + this.forEach = forEach; + } + + public String getInto() { + return into; + } + + public void setInto(String into) { + this.into = into; + } + + public String getIdempotentBy() { + return idempotentBy; + } + + public void setIdempotentBy(String idempotentBy) { + this.idempotentBy = idempotentBy; + } + + public String getGuard() { + return guard; + } + + public void setGuard(String guard) { + this.guard = guard; + } + + public Map getSet() { + return set; + } + + public void setSet(Map set) { + this.set = set == null ? new LinkedHashMap<>() : set; + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostsTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostsTest.java new file mode 100644 index 0000000000..19b1707964 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostsTest.java @@ -0,0 +1,92 @@ +/* + * 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.components.intent.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * Verifies the {@code posts} glue the {@link GlueIntentGenerator} emits from a {@code posts:} rule: + * the source entity + event + per-item collection + target + idempotency back-reference, and the + * field map (including a sign-flip Calc expression). Structural glue - the handler template + BPMN + * wiring is a later stage (kf-catalog PROPOSAL_EVENT_POSTING.md). + */ +class GluePostsTest { + + private static final String YAML = """ + name: inventory + entities: + - name: StockMovement + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: Product, kind: manyToOne, to: Product } + - { name: GoodsIssue, kind: manyToOne, to: GoodsIssue } + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - name: GoodsIssue + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Store, kind: manyToOne, to: Product } + - name: GoodsIssueItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: GoodsIssue, kind: manyToOne, to: GoodsIssue, composition: true } + - { name: Product, kind: manyToOne, to: Product } + posts: + - name: goodsIssueLedger + forEntity: GoodsIssue + event: POSTED + forEach: items + into: StockMovement + idempotentBy: GoodsIssue + guard: negativeStock + set: + Product: item.Product + Quantity: "-item.Quantity" + GoodsIssue: Issue.Id + """; + + @SuppressWarnings("unchecked") + @Test + void emitsThePostGlueDescriptor() { + IntentModel model = IntentParser.parse(YAML); + List> posts = GlueIntentGenerator.buildPostsForTest(model); + assertEquals(1, posts.size()); + Map p = posts.get(0); + + assertEquals("goodsIssueLedger", p.get("name")); + assertEquals("GoodsIssueLedger", p.get("className")); + assertEquals("GoodsIssue", p.get("entity")); + assertEquals("POSTED", p.get("event")); + assertEquals("items", p.get("forEach")); + assertEquals("StockMovement", p.get("into")); + assertEquals("GoodsIssue", p.get("idempotentBy")); + assertEquals("negativeStock", p.get("guard")); + + List> set = (List>) p.get("set"); + assertEquals(3, set.size()); + // field names are pascal-cased; values (incl. the sign-flip expression) pass through verbatim. + assertEquals(Map.of("field", "Product", "value", "item.Product"), set.get(0)); + assertEquals(Map.of("field", "Quantity", "value", "-item.Quantity"), set.get(1)); + assertEquals(Map.of("field", "GoodsIssue", "value", "Issue.Id"), set.get(2)); + } +}