From ed762052de402b6224c3384bfc4cc50649e172a9 Mon Sep 17 00:00:00 2001 From: Deblina Sanyal Date: Wed, 4 Mar 2026 11:43:49 +0100 Subject: [PATCH 1/5] Initial logic commit with tests to start multiple processes via annotation on the same entity using qualifiers. --- cds-plugin.ts | 54 +++++-- lib/constants.ts | 7 + lib/handlers/processStart.ts | 142 ++++++++++++------ lib/handlers/utils.ts | 18 +++ tests/bookshop/srv/annotation-service.cds | 95 ++++++++++++ .../integration/annotations/isolated.test.ts | 119 +++++++++++++++ 6 files changed, 369 insertions(+), 66 deletions(-) diff --git a/cds-plugin.ts b/cds-plugin.ts index 08623c8..4a40040 100644 --- a/cds-plugin.ts +++ b/cds-plugin.ts @@ -75,24 +75,46 @@ cds.on('serving', async (service: cds.Service) => { function buildAnnotationCache(service: cds.Service) { const cache = new Map(); for (const entity of Object.values(service.entities)) { - // Get the actual events from annotations (could be any event, not just CRUD) - const startEvent = entity[PROCESS_START_ON]; - const cancelEvent = entity[PROCESS_CANCEL_ON]; - const suspendEvent = entity[PROCESS_SUSPEND_ON]; - const resumeEvent = entity[PROCESS_RESUME_ON]; - - // Collect unique events that have annotations - const events = new Set(); - if (startEvent) events.add(startEvent); - if (cancelEvent) events.add(cancelEvent); - if (suspendEvent) events.add(suspendEvent); - if (resumeEvent) events.add(resumeEvent); + // Collect all events that have a start annotation (non-qualified and qualified) + const startEventsSet = new Set(); + + // Non-qualified: @build.process.start: { id, on } + const nonQualStartOn = entity[PROCESS_START_ON] as string | undefined; + if (nonQualStartOn && entity[PROCESS_START_ID]) { + startEventsSet.add(nonQualStartOn); + } + + // Qualified: @build.process.start #qualifier: { id, on } + // CDS stores as @build.process.start#qualifier.on, @build.process.start#qualifier.id + for (const key of Object.keys(entity)) { + const match = key.match(/^@build\.process\.start#(\w+)\.on$/); + if (match) { + const qualifier = match[1]; + const onValue = entity[key as keyof typeof entity] as string | undefined; + const hasId = !!(entity[`@build.process.start#${qualifier}.id` as keyof typeof entity]); + if (onValue && hasId) { + startEventsSet.add(onValue); + } + } + } + + const cancelEvent = entity[PROCESS_CANCEL_ON] as string | undefined; + const suspendEvent = entity[PROCESS_SUSPEND_ON] as string | undefined; + const resumeEvent = entity[PROCESS_RESUME_ON] as string | undefined; + + // Collect unique events across all annotation types + const events = new Set([ + ...startEventsSet, + ...(cancelEvent ? [cancelEvent] : []), + ...(suspendEvent ? [suspendEvent] : []), + ...(resumeEvent ? [resumeEvent] : []), + ]); for (const event of events) { - const hasStart = !!(startEvent === event && entity[PROCESS_START_ID]); - const hasCancel = !!(cancelEvent === event); - const hasSuspend = !!(suspendEvent === event); - const hasResume = !!(resumeEvent === event); + const hasStart = startEventsSet.has(event); + const hasCancel = cancelEvent === event; + const hasSuspend = suspendEvent === event; + const hasResume = resumeEvent === event; const cacheKey = `${entity.name}:${event}`; cache.set(cacheKey, { diff --git a/lib/constants.ts b/lib/constants.ts index 20ffc4f..4ce9a16 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -51,6 +51,13 @@ export const PROCESS_INPUT = '@build.process.input' as const; export const BUILD_PREFIX = '@build' as const; export const PROCESS_PREFIX = '@build.process' as const; +/** + * Qualifier prefix for multiple process start annotations + * Usage: @build.process.start #qualifier: { id: '...', on: '...' } + * Stored in CDS as: @build.process.start#qualifier.id, @build.process.start#qualifier.on, etc. + */ +export const PROCESS_START_QUALIFIER_PREFIX = '@build.process.start#' as const; + /** * Process Event Annotations (Runtime) * These annotations are used on dynamically created ProcessService events diff --git a/lib/handlers/processStart.ts b/lib/handlers/processStart.ts index 2d676a5..8cc6a38 100644 --- a/lib/handlers/processStart.ts +++ b/lib/handlers/processStart.ts @@ -16,6 +16,7 @@ import { PROCESS_INPUT, LOG_MESSAGES, PROCESS_LOGGER_PREFIX, + PROCESS_START_QUALIFIER_PREFIX, } from './../constants'; import cds from '@sap/cds'; @@ -38,12 +39,12 @@ export function getColumnsForProcessStart( target: Target, req: cds.Request, ): column_expr[] | string[] { - const startSpecs = initStartSpecs(target, req); - if (startSpecs.inputs.length === 0) { + const sharedInputs = getSharedInputs(target, req); + if (sharedInputs.length === 0) { LOG.debug(LOG_MESSAGES.NO_PROCESS_INPUTS_DEFINED); return ['*']; } else { - return convertToColumnsExpr(startSpecs.inputs); + return convertToColumnsExpr(sharedInputs); } } @@ -53,64 +54,105 @@ export async function handleProcessStart(req: cds.Request): Promise { const target = req.target as Target; const data = ((req as ProcessDeleteRequest)._Process ?? req.data) as EntityRow; - const startSpecs = initStartSpecs(target, req); + const allStartSpecs = getAllStartSpecs(target, req); + if (allStartSpecs.length === 0) { + LOG.debug(LOG_MESSAGES.PROCESS_NOT_STARTED); + return; + } - // if startSpecs.input = [] --> no input defined, fetch entire row + // @build.process.input annotations are element-level, shared across all start specs + const sharedInputs = allStartSpecs[0].inputs; let columns: column_expr[] | string[] = []; - if (startSpecs.inputs.length === 0) { + if (sharedInputs.length === 0) { columns = ['*']; LOG.debug(LOG_MESSAGES.NO_PROCESS_INPUTS_DEFINED); } else { - columns = convertToColumnsExpr(startSpecs.inputs); + columns = convertToColumnsExpr(sharedInputs); + } + + for (const startSpec of allStartSpecs) { + // fetch entity (includes per-spec condition check for non-DELETE events) + const row = await resolveEntityRowOrReject( + req, + data, + startSpec.conditionExpr, + 'PROCESS_START_FETCH_FAILED', + LOG_MESSAGES.PROCESS_NOT_STARTED, + columns, + ); + if (!row) continue; // condition not met for this spec — try the next one + + // get business key + const businessKey = getBusinessKeyOrReject( + target as cds.entity, + row, + req, + 'PROCESS_START_INVALID_KEY', + 'PROCESS_START_EMPTY_KEY', + ); + if (!businessKey) return; // invalid key is fatal for all specs + + const context = { ...row, businesskey: businessKey }; + + // emit process start for this spec + const payload = { definitionId: startSpec.id!, context }; + await emitProcessEvent('start', req, payload, 'PROCESS_START_FAILED', startSpec.id!); + } +} + +/** + * Returns all start specs for the target entity, including both non-qualified + * (@build.process.start) and qualified (@build.process.start #qualifier) annotations. + * Inputs (@build.process.input) are element-level and shared across all specs. + */ +function getAllStartSpecs(target: Target, req: cds.Request): ProcessStartSpec[] { + const specs: ProcessStartSpec[] = []; + const entityAnnotations = target as unknown as Record; + + // Shared inputs — element-level annotations, same for all start specs + const sharedInputs = getSharedInputs(target, req); + + // Non-qualified annotation: @build.process.start: { id, on, if } + if (entityAnnotations[PROCESS_START_ON]) { + specs.push({ + id: entityAnnotations[PROCESS_START_ID] as string, + on: entityAnnotations[PROCESS_START_ON] as string, + inputs: sharedInputs, + conditionExpr: entityAnnotations[PROCESS_START_IF] + ? ((entityAnnotations[PROCESS_START_IF] as unknown as { xpr: expr }).xpr as expr) + : undefined, + }); + } + + // Qualified annotations: @build.process.start #qualifier: { id, on, if } + // CDS stores these as @build.process.start#qualifier.id, @build.process.start#qualifier.on, etc. + for (const key of Object.keys(entityAnnotations)) { + const match = key.match(/^@build\.process\.start#(\w+)\.on$/); + if (match) { + const qualifier = match[1]; + const prefix = `${PROCESS_START_QUALIFIER_PREFIX}${qualifier}`; + specs.push({ + id: entityAnnotations[`${prefix}.id`] as string, + on: entityAnnotations[key] as string, + inputs: sharedInputs, + conditionExpr: entityAnnotations[`${prefix}.if`] + ? ((entityAnnotations[`${prefix}.if`] as unknown as { xpr: expr }).xpr as expr) + : undefined, + }); + } } - // fetch entity - const row = await resolveEntityRowOrReject( - req, - data, - startSpecs.conditionExpr, - 'PROCESS_START_FETCH_FAILED', - LOG_MESSAGES.PROCESS_NOT_STARTED, - columns, - ); - if (!row) return; - - // get business key - const businessKey = getBusinessKeyOrReject( - target as cds.entity, - row, - req, - 'PROCESS_START_INVALID_KEY', - 'PROCESS_START_EMPTY_KEY', - ); - if (!businessKey) return; - - const context = { ...row, businesskey: businessKey }; - - // emit process start - const payload = { definitionId: startSpecs.id!, context }; - await emitProcessEvent('start', req, payload, 'PROCESS_START_FAILED', startSpecs.id!); + return specs; } -function initStartSpecs(target: Target, req: cds.Request): ProcessStartSpec { - const startSpecs: ProcessStartSpec = { - id: target[PROCESS_START_ID] as string, - on: target[PROCESS_START_ON] as string, - inputs: [], - conditionExpr: target[PROCESS_START_IF] - ? ((target[PROCESS_START_IF] as unknown as { xpr: expr }).xpr as expr) - : undefined, - }; +/** + * Extracts shared process input elements from element-level @build.process.input annotations. + * These are the same for all start specs on an entity. + */ +function getSharedInputs(target: Target, req: cds.Request): ProcessStartInput[] { const elementAnnotations = getElementAnnotations(target as cds.entity); const entityName = (target as cds.entity).name; - startSpecs.inputs = getInputElements( - elementAnnotations, - new Set([entityName]), - [entityName], - req, - ); - - return startSpecs; + return getInputElements(elementAnnotations, new Set([entityName]), [entityName], req); } function getInputElements( diff --git a/lib/handlers/utils.ts b/lib/handlers/utils.ts index a751bf4..bcead0e 100644 --- a/lib/handlers/utils.ts +++ b/lib/handlers/utils.ts @@ -236,6 +236,24 @@ export async function addDeletedEntityToRequest( } } + // Handle qualified start annotations: @build.process.start #qualifier: { on: 'DELETE', if: ... } + // CDS stores as @build.process.start#qualifier.on, @build.process.start#qualifier.if + for (const key of Object.keys(annotatedTarget)) { + const match = key.match(/^@build\.process\.start#(\w+)\.on$/); + if (match && annotatedTarget[key] === 'DELETE') { + const qualifier = match[1]; + const conditionExpr = annotatedTarget[`@build.process.start#${qualifier}.if`] as + | { xpr: expr } + | undefined; + if (conditionExpr) { + where = + Array.isArray(where) && where.length + ? [{ xpr: where }, 'and', { xpr: conditionExpr.xpr }] + : conditionExpr.xpr; + } + } + } + if (where) { // Safeguard: use ['*'] if columns array is empty to avoid invalid SQL const selectColumns = columns.length > 0 ? columns : ['*']; diff --git a/tests/bookshop/srv/annotation-service.cds b/tests/bookshop/srv/annotation-service.cds index aad81af..63cbf0c 100644 --- a/tests/bookshop/srv/annotation-service.cds +++ b/tests/bookshop/srv/annotation-service.cds @@ -835,6 +835,101 @@ service AnnotationService { unitPrice : Decimal(15, 2) @(build.process.input: 'Price'); } + // ============================================ + // MULTIPLE START ANNOTATIONS VIA QUALIFIERS + // Testing @build.process.start #qualifier support + // ============================================ + + // -------------------------------------------- + // Test: Two processes start on same event (no conditions) + // Both approvalProcess and notificationProcess should start on CREATE + // -------------------------------------------- + @build.process.start #approval: { + id: 'approvalProcess', + on: 'CREATE', + } + @build.process.start #notification: { + id: 'notificationProcess', + on: 'CREATE', + } + entity MultiStartOnCreate as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // -------------------------------------------- + // Test: Two processes start on different events + // approvalProcess starts on CREATE, auditProcess starts on UPDATE + // -------------------------------------------- + @build.process.start #approvalCreate: { + id: 'approvalProcess', + on: 'CREATE', + } + @build.process.start #auditUpdate: { + id: 'auditProcess', + on: 'UPDATE', + } + entity MultiStartDifferentEvents as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // -------------------------------------------- + // Test: Two processes with conditions on same event + // highMileageProcess starts if mileage > 500, lowMileageProcess if mileage <= 500 + // -------------------------------------------- + @build.process.start #highMileage: { + id: 'highMileageProcess', + on: 'CREATE', + if: (mileage > 500) + } + @build.process.start #lowMileage: { + id: 'lowMileageProcess', + on: 'CREATE', + if: (mileage <= 500) + } + entity MultiStartWithCondition as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // -------------------------------------------- + // Test: Three processes start on same event (no conditions) + // processA, processB, processC should all start on CREATE + // -------------------------------------------- + @build.process.start #procA: { + id: 'processA', + on: 'CREATE', + } + @build.process.start #procB: { + id: 'processB', + on: 'CREATE', + } + @build.process.start #procC: { + id: 'processC', + on: 'CREATE', + } + entity MultiStartThreeProcesses as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + // -------------------------------------------- // Test 7: Cycles in composition with @build.process.input // Should throw error diff --git a/tests/integration/annotations/isolated.test.ts b/tests/integration/annotations/isolated.test.ts index ca311ef..a8eb730 100644 --- a/tests/integration/annotations/isolated.test.ts +++ b/tests/integration/annotations/isolated.test.ts @@ -889,4 +889,123 @@ describe('Integration tests for Process Annotations (Isolated)', () => { }); }); }); + + // ================================================ + // MULTIPLE START ANNOTATIONS VIA QUALIFIERS + // ================================================ + describe('Multiple Process START annotations via qualifiers', () => { + describe('Two processes start on the same event (no conditions)', () => { + it('should start both processes on CREATE', async () => { + const car = createTestCar(); + + const response = await POST('/odata/v4/annotation/MultiStartOnCreate', car); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(2); + + const startMessages = foundMessages.filter((m) => m.event === 'start'); + expect(startMessages.length).toBe(2); + + const definitionIds = startMessages.map((m: any) => m.data.definitionId).sort(); + expect(definitionIds).toEqual(['approvalProcess', 'notificationProcess']); + + // Both should carry the entity context + for (const msg of startMessages) { + expect(msg.data.context.businesskey).toBe(car.ID); + expect(msg.data.context.ID).toBe(car.ID); + } + }); + }); + + describe('Two processes start on different events', () => { + it('should start only approvalProcess on CREATE', async () => { + const car = createTestCar(); + + const response = await POST('/odata/v4/annotation/MultiStartDifferentEvents', car); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[0].data.definitionId).toBe('approvalProcess'); + expect(foundMessages[0].data.context.businesskey).toBe(car.ID); + }); + + it('should start only auditProcess on UPDATE', async () => { + const car = createTestCar(); + + // Create first + const createResponse = await POST('/odata/v4/annotation/MultiStartDifferentEvents', car); + expect(createResponse.status).toBe(201); + foundMessages = []; + + // Update + const updateResponse = await PATCH( + `/odata/v4/annotation/MultiStartDifferentEvents('${car.ID}')`, + { mileage: 200 }, + ); + + expect(updateResponse.status).toBe(200); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[0].data.definitionId).toBe('auditProcess'); + expect(foundMessages[0].data.context.businesskey).toBe(car.ID); + }); + }); + + describe('Two processes with different conditions on the same event', () => { + it('should start only highMileageProcess when mileage > 500', async () => { + const car = createTestCar(undefined, 600); // mileage 600 > 500 + + const response = await POST('/odata/v4/annotation/MultiStartWithCondition', car); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[0].data.definitionId).toBe('highMileageProcess'); + expect(foundMessages[0].data.context.businesskey).toBe(car.ID); + }); + + it('should start only lowMileageProcess when mileage <= 500', async () => { + const car = createTestCar(undefined, 400); // mileage 400 <= 500 + + const response = await POST('/odata/v4/annotation/MultiStartWithCondition', car); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[0].data.definitionId).toBe('lowMileageProcess'); + expect(foundMessages[0].data.context.businesskey).toBe(car.ID); + }); + + it('should start only lowMileageProcess when mileage exactly equals 500', async () => { + const car = createTestCar(undefined, 500); // mileage 500 — only <= 500 matches + + const response = await POST('/odata/v4/annotation/MultiStartWithCondition', car); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[0].data.definitionId).toBe('lowMileageProcess'); + }); + }); + + describe('Three processes start on the same event', () => { + it('should start all three processes on CREATE', async () => { + const car = createTestCar(); + + const response = await POST('/odata/v4/annotation/MultiStartThreeProcesses', car); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(3); + + const definitionIds = foundMessages.map((m: any) => m.data.definitionId).sort(); + expect(definitionIds).toEqual(['processA', 'processB', 'processC']); + + for (const msg of foundMessages) { + expect(msg.event).toBe('start'); + expect(msg.data.context.businesskey).toBe(car.ID); + } + }); + }); + }); }); From 370ef37f3d1393f413ef9a3dfdf595447272609f Mon Sep 17 00:00:00 2001 From: Deblina Sanyal Date: Wed, 4 Mar 2026 12:47:49 +0100 Subject: [PATCH 2/5] Merge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bd4f970..2dfbe75 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ cd /tests/bookshop cds watch ``` + ## To use the plugin as a CAP developer: - (in future): run `npm add @cap-js/process` From 456abb84c0d15e0b181f54a7dc7e89b46c526ed2 Mon Sep 17 00:00:00 2001 From: Deblina Sanyal Date: Wed, 4 Mar 2026 12:49:48 +0100 Subject: [PATCH 3/5] Undo --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 2dfbe75..bd4f970 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ cd /tests/bookshop cds watch ``` - ## To use the plugin as a CAP developer: - (in future): run `npm add @cap-js/process` From bb9e3a3786fca6e01408d149e3c1830b7532e2ed Mon Sep 17 00:00:00 2001 From: Deblina Sanyal Date: Wed, 4 Mar 2026 15:01:42 +0100 Subject: [PATCH 4/5] Fixed failing tests. --- cds-plugin.ts | 37 +++++++++++------------------------- lib/handlers/processStart.ts | 31 +++--------------------------- 2 files changed, 14 insertions(+), 54 deletions(-) diff --git a/cds-plugin.ts b/cds-plugin.ts index 75c0d3e..7bb2b10 100644 --- a/cds-plugin.ts +++ b/cds-plugin.ts @@ -78,24 +78,19 @@ function expandEvent(event: string | undefined, entity: cds.entity): string[] { function buildAnnotationCache(service: cds.Service) { const cache = new Map(); for (const entity of Object.values(service.entities)) { - const startEvent = entity[PROCESS_START_ON]; - const cancelEvent = entity[PROCESS_CANCEL_ON]; - const suspendEvent = entity[PROCESS_SUSPEND_ON]; - const resumeEvent = entity[PROCESS_RESUME_ON]; + const cancelEvent = entity[PROCESS_CANCEL_ON] as string | undefined; + const suspendEvent = entity[PROCESS_SUSPEND_ON] as string | undefined; + const resumeEvent = entity[PROCESS_RESUME_ON] as string | undefined; + // Collect all events that have a start annotation (non-qualified and qualified) const startEventsSet = new Set(); // Non-qualified: @build.process.start: { id, on } const nonQualStartOn = entity[PROCESS_START_ON] as string | undefined; if (nonQualStartOn && entity[PROCESS_START_ID]) { - startEventsSet.add(nonQualStartOn); + for (const ev of expandEvent(nonQualStartOn, entity)) startEventsSet.add(ev); } - const events = new Set(); - for (const ev of expandEvent(startEvent, entity)) events.add(ev); - for (const ev of expandEvent(cancelEvent, entity)) events.add(ev); - for (const ev of expandEvent(suspendEvent, entity)) events.add(ev); - for (const ev of expandEvent(resumeEvent, entity)) events.add(ev); // Qualified: @build.process.start #qualifier: { id, on } // CDS stores as @build.process.start#qualifier.on, @build.process.start#qualifier.id for (const key of Object.keys(entity)) { @@ -105,35 +100,25 @@ function buildAnnotationCache(service: cds.Service) { const onValue = entity[key as keyof typeof entity] as string | undefined; const hasId = !!(entity[`@build.process.start#${qualifier}.id` as keyof typeof entity]); if (onValue && hasId) { - startEventsSet.add(onValue); + for (const ev of expandEvent(onValue, entity)) startEventsSet.add(ev); } } } - const cancelEvent = entity[PROCESS_CANCEL_ON] as string | undefined; - const suspendEvent = entity[PROCESS_SUSPEND_ON] as string | undefined; - const resumeEvent = entity[PROCESS_RESUME_ON] as string | undefined; - // Collect unique events across all annotation types - const events = new Set([ - ...startEventsSet, - ...(cancelEvent ? [cancelEvent] : []), - ...(suspendEvent ? [suspendEvent] : []), - ...(resumeEvent ? [resumeEvent] : []), - ]); + const events = new Set([...startEventsSet]); + for (const ev of expandEvent(cancelEvent, entity)) events.add(ev); + for (const ev of expandEvent(suspendEvent, entity)) events.add(ev); + for (const ev of expandEvent(resumeEvent, entity)) events.add(ev); for (const event of events) { const matchesEvent = (annotationEvent: string | undefined) => annotationEvent === event || annotationEvent === '*'; - const hasStart = !!(matchesEvent(startEvent) && entity[PROCESS_START_ID]); + const hasStart = startEventsSet.has(event); const hasCancel = !!matchesEvent(cancelEvent); const hasSuspend = !!matchesEvent(suspendEvent); const hasResume = !!matchesEvent(resumeEvent); - const hasStart = startEventsSet.has(event); - const hasCancel = cancelEvent === event; - const hasSuspend = suspendEvent === event; - const hasResume = resumeEvent === event; const cacheKey = `${entity.name}:${event}`; cache.set(cacheKey, { diff --git a/lib/handlers/processStart.ts b/lib/handlers/processStart.ts index 106b317..c059d47 100644 --- a/lib/handlers/processStart.ts +++ b/lib/handlers/processStart.ts @@ -72,17 +72,10 @@ export async function handleProcessStart(req: cds.Request): Promise { columns = convertToColumnsExpr(sharedInputs); } - // fetch entity - const row = await resolveEntityRowOrReject( - req, - data, - startSpecs.conditionExpr, - 'Failed to fetch entity for process start.', - LOG_MESSAGES.PROCESS_NOT_STARTED, - columns, - ); - if (!row) return; for (const startSpec of allStartSpecs) { + // Skip specs that don't apply to the current event + if (startSpec.on && startSpec.on !== req.event && startSpec.on !== '*') continue; + // fetch entity (includes per-spec condition check for non-DELETE events) const row = await resolveEntityRowOrReject( req, @@ -94,15 +87,6 @@ export async function handleProcessStart(req: cds.Request): Promise { ); if (!row) continue; // condition not met for this spec — try the next one - // get business key - const businessKey = getBusinessKeyOrReject( - target as cds.entity, - row, - req, - 'Failed to build business key for process start.', - 'Business key is empty for process start.', - ); - if (!businessKey) return; // get business key const businessKey = getBusinessKeyOrReject( target as cds.entity, @@ -115,15 +99,6 @@ export async function handleProcessStart(req: cds.Request): Promise { const context = { ...row, businesskey: businessKey }; - // emit process start - const payload = { definitionId: startSpecs.id!, context }; - await emitProcessEvent( - 'start', - req, - payload, - `Failed to start process with definition ID ${startSpecs.id!}.`, - startSpecs.id!, - ); // emit process start for this spec const payload = { definitionId: startSpec.id!, context }; await emitProcessEvent('start', req, payload, 'PROCESS_START_FAILED', startSpec.id!); From 2d948466d947715f61fed2eb07ad09da7ccd0c34 Mon Sep 17 00:00:00 2001 From: Deblina Sanyal Date: Wed, 4 Mar 2026 15:22:26 +0100 Subject: [PATCH 5/5] Review comments implemented. --- cds-plugin.ts | 12 +++++---- lib/constants.ts | 15 ++++++++--- lib/handlers/processStart.ts | 7 ++--- lib/handlers/utils.ts | 32 +++++++++++++---------- tests/bookshop/srv/annotation-service.cds | 20 +++++++------- 5 files changed, 51 insertions(+), 35 deletions(-) diff --git a/cds-plugin.ts b/cds-plugin.ts index 7bb2b10..60a84b4 100644 --- a/cds-plugin.ts +++ b/cds-plugin.ts @@ -15,6 +15,8 @@ import { PROCESS_RESUME_ON, PROCESS_PREFIX, CUD_EVENTS, + PROCESS_START_QUALIFIER_PREFIX, + PROCESS_START_QUALIFIER_PATTERN, } from './lib/index'; import { importProcess } from './lib/processImport'; @@ -85,20 +87,20 @@ function buildAnnotationCache(service: cds.Service) { // Collect all events that have a start annotation (non-qualified and qualified) const startEventsSet = new Set(); - // Non-qualified: @build.process.start: { id, on } + // Non-qualified: @bpm.process.start: { id, on } const nonQualStartOn = entity[PROCESS_START_ON] as string | undefined; if (nonQualStartOn && entity[PROCESS_START_ID]) { for (const ev of expandEvent(nonQualStartOn, entity)) startEventsSet.add(ev); } - // Qualified: @build.process.start #qualifier: { id, on } - // CDS stores as @build.process.start#qualifier.on, @build.process.start#qualifier.id + // Qualified: @bpm.process.start #qualifier: { id, on } + // CDS stores as @bpm.process.start#qualifier.on, @bpm.process.start#qualifier.id for (const key of Object.keys(entity)) { - const match = key.match(/^@build\.process\.start#(\w+)\.on$/); + const match = key.match(PROCESS_START_QUALIFIER_PATTERN); if (match) { const qualifier = match[1]; const onValue = entity[key as keyof typeof entity] as string | undefined; - const hasId = !!(entity[`@build.process.start#${qualifier}.id` as keyof typeof entity]); + const hasId = !!(entity[`${PROCESS_START_QUALIFIER_PREFIX}${qualifier}.id` as keyof typeof entity]); if (onValue && hasId) { for (const ev of expandEvent(onValue, entity)) startEventsSet.add(ev); } diff --git a/lib/constants.ts b/lib/constants.ts index a124fa7..e8ce081 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -58,10 +58,19 @@ export const PROCESS_PREFIX = '@bpm.process' as const; /** * Qualifier prefix for multiple process start annotations - * Usage: @build.process.start #qualifier: { id: '...', on: '...' } - * Stored in CDS as: @build.process.start#qualifier.id, @build.process.start#qualifier.on, etc. + * Usage: @bpm.process.start #qualifier: { id: '...', on: '...' } + * Stored in CDS as: @bpm.process.start#qualifier.id, @bpm.process.start#qualifier.on, etc. */ -export const PROCESS_START_QUALIFIER_PREFIX = '@build.process.start#' as const; +export const PROCESS_START_QUALIFIER_PREFIX = '@bpm.process.start#' as const; + +/** + * Regex to match qualified @bpm.process.start#qualifier.on keys on a CDS entity. + * Derived from PROCESS_START_QUALIFIER_PREFIX so it stays in sync if the prefix changes. + * Capture group 1: the qualifier name (e.g. "approval" from "@bpm.process.start#approval.on") + */ +export const PROCESS_START_QUALIFIER_PATTERN = new RegExp( + `^${PROCESS_START_QUALIFIER_PREFIX.replace(/\./g, '\\.')}(\\w+)\\.on$`, +); /** * Process Event Annotations (Runtime) diff --git a/lib/handlers/processStart.ts b/lib/handlers/processStart.ts index c059d47..c831bda 100644 --- a/lib/handlers/processStart.ts +++ b/lib/handlers/processStart.ts @@ -18,6 +18,7 @@ import { LOG_MESSAGES, PROCESS_LOGGER_PREFIX, PROCESS_START_QUALIFIER_PREFIX, + PROCESS_START_QUALIFIER_PATTERN, } from './../constants'; import cds from '@sap/cds'; @@ -129,10 +130,10 @@ function getAllStartSpecs(target: Target, req: cds.Request): ProcessStartSpec[] }); } - // Qualified annotations: @build.process.start #qualifier: { id, on, if } - // CDS stores these as @build.process.start#qualifier.id, @build.process.start#qualifier.on, etc. + // Qualified annotations: @bpm.process.start #qualifier: { id, on, if } + // CDS stores these as @bpm.process.start#qualifier.id, @bpm.process.start#qualifier.on, etc. for (const key of Object.keys(entityAnnotations)) { - const match = key.match(/^@build\.process\.start#(\w+)\.on$/); + const match = key.match(PROCESS_START_QUALIFIER_PATTERN); if (match) { const qualifier = match[1]; const prefix = `${PROCESS_START_QUALIFIER_PREFIX}${qualifier}`; diff --git a/lib/handlers/utils.ts b/lib/handlers/utils.ts index 6b80a66..bdb4e5d 100644 --- a/lib/handlers/utils.ts +++ b/lib/handlers/utils.ts @@ -11,6 +11,8 @@ import { PROCESS_START_ON, PROCESS_SUSPEND_IF, PROCESS_SUSPEND_ON, + PROCESS_START_QUALIFIER_PREFIX, + PROCESS_START_QUALIFIER_PATTERN, } from '../constants'; import { getColumnsForProcessStart } from './processStart'; const { SELECT } = cds.ql; @@ -256,20 +258,22 @@ export async function addDeletedEntityToRequest( } } - // Handle qualified start annotations: @build.process.start #qualifier: { on: 'DELETE', if: ... } - // CDS stores as @build.process.start#qualifier.on, @build.process.start#qualifier.if - for (const key of Object.keys(annotatedTarget)) { - const match = key.match(/^@build\.process\.start#(\w+)\.on$/); - if (match && annotatedTarget[key] === 'DELETE') { - const qualifier = match[1]; - const conditionExpr = annotatedTarget[`@build.process.start#${qualifier}.if`] as - | { xpr: expr } - | undefined; - if (conditionExpr) { - where = - Array.isArray(where) && where.length - ? [{ xpr: where }, 'and', { xpr: conditionExpr.xpr }] - : conditionExpr.xpr; + // Handle qualified start annotations: @bpm.process.start #qualifier: { on: 'DELETE', if: ... } + // CDS stores as @bpm.process.start#qualifier.on, @bpm.process.start#qualifier.if + if (areStartAnnotationsDefined) { + for (const key of Object.keys(annotatedTarget)) { + const match = key.match(PROCESS_START_QUALIFIER_PATTERN); + if (match && annotatedTarget[key] === 'DELETE') { + const qualifier = match[1]; + const conditionExpr = annotatedTarget[`${PROCESS_START_QUALIFIER_PREFIX}${qualifier}.if`] as + | { xpr: expr } + | undefined; + if (conditionExpr) { + where = + Array.isArray(where) && where.length + ? [{ xpr: where }, 'and', { xpr: conditionExpr.xpr }] + : conditionExpr.xpr; + } } } } diff --git a/tests/bookshop/srv/annotation-service.cds b/tests/bookshop/srv/annotation-service.cds index 60735f5..da9df38 100644 --- a/tests/bookshop/srv/annotation-service.cds +++ b/tests/bookshop/srv/annotation-service.cds @@ -837,18 +837,18 @@ service AnnotationService { // ============================================ // MULTIPLE START ANNOTATIONS VIA QUALIFIERS - // Testing @build.process.start #qualifier support + // Testing @bpm.process.start #qualifier support // ============================================ // -------------------------------------------- // Test: Two processes start on same event (no conditions) // Both approvalProcess and notificationProcess should start on CREATE // -------------------------------------------- - @build.process.start #approval: { + @bpm.process.start #approval: { id: 'approvalProcess', on: 'CREATE', } - @build.process.start #notification: { + @bpm.process.start #notification: { id: 'notificationProcess', on: 'CREATE', } @@ -865,11 +865,11 @@ service AnnotationService { // Test: Two processes start on different events // approvalProcess starts on CREATE, auditProcess starts on UPDATE // -------------------------------------------- - @build.process.start #approvalCreate: { + @bpm.process.start #approvalCreate: { id: 'approvalProcess', on: 'CREATE', } - @build.process.start #auditUpdate: { + @bpm.process.start #auditUpdate: { id: 'auditProcess', on: 'UPDATE', } @@ -886,12 +886,12 @@ service AnnotationService { // Test: Two processes with conditions on same event // highMileageProcess starts if mileage > 500, lowMileageProcess if mileage <= 500 // -------------------------------------------- - @build.process.start #highMileage: { + @bpm.process.start #highMileage: { id: 'highMileageProcess', on: 'CREATE', if: (mileage > 500) } - @build.process.start #lowMileage: { + @bpm.process.start #lowMileage: { id: 'lowMileageProcess', on: 'CREATE', if: (mileage <= 500) @@ -909,15 +909,15 @@ service AnnotationService { // Test: Three processes start on same event (no conditions) // processA, processB, processC should all start on CREATE // -------------------------------------------- - @build.process.start #procA: { + @bpm.process.start #procA: { id: 'processA', on: 'CREATE', } - @build.process.start #procB: { + @bpm.process.start #procB: { id: 'processB', on: 'CREATE', } - @build.process.start #procC: { + @bpm.process.start #procC: { id: 'processC', on: 'CREATE', }