diff --git a/cds-plugin.ts b/cds-plugin.ts index 61f55f5..cda8726 100644 --- a/cds-plugin.ts +++ b/cds-plugin.ts @@ -16,6 +16,8 @@ import { PROCESS_PREFIX, CUD_EVENTS, EntityRow, + PROCESS_START_QUALIFIER_PREFIX, + PROCESS_START_QUALIFIER_PATTERN, } from './lib/index'; import { importProcess } from './lib/processImport'; @@ -92,13 +94,35 @@ 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; - const events = new Set(); - for (const ev of expandEvent(startEvent, entity)) events.add(ev); + // Collect all events that have a start annotation (non-qualified and qualified) + const startEventsSet = new Set(); + + // 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: @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(PROCESS_START_QUALIFIER_PATTERN); + if (match) { + const qualifier = match[1]; + const onValue = entity[key as keyof typeof entity] as string | undefined; + 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); + } + } + } + + // Collect unique events across all annotation types + 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); @@ -107,7 +131,7 @@ function buildAnnotationCache(service: cds.Service) { 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); diff --git a/lib/constants.ts b/lib/constants.ts index c619bb0..e8ce081 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -56,6 +56,22 @@ export const PROCESS_INPUT = '@bpm.process.input' as const; export const BUILD_PREFIX = '@bpm' as const; export const PROCESS_PREFIX = '@bpm.process' as const; +/** + * Qualifier prefix for multiple process start annotations + * 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 = '@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) * These annotations are used on dynamically created ProcessService events diff --git a/lib/handlers/processStart.ts b/lib/handlers/processStart.ts index f6b3ea4..d0f8b1b 100644 --- a/lib/handlers/processStart.ts +++ b/lib/handlers/processStart.ts @@ -17,6 +17,8 @@ import { PROCESS_INPUT, LOG_MESSAGES, PROCESS_LOGGER_PREFIX, + PROCESS_START_QUALIFIER_PREFIX, + PROCESS_START_QUALIFIER_PATTERN, } from './../constants'; import cds from '@sap/cds'; @@ -39,12 +41,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); } } @@ -55,70 +57,108 @@ export async function handleProcessStart(req: cds.Request, data: EntityRow): Pro data = ((req as ProcessDeleteRequest)._Process ?? getEntityDataFromRequest(data, req.params)) 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) { + // 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, + 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: @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(PROCESS_START_QUALIFIER_PATTERN); + 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, - 'Failed to fetch entity for process start.', - LOG_MESSAGES.PROCESS_NOT_STARTED, - columns, - ); - if (!row) return; - - // 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; - - 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!, - ); + 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 1a58128..e67c148 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; @@ -262,6 +264,26 @@ export async function addDeletedEntityToRequest( } } + // 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; + } + } + } + } + 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 6e16bf4..da9df38 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) @(bpm.process.input: 'Price'); } + // ============================================ + // MULTIPLE START ANNOTATIONS VIA QUALIFIERS + // Testing @bpm.process.start #qualifier support + // ============================================ + + // -------------------------------------------- + // Test: Two processes start on same event (no conditions) + // Both approvalProcess and notificationProcess should start on CREATE + // -------------------------------------------- + @bpm.process.start #approval: { + id: 'approvalProcess', + on: 'CREATE', + } + @bpm.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 + // -------------------------------------------- + @bpm.process.start #approvalCreate: { + id: 'approvalProcess', + on: 'CREATE', + } + @bpm.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 + // -------------------------------------------- + @bpm.process.start #highMileage: { + id: 'highMileageProcess', + on: 'CREATE', + if: (mileage > 500) + } + @bpm.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 + // -------------------------------------------- + @bpm.process.start #procA: { + id: 'processA', + on: 'CREATE', + } + @bpm.process.start #procB: { + id: 'processB', + on: 'CREATE', + } + @bpm.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 @bpm.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); + } + }); + }); + }); });