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 1d662e4577..25b53ae45e 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 @@ -99,6 +99,7 @@ public void generate(IntentGenerationContext context) { List> generates = buildGenerates(model, byName, compositionParents, settings, context); List> transitions = buildTransitions(model, byName, compositionParents, settings); List> postings = buildPostings(model, byName, compositionParents, settings, context); + List> posts = buildPosts(model, byName, compositionParents); List> printFeeders = PrintFeederSupport.buildPrintFeeders(model, byName, compositionParents, context); List> snapshots = SnapshotSupport.buildSnapshots(model, byName, compositionParents); List> numbering = NumberingSupport.buildNumbering(model, compositionParents); @@ -131,6 +132,7 @@ public void generate(IntentGenerationContext context) { glue.put("generates", generates); glue.put("transitions", transitions); glue.put("postings", postings); + glue.put("posts", posts); glue.put("printFeeders", printFeeders); glue.put("snapshots", snapshots); glue.put("numbering", numbering); @@ -781,43 +783,151 @@ static List> buildTransitionsForTest(IntentModel model) { * {@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) { + private static List> buildPosts(IntentModel model, Map byName, + Map compositionParents) { 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 + continue; // malformed: name/forEntity/into/event are required + } + EntityIntent source = byName.get(p.getForEntity()); + EntityIntent target = byName.get(p.getInto()); + if (source == null || target == null) { + continue; // bad source/target reference (v1: same-model target) + } + // The trigger: `create` (fires on insert, no status guard) or a numeric status seed id + // (fires on the -transitioned topic, guarded to that status - matches transitions/postings). + boolean isCreate = "create".equalsIgnoreCase(p.getEvent() + .trim()); + Integer statusValue = null; + String statusProperty = ""; + if (!isCreate) { + try { + statusValue = Integer.valueOf(p.getEvent() + .trim()); + } catch (NumberFormatException nfe) { + LOGGER.warn("posts [{}]: event must be `create` or a numeric status seed id, was [{}] - skipped", p.getName(), + p.getEvent()); + continue; + } + for (RelationIntent relation : source.getRelations()) { + if (relation.isEntityStatus()) { + statusProperty = IntentNaming.pascalCase(relation.getName()); + } + } + if (statusProperty.isEmpty()) { + LOGGER.warn("posts [{}]: source [{}] has no function: EntityStatus relation for a status-triggered post - skipped", + p.getName(), p.getForEntity()); + continue; + } } - if (byName.get(p.getForEntity()) == null) { - continue; // bad source reference + // Per-item collection: forEach -> the composition child of the source (the entity whose + // composition relation targets it). Absent -> one row from the source itself. + String itemsEntity = ""; + String itemsFk = ""; + String itemsPerspective = ""; + boolean perItem = p.getForEach() != null && !p.getForEach() + .isBlank(); + if (perItem) { + EntityIntent child = null; + String fk = null; + for (EntityIntent candidate : model.getEntities()) { + if (p.getForEntity() + .equals(compositionParents.get(candidate.getName()))) { + for (RelationIntent relation : candidate.getRelations()) { + if (relation.isComposition() && p.getForEntity() + .equals(relation.getTo())) { + child = candidate; + fk = IntentNaming.pascalCase(relation.getName()); + } + } + } + } + if (child == null) { + LOGGER.warn("posts [{}]: forEach set but [{}] has no composition child - skipped", p.getName(), p.getForEntity()); + continue; + } + itemsEntity = child.getName(); + itemsFk = fk; + itemsPerspective = IntentEntities.resolvePerspective(child.getName(), compositionParents); } + Map e = new LinkedHashMap<>(); e.put("name", p.getName()); e.put("className", IntentNaming.pascalIdentifier(p.getName())); e.put("entity", p.getForEntity()); + e.put("sourcePerspective", IntentEntities.resolvePerspective(p.getForEntity(), compositionParents)); + e.put("sourceKeyField", IntentEntities.keyFieldName(source)); + e.put("isCreate", isCreate); e.put("event", p.getEvent()); - e.put("forEach", p.getForEach() == null ? "" : p.getForEach()); + e.put("statusProperty", statusProperty); + e.put("statusValue", statusValue == null ? "" : String.valueOf(statusValue)); + e.put("perItem", perItem); + e.put("itemsEntity", itemsEntity); + e.put("itemsFk", itemsFk); + e.put("itemsPerspective", itemsPerspective); e.put("into", p.getInto()); - e.put("idempotentBy", p.getIdempotentBy() == null ? "" : p.getIdempotentBy()); + e.put("targetPerspective", IntentEntities.resolvePerspective(p.getInto(), compositionParents)); + e.put("targetPk", IntentEntities.keyFieldName(target)); + e.put("backRef", p.getIdempotentBy() == null ? "" : IntentNaming.pascalCase(p.getIdempotentBy())); e.put("guard", p.getGuard() == null ? "" : p.getGuard()); - List> set = new ArrayList<>(); + // Rendered per-row assignments: target field -> a Java expression over `source` / `item`. + List> assigns = 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); + pair.put("expr", postSetExpr(f.getValue())); + assigns.add(pair); } - e.put("set", set); + e.put("assigns", assigns); out.add(e); } return out; } + /** + * Render a {@code posts:} {@code set:} value to a Java expression over the {@code source} entity + * and the per-item {@code item} entity. Supported forms (the inventory ledger's needs): + * {@code item.} (item copy), {@code -item.} (negated item copy, null-safe), + * {@code source.} (source copy), an integer literal (a constant FK/int), a quoted string, + * else pass-through (best effort). Fuller {@code Calc} expressions are a follow-up. + */ + private static String postSetExpr(String raw) { + if (raw == null) { + return "null"; + } + String v = raw.trim(); + java.util.regex.Matcher neg = java.util.regex.Pattern.compile("^-\\s*item\\.(\\w+)$") + .matcher(v); + if (neg.matches()) { + String f = "item." + IntentNaming.pascalCase(neg.group(1)); + return f + " == null ? null : " + f + ".negate()"; + } + java.util.regex.Matcher item = java.util.regex.Pattern.compile("^item\\.(\\w+)$") + .matcher(v); + if (item.matches()) { + return "item." + IntentNaming.pascalCase(item.group(1)); + } + java.util.regex.Matcher src = java.util.regex.Pattern.compile("^source\\.(\\w+)$") + .matcher(v); + if (src.matches()) { + return "source." + IntentNaming.pascalCase(src.group(1)); + } + if (v.matches("-?\\d+")) { + return v; // integer constant (e.g. a Direction FK id) + } + if (v.matches("\"[^\"]*\"")) { + return v; // already-quoted string literal + } + return v; // pass-through (best effort); fuller Calc rendering is a follow-up + } + /** Test hook: build the {@code posts} glue collection without a repository. */ static List> buildPostsForTest(IntentModel model) { - return buildPosts(model, IntentEntities.byName(model)); + return buildPosts(model, IntentEntities.byName(model), IntentEntities.compositionParents(model)); } /** 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 index 19b1707964..b6a3ef601e 100644 --- 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 @@ -29,10 +29,16 @@ class GluePostsTest { private static final String YAML = """ name: inventory entities: + - name: StockMovementStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } - name: StockMovement fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: quantity, type: decimal } + - { name: direction, type: integer } relations: - { name: Product, kind: manyToOne, to: Product } - { name: GoodsIssue, kind: manyToOne, to: GoodsIssue } @@ -44,6 +50,7 @@ class GluePostsTest { - { name: id, type: integer, primaryKey: true, generated: true } relations: - { name: Store, kind: manyToOne, to: Product } + - { name: Status, kind: manyToOne, to: StockMovementStatus, function: EntityStatus, init: 1 } - name: GoodsIssueItem fields: - { name: id, type: integer, primaryKey: true, generated: true } @@ -54,7 +61,7 @@ class GluePostsTest { posts: - name: goodsIssueLedger forEntity: GoodsIssue - event: POSTED + event: 2 forEach: items into: StockMovement idempotentBy: GoodsIssue @@ -62,12 +69,13 @@ class GluePostsTest { set: Product: item.Product Quantity: "-item.Quantity" - GoodsIssue: Issue.Id + Direction: 2 + Store: source.Store """; @SuppressWarnings("unchecked") @Test - void emitsThePostGlueDescriptor() { + void emitsTheResolvedPostGlueDescriptor() { IntentModel model = IntentParser.parse(YAML); List> posts = GlueIntentGenerator.buildPostsForTest(model); assertEquals(1, posts.size()); @@ -76,17 +84,25 @@ void emitsThePostGlueDescriptor() { 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")); + // status-triggered: not a create, guarded to status seed id 2 on the EntityStatus property. + assertEquals(Boolean.FALSE, p.get("isCreate")); + assertEquals("Status", p.get("statusProperty")); + assertEquals("2", p.get("statusValue")); + // per-item: the composition child of GoodsIssue is GoodsIssueItem (FK GoodsIssue). + assertEquals(Boolean.TRUE, p.get("perItem")); + assertEquals("GoodsIssueItem", p.get("itemsEntity")); + assertEquals("GoodsIssue", p.get("itemsFk")); + // target + idempotency back-reference. assertEquals("StockMovement", p.get("into")); - assertEquals("GoodsIssue", p.get("idempotentBy")); - assertEquals("negativeStock", p.get("guard")); + assertEquals("Id", p.get("targetPk")); + assertEquals("GoodsIssue", p.get("backRef")); - 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)); + List> assigns = (List>) p.get("assigns"); + assertEquals(4, assigns.size()); + // item copy, null-safe negation, integer constant, source copy - rendered to Java expressions. + assertEquals(Map.of("field", "Product", "expr", "item.Product"), assigns.get(0)); + assertEquals(Map.of("field", "Quantity", "expr", "item.Quantity == null ? null : item.Quantity.negate()"), assigns.get(1)); + assertEquals(Map.of("field", "Direction", "expr", "2"), assigns.get(2)); + assertEquals(Map.of("field", "Store", "expr", "source.Store"), assigns.get(3)); } } diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posts.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posts.java.template new file mode 100644 index 0000000000..3f161bf068 --- /dev/null +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posts.java.template @@ -0,0 +1,86 @@ +package gen.events.${javaGenFolderName}; + +import org.eclipse.dirigible.components.data.store.java.repository.Criteria; +import org.eclipse.dirigible.sdk.component.Component; +import org.eclipse.dirigible.sdk.messaging.ListenerKind; +import org.eclipse.dirigible.sdk.messaging.MessageHandler; +import org.eclipse.dirigible.sdk.utils.Json; + +/** + * Post ${name}: on a ${sourceEntity} event (${event}), emit ${into} rows (one per ${itemsEntity} line + * when forEach is set), idempotently by the ${backRef} back-reference. Generated from the intent + * posts: block - do not edit; re-generated with the application. See kf-catalog + * PROPOSAL_EVENT_POSTING.md. + * + * Contract (mirrors the postings skeleton, flattened - no header document): + * - binds the source's #if($isCreate)-created#{else}-transitioned#end topic and RE-LOADS the source by + * id (the payload is as-of the event and may lack later-step data); + * - idempotent by ${backRef}: if ${into} rows already back-reference this source, it is a no-op; + * - all writes go through the generated ${into} repository (so its own create logic fires). + */ +@Component("${javaGenFolderName}_${className}Post") +public class ${className}Post implements MessageHandler { + + @Override + public String destination() { + return "${projectName}-${sourcePerspective}-${sourceEntity}-#if($isCreate)created#{else}transitioned#end"; + } + + @Override + public ListenerKind kind() { + return ListenerKind.TOPIC; + } + + @Override + public void onMessage(String message) { + gen.${javaGenFolderName}.data.${sourceJavaPerspective}.${sourceEntity}Entity payload = + Json.parse(message, gen.${javaGenFolderName}.data.${sourceJavaPerspective}.${sourceEntity}Entity.class); + if (payload == null || payload.${sourceKeyField} == null) { + return; + } + gen.${javaGenFolderName}.data.${sourceJavaPerspective}.${sourceEntity}Entity source = + new gen.${javaGenFolderName}.data.${sourceJavaPerspective}.${sourceEntity}Repository().findById(payload.${sourceKeyField}); + if (source == null) { + return; + } +#if(!$isCreate) + if (source.${statusProperty} == null || source.${statusProperty} != ${statusValue}) { + return; // only fires on the transition into status ${statusValue} + } +#end + gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Repository targetRepository = + new gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Repository(); +#if($backRef != "") + // Idempotency: a row already back-referencing this source means the post ran - no-op. + if (!targetRepository.findAll(Criteria.create().eq("${backRef}", source.${sourceKeyField})).isEmpty()) { + return; + } +#end +#if($perItem) + gen.${javaGenFolderName}.data.${itemsJavaPerspective}.${itemsEntity}Repository itemsRepository = + new gen.${javaGenFolderName}.data.${itemsJavaPerspective}.${itemsEntity}Repository(); + for (gen.${javaGenFolderName}.data.${itemsJavaPerspective}.${itemsEntity}Entity item : + itemsRepository.findAll(Criteria.create().eq("${itemsFk}", source.${sourceKeyField}))) { + gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Entity row = + new gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Entity(); +#foreach($a in $assigns) + row.${a.field} = ${a.expr}; +#end +#if($backRef != "") + row.${backRef} = source.${sourceKeyField}; +#end + targetRepository.save(row); + } +#else + gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Entity row = + new gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Entity(); +#foreach($a in $assigns) + row.${a.field} = ${a.expr}; +#end +#if($backRef != "") + row.${backRef} = source.${sourceKeyField}; +#end + targetRepository.save(row); +#end + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js index f7216878ef..eb19b25563 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js @@ -177,6 +177,13 @@ export function getTemplate(parameters) { engine: "velocity", collection: "postings" }, + { + location: "/template-application-events-java/events/Posts.java.template", + action: "generate", + rename: "gen/events/{{javaGenFolderName}}/{{className}}Post.java", + engine: "velocity", + collection: "posts" + }, { location: "/template-application-events-java/project.json.mjs", action: "generate", diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index 9f433f7bce..c861f296d1 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -1061,6 +1061,48 @@ export function generateFiles(model, parameters, templateSources) { } } break; + case "posts": + // Event-driven row posting (intent posts:): per rule, a MessageHandler on the + // source's -transitioned / -created topic that emits mapped rows into the target + // (per line item), idempotently by the back-reference. All expressions are + // pre-rendered by the glue generator; here only the Java package segments are + // sanitized. Same-model source + target (cross-model target is a follow-up). + if (model.posts) { + for (let i = 0; i < model.posts.length; i++) { + const po = model.posts[i]; + const postsParameters = { + ...parameters, + name: po.name, + className: po.className, + sourceEntity: po.entity, + sourcePerspective: po.sourcePerspective, + sourceJavaPerspective: sanitizeJavaIdentifier(po.sourcePerspective), + sourceKeyField: po.sourceKeyField, + isCreate: po.isCreate, + event: po.event, + statusProperty: po.statusProperty, + statusValue: po.statusValue, + perItem: po.perItem, + itemsEntity: po.itemsEntity, + itemsFk: po.itemsFk, + itemsPerspective: po.itemsPerspective, + itemsJavaPerspective: sanitizeJavaIdentifier(po.itemsPerspective), + into: po.into, + targetPerspective: po.targetPerspective, + targetJavaPerspective: sanitizeJavaIdentifier(po.targetPerspective), + targetPk: po.targetPk, + backRef: po.backRef, + assigns: po.assigns + }; + const cleanPostsParameters = cleanData(postsParameters); + generatedFiles.push({ + location: location, + content: getGenerationEngine(template).generate(location, content, cleanPostsParameters), + path: templateEngines.getMustacheEngine().generate(location, template.rename, cleanPostsParameters) + }); + } + } + break; case "postings": // Declarative postings (intent layer): a MessageHandler on the source's // -transitioned topic creating a local document + computed items. Everything is