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 @@ -99,6 +99,7 @@ public void generate(IntentGenerationContext context) {
List<Map<String, Object>> generates = buildGenerates(model, byName, compositionParents, settings, context);
List<Map<String, Object>> transitions = buildTransitions(model, byName, compositionParents, settings);
List<Map<String, Object>> postings = buildPostings(model, byName, compositionParents, settings, context);
List<Map<String, Object>> posts = buildPosts(model, byName, compositionParents);
List<Map<String, Object>> printFeeders = PrintFeederSupport.buildPrintFeeders(model, byName, compositionParents, context);
List<Map<String, Object>> snapshots = SnapshotSupport.buildSnapshots(model, byName, compositionParents);
List<Map<String, Object>> numbering = NumberingSupport.buildNumbering(model, compositionParents);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -781,43 +783,151 @@ static List<Map<String, Object>> 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<Map<String, Object>> buildPosts(IntentModel model, Map<String, EntityIntent> byName) {
private static List<Map<String, Object>> buildPosts(IntentModel model, Map<String, EntityIntent> byName,
Map<String, String> compositionParents) {
List<Map<String, Object>> 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<String, Object> 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<Map<String, String>> set = new ArrayList<>();
// Rendered per-row assignments: target field -> a Java expression over `source` / `item`.
List<Map<String, String>> assigns = new ArrayList<>();
for (Map.Entry<String, String> f : p.getSet()
.entrySet()) {
Map<String, String> 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.<Field>} (item copy), {@code -item.<Field>} (negated item copy, null-safe),
* {@code source.<Field>} (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<Map<String, Object>> buildPostsForTest(IntentModel model) {
return buildPosts(model, IntentEntities.byName(model));
return buildPosts(model, IntentEntities.byName(model), IntentEntities.compositionParents(model));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand All @@ -54,20 +61,21 @@ class GluePostsTest {
posts:
- name: goodsIssueLedger
forEntity: GoodsIssue
event: POSTED
event: 2
forEach: items
into: StockMovement
idempotentBy: GoodsIssue
guard: negativeStock
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<Map<String, Object>> posts = GlueIntentGenerator.buildPostsForTest(model);
assertEquals(1, posts.size());
Expand All @@ -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<Map<String, String>> set = (List<Map<String, String>>) 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<Map<String, String>> assigns = (List<Map<String, String>>) 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));
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading