diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateOnEvent.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateOnEvent.java.template index 632bcee98c4..9fcf5599670 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateOnEvent.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateOnEvent.java.template @@ -15,16 +15,13 @@ import org.eclipse.dirigible.sdk.utils.Json; * re-generated with the application. * * Contract: -#if($isStep) - * - binds the step-scoped topic the generated step emitter publishes the ${fromEntity} on at that + * - binds the #if($isStep)step-scoped topic the generated step emitter publishes the ${fromEntity} on at that * step's boundary - published after commit and NOT transactional with the step, so a redelivery - * is possible; -#elseif($isCreate) - * - binds the source's create topic (the platform publishes creates unsuffixed); -#{else} - * - binds the source's -transitioned topic - the channel every status write publishes, - * which -updated reactions never see, so there are no loops; -#end + * is possible#elseif($isCreate)source's create topic (the platform publishes creates unsuffixed)#{else}source's -transitioned topic - the channel a status write ROUTED BY THE + * WORKFLOW publishes (a transition button, a setField step, a create-from completion hook, a + * register lookup that sets a status), which -updated reactions never see, so there are no loops. + * It is NOT every status write: a plain REST update() publishes only -updated, and a targeted + * updateProperty/updateProperties write publishes nothing unless its own template publishes#end; * - RE-LOADS the source by id before the guard, because the event payload is as-of the event and lacks * later-step data such as a stamped document number; * - delegates to ${className}Generate, the SAME create-from a button would call, so the mapping, the diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Resolve.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Resolve.java.template index aefcfeedc2c..a52ef10aa92 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Resolve.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Resolve.java.template @@ -29,6 +29,11 @@ import gen.${javaGenFolderName}.data.${javaRegisterPerspective}.${registerEntity * and a re-delivered event is a no-op. The write is TARGETED (updateProperties) - only the resolved * column, the outcome and the status are in the UPDATE statement, so a concurrent user write to any * other column cannot be reverted, and no "-updated" event re-fires. + * + * When an outcome routes the record by status, the "-transitioned" topic IS published afterwards, so + * that an automatic resolution reaches the same constructs a manual transition does - `generates:` and + * `postings:` bound to `event: { onTransition: ... }`. A targeted write raises no event of its own, so + * this publish is the only thing that makes the automatic path observable at all. */ @Component("${javaGenFolderName}_${className}Resolve") public class ${className}Resolve implements MessageHandler { @@ -108,6 +113,9 @@ public class ${className}Resolve implements MessageHandler { /** * One targeted write carrying everything this attempt decided: the resolved relation when there is * one, the outcome trace, and the routing status. Nothing else of the record is touched. + * + * A targeted write publishes NO event, so when this lookup routes the record by status it must + * announce that transition itself - see the "-transitioned" publish below. */ private static void stamp(Object id, String outcome, Integer resolved#if($writesStatus == "true"), Integer status#end) { java.util.Map values = new java.util.LinkedHashMap<>(); @@ -125,6 +133,22 @@ public class ${className}Resolve implements MessageHandler { if (!values.isEmpty()) { new ${entity}Repository().updateProperties(id, values); } +#if($writesStatus == "true") + if (status != null) { + // The status this lookup just routed the record to IS a transition, and the constructs that + // react to one - `generates:` and `postings:` bound to `event: { onTransition: ... }` - listen + // on "-transitioned". updateProperties above publishes nothing at all, so without this the + // AUTOMATIC path silently did nothing while the manual one (a `transitions:` button, which + // does publish) worked: exactly the wrong way round, and with no log line to show it. + // Reload so the payload carries the committed row rather than the pre-write snapshot, the + // same shape the transition controller and the create-from completion hook publish. + ${entity}Entity transitioned = new ${entity}Repository().findById(id); + if (transitioned != null) { + org.eclipse.dirigible.sdk.messaging.Producer.sendToTopic("${projectName}-${perspective}-${entity}-transitioned", + Json.stringify(transitioned)); + } + } +#end } /** diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 240b42f0444..2e570ad3341 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -727,6 +727,51 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { name: id, type: integer, primaryKey: true, generated: true } - { name: name, type: string, required: true, length: 100 } + # resolves: (#6712) the effective-dated register lookup and, crucially, what happens + # AFTER it succeeds. Zone is the match key, Inspector is what gets resolved, Duty is the + # register whose validity period decides which Inspector was on duty on the patrol's date. + - name: Zone + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + - name: Inspector + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + # The register: exactly ONE to-one to Inspector (what `set:` resolves), the match key, + # and the period. Two rows for one Zone with non-overlapping windows, so only the row + # covering the patrol's date can match - a lookup that ignored the period would pick the + # wrong Inspector rather than none, which is the failure worth catching. + - name: Duty + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: validFrom, type: date } + - { name: validTo, type: date } + relations: + - { name: Zone, kind: manyToOne, to: Zone } + - { name: Inspector, kind: manyToOne, to: Inspector } + # The record the lookup fills in. Its status is routed by the outcome, and that status + # write is the transition the generates below is bound to. + - name: Patrol + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: visitedAt, type: date } + - { name: outcome, type: string, length: 20 } + relations: + - { name: Zone, kind: manyToOne, to: Zone } + - { name: Inspector, kind: manyToOne, to: Inspector } + - { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 } + # The downstream proof: this exists only if the automatic resolution published + # "-transitioned". The back-reference to Patrol is also the at-most-once guard. + - name: PatrolReport + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 100 } + relations: + - { name: Patrol, kind: manyToOne, to: Patrol } + # generates step axis + mode: append (#6800): a shipment whose flow is observed at two # moments, a log the observations are APPENDED to (several rows per source - the # cardinality the at-most-once create-from could not express), and a summary minted @@ -1155,6 +1200,33 @@ class IntentEmissionCoverageIT extends IntegrationTest { prompt: - { field: note } - { field: amount, required: true } + # The automatic half of the same axis: the create-from below is triggered by a status + # the REGISTER LOOKUP wrote, not by a button and not by a workflow step. The lookup + # persists that status with a targeted write, which publishes no event of its own, so + # this entry only ever fires if the lookup announces the transition itself. + - name: report-from-patrol + from: Patrol + to: PatrolReport + event: { onTransition: Patrol, when: "Status == POSTED" } + map: + Patrol: id + defaults: + note: "AUTO" + + # resolves: (#6712) fill Patrol.Inspector from the Duty row whose validity period covers + # the patrol's date, stamp the outcome, and route the record by status. The status write is + # the point of the coverage: it is a transition like any other, and the generates entry + # above is bound to it - so this asserts the AUTOMATIC path reaches the same consumers the + # manual one does, which is exactly what silently did not happen. + resolves: + - name: assign-inspector + event: { onCreate: Patrol } + set: Inspector + from: Duty + match: { Zone: Zone } + between: { start: validFrom, end: validTo, value: visitedAt } + outcome: outcome + found: { setStatus: POSTED } # The roles the model issues - and the ones a `visibleTo:` field may name. permissions: @@ -1201,6 +1273,23 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { id: 1, name: DRAFT, stage: draft } - { id: 2, name: POSTED, stage: live } - { id: 3, name: CANCELLED, stage: cancelled } + - name: zones + entity: Zone + rows: + - { id: 1, name: North } + - name: inspectors + entity: Inspector + rows: + - { id: 1, name: Early } + - { id: 2, name: Late } + # Two duties for one zone, windows that do not overlap: the visit's date falls inside + # the SECOND, so resolving to inspector 2 can only mean the period was honoured - a + # lookup that matched on the zone alone would answer 1 (or refuse as ambiguous). + - name: duties + entity: Duty + rows: + - { id: 1, Zone: 1, Inspector: 1, validFrom: "2020-01-01", validTo: "2020-12-31" } + - { id: 2, Zone: 1, Inspector: 2, validFrom: "2021-01-01", validTo: "2021-12-31" } - name: units entity: Unit rows: @@ -2453,6 +2542,26 @@ private void assertEmission() { assertTrue(contentOf("gen/events/emission/ShipmentFlowSettleCompleted.java").contains("implements JavaDelegate"), "a create-from asking for a step moment must get that moment's emitter, even as its only consumer"); + // resolves (#6712): the lookup persists its outcome with a TARGETED write, which publishes no + // event at all - so when that write routes the record by status it has to announce the + // transition itself. Without this the automatic path wrote the status and told nobody, while a + // transitions: button on the same entity worked: the primary path silently dead, the fallback + // fine. The consumers are bound to "-transitioned" (see the create-from above), so that is the + // channel it must publish on. + String resolve = contentOf("gen/events/emission/AssignInspectorResolve.java"); + assertTrue(resolve.contains("updateProperties("), "the lookup must persist its outcome as one targeted write"); + assertTrue(resolve.contains("-Patrol-transitioned"), + "a resolve that routes the record by status must publish the record's -transitioned topic, " + + "or nothing bound to onTransition can ever observe an automatic resolution"); + assertTrue(resolve.contains("Producer.sendToTopic"), "the resolve must publish through the messaging producer"); + // Guarded on a status having been written: a lookup that only filled the relation (or found + // nothing) transitioned nothing, and must not announce one. + assertTrue(resolve.indexOf("if (status != null)") < resolve.indexOf("Producer.sendToTopic"), + "the publish must sit under the status guard, so a lookup that wrote no status announces no transition"); + String reportOnEvent = contentOf("gen/events/emission/ReportFromPatrolGenerateOnEvent.java"); + assertTrue(reportOnEvent.contains("-Patrol-transitioned"), + "the create-from driven by the lookup must listen on the very topic the lookup publishes"); + // generates prompt (#6685): the prompted controller takes a values map, enforces the // required input with a 400, and converts each posted value to the target field's Java type // (decimal -> BigDecimal). The action descriptor carries the prompt so the customActions @@ -3642,9 +3751,59 @@ private void assertRuntimeEnforcement() { assertOutboundDepartureRuntime(); assertExpansionLifecycleRuntime(); assertBpmEventsRuntime(); + assertResolveTransitionRuntime(); assertGeneratesStepAxisRuntime(); } + /** + * The register lookup end to end (#6712), and specifically what happens once it succeeds: the + * resolved relation and the routing status are written, and the create-from bound to that + * transition mints its document - with nobody clicking anything. + * + *

+ * The last step is the one worth having. The lookup persists through a targeted write, which + * publishes no event, so an automatic resolution used to reach no consumer at all while a manual + * transition on the same entity reached every one of them - the automation's primary path silently + * doing nothing, its fallback working, and no log line either way. Asserting only that the + * Inspector was filled in would still pass in that world. + */ + private void assertResolveTransitionRuntime() { + String patrolApi = API + "/patrol/PatrolController"; + AtomicInteger patrol = new AtomicInteger(); + // Zone 1 has two duties; this date falls inside the SECOND window only. + restAssuredExecutor.execute(() -> patrol.set(given().contentType("application/json") + .body("{\"Zone\":1,\"VisitedAt\":\"2021-06-15\"}") + .when() + .post(patrolApi) + .then() + .statusCode(200) + .extract() + .path("Id"))); + // The lookup runs off the create event, so it lands after the POST returns. + restAssuredExecutor.execute(() -> given().when() + .get(patrolApi + "/" + patrol.get()) + .then() + .statusCode(200) + // Inspector 2, not 1: the covering period decided it, not the zone alone. + .body("Inspector", equalTo(2)) + .body("Outcome", equalTo("found")) + .body("Status", equalTo(2)), + 90); + // The transition the lookup wrote must have reached the create-from bound to it. + restAssuredExecutor.execute(() -> { + io.restassured.path.json.JsonPath reports = given().when() + .get(API + "/patrolreport/PatrolReportController") + .then() + .statusCode(200) + .extract() + .jsonPath(); + assertEquals(1, reports.getList("findAll { it.Patrol == " + patrol.get() + " }") + .size(), + "the create-from bound to onTransition must mint its document from an AUTOMATIC resolution, " + + "not only from a transition button"); + }, 90); + } + /** * The step axis and both cardinalities of an event-driven create-from (#6800) at the outermost * layer, on ONE shipment whose all-serviceTask flow runs by itself: the two appending create-froms