diff --git a/CHANGELOG.md b/CHANGELOG.md index 96bec8fa..1d72e55e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Reducing the context sent to the workflow API to only needed fields - Allowing the import of downloaded process models +- Support for multiple start annotations on the same entity using qualifier ## Version 0.1.1 - 2026-03-27 diff --git a/README.md b/README.md index 6340d6f8..f1f656f2 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ CAP Plugin to interact with SAP Build Process Automation to manage processes. - [Importing Processes as a Service](#importing-processes-as-a-service) - [Annotations](#annotations) - [Starting a Process](#starting-a-process) + - [Multiple Start Annotations](#multiple-start-annotations) - [Cancelling, Resuming, or Suspending a Process](#cancelling-resuming-or-suspending-a-process) - [Conditional Execution](#conditional-execution) - [Input Mapping](#input-mapping) @@ -100,6 +101,34 @@ service MyService { > See [Input Mapping](#input-mapping) below for detailed examples on controlling which entity fields are passed as process context. +#### Multiple Start Annotations + +To start multiple processes from the same entity, use CDS qualifiers (`#qualifier`) to distinguish them. Each qualified annotation is evaluated independently, so each can have its own `id`, `on`, `if`, and `inputs`. + +```cds +service MyService { + + @bpm.process.start #orderProcess : { + id: 'orderProcess', + on: 'CREATE', + } + @bpm.process.start #notificationProcess : { + id: 'notificationProcess', + on: 'CREATE', + if: (field3 > 10) + } + entity MyEntity { + key ID : UUID; + field1 : String; + field2 : String; + field3 : Integer; + }; + +} +``` + +Both processes are started when a `CREATE` event occurs on the entity, but `notificationProcess`is only stated if field3 is > 10. You can also combine qualifiers with different events or conditions. + ### Cancelling, Resuming, or Suspending a Process - `@bpm.process.` -- Cancel/Suspend/Resume any processes with the given businessKey diff --git a/lib/build/plugin.ts b/lib/build/plugin.ts index aef5f34c..2e61f84e 100644 --- a/lib/build/plugin.ts +++ b/lib/build/plugin.ts @@ -12,28 +12,28 @@ import { validateBusinessKeyAnnotation, } from './index'; import { - PROCESS_START_ID, - PROCESS_START_ON, - PROCESS_START_IF, - PROCESS_START_INPUTS, + PROCESS_START, + PROCESS_CANCEL, PROCESS_CANCEL_ON, PROCESS_CANCEL_CASCADE, PROCESS_CANCEL_IF, - PROCESS_SUSPEND_ON, - PROCESS_SUSPEND_CASCADE, - PROCESS_SUSPEND_IF, + PROCESS_RESUME, PROCESS_RESUME_ON, PROCESS_RESUME_CASCADE, PROCESS_RESUME_IF, - PROCESS_CANCEL, PROCESS_SUSPEND, - PROCESS_RESUME, - PROCESS_START, + PROCESS_SUSPEND_ON, + PROCESS_SUSPEND_CASCADE, + PROCESS_SUSPEND_IF, PROCESS_PREFIX, + SUFFIX_ID, + SUFFIX_ON, + SUFFIX_IF, + SUFFIX_INPUTS, BUSINESS_KEY, } from '../constants'; - import { CsnDefinition, CsnEntity } from '../types/csn-extensions'; +import { getAnnotationPrefixes } from '../shared/annotations-helper'; /** * Configuration for lifecycle annotation validation (cancel, suspend, resume) @@ -131,40 +131,52 @@ export class ProcessValidationPlugin extends BuildPluginBase { processDefinitions: Map, allDefinitions: Record, ) { - // check unknown annotations - const allowedAnnotations = [ - PROCESS_START_ID, - PROCESS_START_ON, - PROCESS_START_IF, - PROCESS_START_INPUTS, - ]; - validateAllowedAnnotations(allowedAnnotations, def, entityName, PROCESS_START, this); - - const hasId = def[PROCESS_START_ID] !== undefined; - const hasOn = def[PROCESS_START_ON] !== undefined; - const hasIf = def[PROCESS_START_IF] !== undefined; - - // required fields - validateRequiredStartAnnotations(hasOn, hasId, entityName, this); - - const processDef = processDefinitions.get(def[PROCESS_START_ID]); - - if (hasId) { - validateIdAnnotation(def, entityName, processDef, this); - } + const startPrefixes = getAnnotationPrefixes(def, PROCESS_START); - if (hasOn) { - validateOnAnnotation(def, entityName, PROCESS_START_ON, this); - } + for (const prefix of startPrefixes) { + const annotationId = `${prefix}${SUFFIX_ID}` as `@${string}`; + const annotationOn = `${prefix}${SUFFIX_ON}` as `@${string}`; + const annotationIf = `${prefix}${SUFFIX_IF}` as `@${string}`; + const annotationInputs = `${prefix}${SUFFIX_INPUTS}` as `@${string}`; - if (hasIf) { - validateIfAnnotation(def, entityName, PROCESS_START_IF, this); - } + // check unknown annotations for this prefix + const allowedAnnotations = [annotationId, annotationOn, annotationIf, annotationInputs]; + validateAllowedAnnotations(allowedAnnotations, def, entityName, prefix, this); + + const hasId = def[annotationId] !== undefined; + const hasOn = def[annotationOn] !== undefined; + const hasIf = def[annotationIf] !== undefined; + + // required fields + validateRequiredStartAnnotations(hasOn, hasId, entityName, annotationOn, annotationId, this); + + const processDef = processDefinitions.get(def[annotationId]); + + if (hasId) { + validateIdAnnotation(def, entityName, annotationId, processDef, this); + } + + if (hasOn) { + validateOnAnnotation(def, entityName, annotationOn, this); + } - if (hasId && hasOn && processDef) { - const processInputs = allDefinitions[`${processDef.name}.ProcessInputs`]; - if (typeof processInputs !== 'undefined') { - validateInputTypes(this, entityName, def, processInputs, allDefinitions); + if (hasIf) { + validateIfAnnotation(def, entityName, annotationIf, this); + } + + if (hasId && hasOn && processDef) { + const processInputs = allDefinitions[`${processDef.name}.ProcessInputs`]; + if (typeof processInputs !== 'undefined') { + validateInputTypes( + this, + entityName, + def, + processInputs, + allDefinitions, + annotationInputs, + annotationId, + ); + } } } } diff --git a/lib/build/validation-utils.ts b/lib/build/validation-utils.ts index ae7f1861..4a8fba51 100644 --- a/lib/build/validation-utils.ts +++ b/lib/build/validation-utils.ts @@ -1,5 +1,5 @@ import { CsnDefinition, CsnElement, CsnEntity } from '../types/csn-extensions'; -import { PROCESS_PREFIX, PROCESS_START_INPUTS } from '../constants'; +import { PROCESS_PREFIX } from '../constants'; import { InputCSNEntry, InputTreeNode, @@ -272,8 +272,11 @@ export function getProcessDefinitions( return processMap; } -export function getParsedInputEntries(def: CsnEntity): ParsedInputEntry[] | undefined { - const inputsCSN = def[PROCESS_START_INPUTS] as InputCSNEntry[] | undefined; +export function getParsedInputEntries( + def: CsnEntity, + inputsAnnotationKey: `@${string}`, +): ParsedInputEntry[] | undefined { + const inputsCSN = def[inputsAnnotationKey] as InputCSNEntry[] | undefined; if (!inputsCSN || inputsCSN.length === 0) { return undefined; } diff --git a/lib/build/validations.ts b/lib/build/validations.ts index 18352c07..51eca1e1 100644 --- a/lib/build/validations.ts +++ b/lib/build/validations.ts @@ -1,7 +1,7 @@ import cds from '@sap/cds'; import { ProcessValidationPlugin } from './plugin'; import { CsnDefinition, CsnElement, CsnEntity } from '../types/csn-extensions'; -import { BUSINESS_KEY, PROCESS_START_ID, PROCESS_START_ON } from '../constants'; +import { BUSINESS_KEY } from '../constants'; import { createCsnEntityContext, ElementType, @@ -110,17 +110,19 @@ export function validateRequiredStartAnnotations( hasOn: boolean, hasId: boolean, entityName: string, + annotationOn: string, + annotationId: string, buildPlugin: ProcessValidationPlugin, ) { if (hasOn && !hasId) { buildPlugin.pushMessage( - ERROR_START_ON_REQUIRES_ID(entityName, PROCESS_START_ON, PROCESS_START_ID), + ERROR_START_ON_REQUIRES_ID(entityName, annotationOn, annotationId), ERROR, ); } if (hasId && !hasOn) { buildPlugin.pushMessage( - ERROR_START_ID_REQUIRES_ON(entityName, PROCESS_START_ID, PROCESS_START_ON), + ERROR_START_ID_REQUIRES_ON(entityName, annotationId, annotationOn), ERROR, ); } @@ -129,16 +131,17 @@ export function validateRequiredStartAnnotations( export function validateIdAnnotation( def: CsnEntity, entityName: string, + annotationId: `@${string}`, processDef: CsnDefinition | undefined, buildPlugin: ProcessValidationPlugin, ) { - if (typeof def[PROCESS_START_ID] !== 'string') { - buildPlugin.pushMessage(ERROR_START_ID_MUST_BE_STRING(entityName, PROCESS_START_ID), ERROR); + if (typeof def[annotationId] !== 'string') { + buildPlugin.pushMessage(ERROR_START_ID_MUST_BE_STRING(entityName, annotationId), ERROR); } if (!processDef) { buildPlugin.pushMessage( - WARNING_NO_PROCESS_DEFINITION(entityName, PROCESS_START_ID, def[PROCESS_START_ID]), + WARNING_NO_PROCESS_DEFINITION(entityName, annotationId, def[annotationId]), WARNING, ); } @@ -169,8 +172,10 @@ export function validateInputTypes( def: CsnDefinition, processDef: CsnDefinition, allDefinitions: Record | undefined, + inputsAnnotationKey: `@${string}`, + idAnnotationKey: `@${string}`, ) { - const parsedEntries = getParsedInputEntries(def as CsnEntity); + const parsedEntries = getParsedInputEntries(def as CsnEntity, inputsAnnotationKey); const elements = (def as CsnEntity).elements ?? {}; const entityContext = createCsnEntityContext( elements as Record, @@ -196,7 +201,7 @@ export function validateInputTypes( entityName, entityAttributes, processDefInputs, - def[PROCESS_START_ID], + (def as CsnEntity)[idAnnotationKey], ); } diff --git a/lib/constants.ts b/lib/constants.ts index 9a0b049a..355b6487 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -25,37 +25,41 @@ export const BUSINESS_KEY_MAX_LENGTH = 255; export const BUSINESS_KEY_ALIAS = 'as businessKey' as const; /** - * Process Start Annotations + * Process annotation base prefixes */ export const PROCESS_START = '@bpm.process.start' as const; -export const PROCESS_START_ID = '@bpm.process.start.id' as const; -export const PROCESS_START_ON = '@bpm.process.start.on' as const; -export const PROCESS_START_IF = '@bpm.process.start.if' as const; -export const PROCESS_START_INPUTS = '@bpm.process.start.inputs' as const; - -/** - * Process Cancel Annotations - */ export const PROCESS_CANCEL = '@bpm.process.cancel' as const; -export const PROCESS_CANCEL_ON = '@bpm.process.cancel.on' as const; -export const PROCESS_CANCEL_CASCADE = '@bpm.process.cancel.cascade' as const; -export const PROCESS_CANCEL_IF = '@bpm.process.cancel.if' as const; +export const PROCESS_SUSPEND = '@bpm.process.suspend' as const; +export const PROCESS_RESUME = '@bpm.process.resume' as const; /** - * Process Suspend Annotations + * Annotation property suffixes. */ -export const PROCESS_SUSPEND = '@bpm.process.suspend' as const; -export const PROCESS_SUSPEND_ON = '@bpm.process.suspend.on' as const; -export const PROCESS_SUSPEND_CASCADE = '@bpm.process.suspend.cascade' as const; -export const PROCESS_SUSPEND_IF = '@bpm.process.suspend.if' as const; +export const SUFFIX_ID = '.id' as const; +export const SUFFIX_ON = '.on' as const; +export const SUFFIX_IF = '.if' as const; +export const SUFFIX_CASCADE = '.cascade' as const; +export const SUFFIX_INPUTS = '.inputs' as const; /** - * Process Resume Annotations + * Derived full-path annotation keys (unqualified). */ -export const PROCESS_RESUME = '@bpm.process.resume' as const; -export const PROCESS_RESUME_ON = '@bpm.process.resume.on' as const; -export const PROCESS_RESUME_CASCADE = '@bpm.process.resume.cascade' as const; -export const PROCESS_RESUME_IF = '@bpm.process.resume.if' as const; +export const PROCESS_START_ID = `${PROCESS_START}${SUFFIX_ID}` as const; +export const PROCESS_START_ON = `${PROCESS_START}${SUFFIX_ON}` as const; +export const PROCESS_START_IF = `${PROCESS_START}${SUFFIX_IF}` as const; +export const PROCESS_START_INPUTS = `${PROCESS_START}${SUFFIX_INPUTS}` as const; + +export const PROCESS_CANCEL_ON = `${PROCESS_CANCEL}${SUFFIX_ON}` as const; +export const PROCESS_CANCEL_CASCADE = `${PROCESS_CANCEL}${SUFFIX_CASCADE}` as const; +export const PROCESS_CANCEL_IF = `${PROCESS_CANCEL}${SUFFIX_IF}` as const; + +export const PROCESS_SUSPEND_ON = `${PROCESS_SUSPEND}${SUFFIX_ON}` as const; +export const PROCESS_SUSPEND_CASCADE = `${PROCESS_SUSPEND}${SUFFIX_CASCADE}` as const; +export const PROCESS_SUSPEND_IF = `${PROCESS_SUSPEND}${SUFFIX_IF}` as const; + +export const PROCESS_RESUME_ON = `${PROCESS_RESUME}${SUFFIX_ON}` as const; +export const PROCESS_RESUME_CASCADE = `${PROCESS_RESUME}${SUFFIX_CASCADE}` as const; +export const PROCESS_RESUME_IF = `${PROCESS_RESUME}${SUFFIX_IF}` as const; /** * Annotation prefix for filtering diff --git a/lib/handlers/annotationCache.ts b/lib/handlers/annotationCache.ts index da027a84..08d98332 100644 --- a/lib/handlers/annotationCache.ts +++ b/lib/handlers/annotationCache.ts @@ -1,13 +1,7 @@ import cds from '@sap/cds'; import { EntityEventCache } from '../types/cds-plugin'; -import { - PROCESS_START_ID, - PROCESS_START_ON, - PROCESS_CANCEL_ON, - PROCESS_SUSPEND_ON, - PROCESS_RESUME_ON, - CUD_EVENTS, -} from '../constants'; +import { CUD_EVENTS, PROCESS_CANCEL_ON, PROCESS_SUSPEND_ON, PROCESS_RESUME_ON } from '../constants'; +import { findStartAnnotations } from '../shared/annotations-helper'; function expandEvent(event: string | undefined, entity: cds.entity): string[] { if (!event) return []; @@ -21,13 +15,15 @@ function expandEvent(event: string | undefined, entity: cds.entity): string[] { export function buildAnnotationCache(service: cds.Service) { const cache = new Map(); for (const entity of Object.values(service.entities)) { - const startEvent = entity[PROCESS_START_ON]; + const startAnnotations = findStartAnnotations(entity); const cancelEvent = entity[PROCESS_CANCEL_ON]; const suspendEvent = entity[PROCESS_SUSPEND_ON]; const resumeEvent = entity[PROCESS_RESUME_ON]; const events = new Set(); - for (const ev of expandEvent(startEvent, entity)) events.add(ev); + for (const ann of startAnnotations) { + for (const ev of expandEvent(ann.on, 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); @@ -36,14 +32,15 @@ export function buildAnnotationCache(service: cds.Service) { const matchesEvent = (annotationEvent: string | undefined) => annotationEvent === event || annotationEvent === '*'; - const hasStart = !!(matchesEvent(startEvent) && entity[PROCESS_START_ID]); + // Filter annotations to those matching this event + const matchingStarts = startAnnotations.filter((ann) => matchesEvent(ann.on)); const hasCancel = !!matchesEvent(cancelEvent); const hasSuspend = !!matchesEvent(suspendEvent); const hasResume = !!matchesEvent(resumeEvent); const cacheKey = `${entity.name}:${event}`; cache.set(cacheKey, { - hasStart, + startAnnotations: matchingStarts, hasCancel, hasSuspend, hasResume, diff --git a/lib/handlers/annotationHandlers.ts b/lib/handlers/annotationHandlers.ts index 417bbd2c..e8ed543b 100644 --- a/lib/handlers/annotationHandlers.ts +++ b/lib/handlers/annotationHandlers.ts @@ -7,12 +7,11 @@ import { handleProcessSuspend, buildAnnotationCache, EntityRow, + prefetchStartDataForDelete, + ProcessDeleteRequest, addDeletedEntityToRequestCancel, - addDeletedEntityToRequestStart, - addDeletedEntityToRequestStartBusinessKey, addDeletedEntityToRequestResume, addDeletedEntityToRequestSuspend, - ProcessDeleteRequest, } from '../handlers'; export function registerAnnotationHandlers(service: cds.Service) { @@ -25,10 +24,11 @@ export function registerAnnotationHandlers(service: cds.Service) { const cached = annotationCache.get(cacheKey); if (!cached) return; + const hasStart = cached.startAnnotations.length > 0; + const results = await Promise.all( [ - cached.hasStart && addDeletedEntityToRequestStart(req), - cached.hasStart && addDeletedEntityToRequestStartBusinessKey(req), + hasStart && prefetchStartDataForDelete(req, cached.startAnnotations), cached.hasCancel && addDeletedEntityToRequestCancel(req), cached.hasResume && addDeletedEntityToRequestResume(req), cached.hasSuspend && addDeletedEntityToRequestSuspend(req), @@ -58,9 +58,10 @@ async function dispatchProcessHandlers( req: cds.Request, data: EntityRow, ) { - if (cached.hasStart) { - await handleProcessStart(req, data); - } + await Promise.all( + cached.startAnnotations.map((startAnn) => handleProcessStart(req, data, startAnn)), + ); + if (cached.hasCancel) { await handleProcessCancel(req, data); } diff --git a/lib/handlers/index.ts b/lib/handlers/index.ts index 0bff6a89..d9768728 100644 --- a/lib/handlers/index.ts +++ b/lib/handlers/index.ts @@ -1,9 +1,4 @@ -export { - handleProcessStart, - getColumnsForProcessStart, - addDeletedEntityToRequestStart, - addDeletedEntityToRequestStartBusinessKey, -} from './processStart'; +export { handleProcessStart, prefetchStartDataForDelete } from './processStart'; export { handleProcessCancel, addDeletedEntityToRequestCancel } from './processCancel'; export { handleProcessSuspend, addDeletedEntityToRequestSuspend } from './processSuspend'; export { handleProcessResume, addDeletedEntityToRequestResume } from './processResume'; diff --git a/lib/handlers/onDeleteUtils.ts b/lib/handlers/onDeleteUtils.ts index eb1cd1cf..d205f63c 100644 --- a/lib/handlers/onDeleteUtils.ts +++ b/lib/handlers/onDeleteUtils.ts @@ -1,6 +1,6 @@ import cds, { column_expr, expr, Results } from '@sap/cds'; -import { PROCESS_LOGGER_PREFIX } from '../constants'; import { EntityRow } from './utils'; +import { PROCESS_LOGGER_PREFIX } from '../constants'; import { WILDCARD } from '../shared/input-parser'; const LOG = cds.log(PROCESS_LOGGER_PREFIX); @@ -24,14 +24,14 @@ export interface ProcessDeleteRequest extends cds.Request { } type DeleteProcessObject = { - Start?: Results; - StartBusinessKey?: Results; + Start?: Map; + StartBusinessKey?: Map; Cancel?: Results; Suspend?: Results; Resume?: Results; }; -function buildWhereDeleteExpression( +export function buildWhereDeleteExpression( req: ProcessDeleteRequest, conditionExpr: { xpr: expr } | undefined, ): unknown { diff --git a/lib/handlers/processActionHandler.ts b/lib/handlers/processActionHandler.ts index 9010ae5b..2c832b8c 100644 --- a/lib/handlers/processActionHandler.ts +++ b/lib/handlers/processActionHandler.ts @@ -1,5 +1,4 @@ -import cds from '@sap/cds'; -import { expr, Target } from '@sap/cds'; +import cds, { expr, Target } from '@sap/cds'; import { emitProcessEvent, EntityRow, @@ -25,6 +24,13 @@ interface ProcessActionSpec { businessKey: string | undefined; } +interface ProcessActionDeleteConfig { + action: ProcessActionType; + annotations: { + IF: string; + }; +} + interface ProcessActionConfig { action: ProcessActionType; annotations: { @@ -40,12 +46,6 @@ interface ProcessActionConfig { FAILED: string; }; } -interface ProcessActionDeleteConfig { - action: ProcessActionType; - annotations: { - IF: string; - }; -} function initSpecs( target: Target, @@ -89,7 +89,6 @@ export function createProcessActionHandler(config: ProcessActionConfig) { if (!row) return; // Emit process event - const payload: ProcessLifecyclePayload = { businessKey: (row as { businessKey: string }).businessKey, cascade: specs.cascade, diff --git a/lib/handlers/processStart.ts b/lib/handlers/processStart.ts index 135e7688..f83306cf 100644 --- a/lib/handlers/processStart.ts +++ b/lib/handlers/processStart.ts @@ -1,4 +1,4 @@ -import { column_expr, expr, Target } from '@sap/cds'; +import { column_expr, Target } from '@sap/cds'; import * as csn from '../types/csn-extensions'; import { emitProcessEvent, @@ -7,14 +7,9 @@ import { resolveEntityRowOrReject, } from './utils'; import { - PROCESS_START_ID, - PROCESS_START_ON, - PROCESS_START_IF, - PROCESS_START_INPUTS, LOG_MESSAGES, PROCESS_LOGGER_PREFIX, PROCESS_PREFIX, - BUSINESS_KEY, BUSINESS_KEY_MAX_LENGTH, } from './../constants'; import { @@ -25,145 +20,193 @@ import { EntityContext, WILDCARD, } from '../shared/input-parser'; - import cds from '@sap/cds'; -import { - createAddDeletedEntityHandler, - isDeleteWithoutProcess, - PROCESS_EVENT_MAP, - ProcessDeleteRequest, -} from './onDeleteUtils'; +import { buildWhereDeleteExpression, ProcessDeleteRequest } from './onDeleteUtils'; import { getBusinessKeyColumn } from '../shared/businessKey-helper'; +import { StartAnnotationDescriptor } from '../types/cds-plugin'; + const LOG = cds.log(PROCESS_LOGGER_PREFIX); // Use InputTreeNode as ProcessStartInput (same structure) type ProcessStartInput = InputTreeNode; -export type ProcessStartSpec = { - id?: string; - on?: string; - inputs: ProcessStartInput[]; - conditionExpr: expr | undefined; -}; - -export function getColumnsForProcessStart(target: Target): (column_expr | string)[] { - const startSpecs = initStartSpecs(target); - startSpecs.inputs = parseInputToTree(target); - if (startSpecs.inputs.length === 0) { +function getColumnsForDescriptor( + startAnnotation: StartAnnotationDescriptor, + target: Target, +): (column_expr | string)[] { + const inputs = parseInputToTreeFromInputs(startAnnotation.inputs, target); + if (inputs.length === 0) { LOG.debug(LOG_MESSAGES.PROCESS_INPUTS_FROM_DEFINITION); - return resolveColumnsFromProcessDefinition(startSpecs.id!, target); + if (startAnnotation.id) { + return resolveColumnsFromProcessDefinition(startAnnotation.id, target); + } + return [WILDCARD]; } + return convertToColumnsExpr(inputs); +} + +/** + * Resolves business key value for process start + * rejects if business key value exceeds maximum length + * + */ +async function resolveBusinessKeyValue( + req: cds.Request, + data: EntityRow, + startAnnotation: StartAnnotationDescriptor, + qualifierKey: string, +): Promise { + const businessKeyColumn = getBusinessKeyColumn(startAnnotation.businessKey); + if (!businessKeyColumn) return undefined; - return convertToColumnsExpr(startSpecs.inputs); + let businessKeyValue: string | undefined; + if (req.event === 'DELETE') { + const businessKeyData = getDeletePrefetchedBusinessKey(req, qualifierKey); + businessKeyValue = businessKeyData?.businessKey as string | undefined; + } else { + const businessKeyRow = await resolveEntityRowOrReject( + req, + data, + startAnnotation.conditionExpr, + 'Failed to fetch business key for process start.', + LOG_MESSAGES.PROCESS_NOT_STARTED, + [businessKeyColumn], + ); + businessKeyValue = businessKeyRow?.businessKey as string | undefined; + } + + if (businessKeyValue && businessKeyValue.length > BUSINESS_KEY_MAX_LENGTH) { + const msg = `Business key value exceeds maximum length of ${BUSINESS_KEY_MAX_LENGTH} characters. Process start will fail.`; + LOG.error(msg); + return req.reject({ status: 400, message: msg }); + } + return businessKeyValue; } -export async function handleProcessStart(req: cds.Request, data: EntityRow): Promise { - if (isDeleteWithoutProcess(req, LOG_MESSAGES.PROCESS_NOT_STARTED, 'start')) return; +/** + * Returns the pre-fetched entity data for a given start qualifier on DELETE, + * or undefined if the condition was not met / no data was pre-fetched. + */ +function getDeletePrefetchedStart(req: cds.Request, qualifierKey: string): EntityRow | undefined { + return (req as ProcessDeleteRequest)._Process?.Start?.get(qualifierKey) as EntityRow | undefined; +} - const target = req.target as Target; - const processEventKey = PROCESS_EVENT_MAP['start']; - data = ((req as ProcessDeleteRequest)._Process?.[processEventKey] ?? - getEntityDataFromRequest(data, req.params)) as EntityRow; - - const startSpecs = initStartSpecs(target); - startSpecs.inputs = parseInputToTree(target); - - // if startSpecs.input = [] --> no input annotation defined, resolve from process definition - let columns: (column_expr | string)[]; - if (startSpecs.inputs.length === 0) { - if (startSpecs.id) { - columns = resolveColumnsFromProcessDefinition(startSpecs.id, target); - } else { - columns = [WILDCARD]; +/** + * Returns the pre-fetched business key data for a given start qualifier on DELETE, + * or undefined if no business key was pre-fetched. + */ +function getDeletePrefetchedBusinessKey( + req: cds.Request, + qualifierKey: string, +): EntityRow | undefined { + return (req as ProcessDeleteRequest)._Process?.StartBusinessKey?.get(qualifierKey) as + | EntityRow + | undefined; +} + +export async function handleProcessStart( + req: cds.Request, + data: EntityRow, + startAnnotation: StartAnnotationDescriptor, +): Promise { + const qualifierKey = startAnnotation.qualifier ?? ''; + + // For DELETE: use pre-fetched data for this qualifier; for other events: resolve from request + if (req.event === 'DELETE') { + const prefetched = getDeletePrefetchedStart(req, qualifierKey); + if (!prefetched) { + LOG.debug(LOG_MESSAGES.PROCESS_NOT_STARTED); + return; } - LOG.debug(LOG_MESSAGES.PROCESS_INPUTS_FROM_DEFINITION); + data = prefetched; } else { - columns = convertToColumnsExpr(startSpecs.inputs); + data = getEntityDataFromRequest(data, req.params) as EntityRow; } - const businessKeyColumn = getBusinessKeyColumn((target[BUSINESS_KEY] as { '=': string })?.['=']); + const target = req.target as Target; + const columns = getColumnsForDescriptor(startAnnotation, target); // fetch entity data (without businessKey to avoid alias collision) const row = await resolveEntityRowOrReject( req, data, - startSpecs.conditionExpr, + startAnnotation.conditionExpr, 'Failed to fetch entity for process start.', LOG_MESSAGES.PROCESS_NOT_STARTED, columns, ); if (!row) return; - let businessKeyValue: string | undefined; - if (businessKeyColumn) { - if (req.event === 'DELETE') { - const businessKeyData = (req as ProcessDeleteRequest)._Process?.[ - PROCESS_EVENT_MAP['startBusinessKey'] - ] as EntityRow | undefined; - businessKeyValue = businessKeyData?.businessKey as string | undefined; - } else { - const businessKeyRow = await resolveEntityRowOrReject( - req, - data, - startSpecs.conditionExpr, - 'Failed to fetch business key for process start.', - LOG_MESSAGES.PROCESS_NOT_STARTED, - [businessKeyColumn], - ); - businessKeyValue = businessKeyRow?.businessKey as string | undefined; - } - - if (businessKeyValue && businessKeyValue.length > BUSINESS_KEY_MAX_LENGTH) { - const msg = `Business key value exceeds maximum length of ${BUSINESS_KEY_MAX_LENGTH} characters. Process start will fail.`; - LOG.error(msg); - return req.reject({ status: 400, message: msg }); - } - } + const businessKeyValue = await resolveBusinessKeyValue(req, data, startAnnotation, qualifierKey); // emit process start - const payload = { definitionId: startSpecs.id!, context: row }; + const payload = { definitionId: startAnnotation.id, context: row }; await emitProcessEvent( 'start', req, payload, - `Failed to start process with definition ID ${startSpecs.id!}.`, + `Failed to start process with definition ID ${startAnnotation.id!}.`, businessKeyValue, ); } /** - * Fetches and attaches entity data to the request for DELETE operations + * Pre-fetches entity data and business key for all start annotations before DELETE. + * Returns a partial _Process object with Maps keyed by qualifier ('' for unqualified). */ -export const addDeletedEntityToRequestStart = createAddDeletedEntityHandler({ - action: 'start', - ifAnnotation: PROCESS_START_IF, - getColumns: (req) => getColumnsForProcessStart(req.target as Target), -}); +export async function prefetchStartDataForDelete( + req: cds.Request, + startAnnotations: StartAnnotationDescriptor[], +): Promise>> { + const target = req.target as Target; + const deleteReq = req as ProcessDeleteRequest; + + const startMap = new Map(); + const businessKeyMap = new Map(); + + await Promise.all( + startAnnotations.map(async (ann) => { + const qualifierKey = ann.qualifier ?? ''; + const conditionExpr = ann.conditionExpr ? { xpr: ann.conditionExpr } : undefined; + const where = buildWhereDeleteExpression(deleteReq, conditionExpr); + if (!where) return; + + // Fetch entity data columns for this annotation + const columns = getColumnsForDescriptor(ann, target); + const selectColumns = columns.length > 0 ? columns : [WILDCARD]; + const entity = await SELECT.one.from(req.subject).columns(selectColumns).where(where); + if (entity) { + if (startMap.has(qualifierKey)) { + LOG.warn( + `Duplicate start annotation qualifier '${qualifierKey}' detected; the previous prefetch will be overwritten.`, + ); + } + + startMap.set(qualifierKey, entity); + } -/** - * Fetches and attaches businessKey data separately for DELETE operations - * to avoid alias collision with entity fields named "businessKey" - */ -export const addDeletedEntityToRequestStartBusinessKey = createAddDeletedEntityHandler({ - action: 'startBusinessKey', - ifAnnotation: PROCESS_START_IF, - getColumns: (req) => { - const target = req.target as Target; - const businessKeyCol = getBusinessKeyColumn((target[BUSINESS_KEY] as { '=': string })?.['=']); - return businessKeyCol ? [businessKeyCol] : []; - }, -}); - -function initStartSpecs(target: Target): 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, - }; - return startSpecs; + // Fetch business key separately (to avoid alias collision) + const businessKeyColumn = getBusinessKeyColumn(ann.businessKey); + if (businessKeyColumn) { + const bkEntity = await SELECT.one + .from(req.subject) + .columns([businessKeyColumn]) + .where(where); + if (bkEntity) { + businessKeyMap.set(qualifierKey, bkEntity); + } + } + }), + ); + + const result: Record> = {}; + if (startMap.size > 0) { + result.Start = startMap; + } + if (businessKeyMap.size > 0) { + result.StartBusinessKey = businessKeyMap; + } + return result; } /** @@ -188,8 +231,14 @@ function createRuntimeEntityContext(entity: cds.entity): EntityContext { }; } -function parseInputToTree(target: Target): ProcessStartInput[] { - const inputsCSN = target[PROCESS_START_INPUTS] as InputCSNEntry[] | undefined; +/** + * Parses inputs from a raw InputCSNEntry array (from the annotation descriptor) + * and builds the input tree against the entity context. + */ +function parseInputToTreeFromInputs( + inputsCSN: InputCSNEntry[] | undefined, + target: Target, +): ProcessStartInput[] { const parsedEntries = parseInputsArray(inputsCSN); const runtimeContext = createRuntimeEntityContext(target as cds.entity); return buildInputTree(parsedEntries, runtimeContext); diff --git a/lib/shared/annotations-helper.ts b/lib/shared/annotations-helper.ts new file mode 100644 index 00000000..31f233bd --- /dev/null +++ b/lib/shared/annotations-helper.ts @@ -0,0 +1,71 @@ +import cds, { expr } from '@sap/cds'; +import { CsnEntity } from '../types/csn-extensions'; +import { + BUSINESS_KEY, + PROCESS_START, + SUFFIX_ID, + SUFFIX_IF, + SUFFIX_INPUTS, + SUFFIX_ON, +} from '../constants'; +import { StartAnnotationDescriptor } from '../types/cds-plugin'; +import { InputCSNEntry } from './input-parser'; + +/** + * Extracts the qualifier from an annotation prefix. + * e.g. '@bpm.process.cancel#two' with base '@bpm.process.cancel' returns 'two'. + * Returns undefined if the prefix has no qualifier (equals the base) or if the + * separator is not the expected '#' character. + */ +function extractQualifier(prefix: string, annotationBase: string): string | undefined { + if (prefix.length <= annotationBase.length) return undefined; + const remainder = prefix.substring(annotationBase.length); + return remainder.startsWith('#') ? remainder.substring(1) : undefined; +} + +/** + * Scans all keys on a CDS entity object and returns the unique annotation prefixes + * that match the given base annotation. + */ +export function getAnnotationPrefixes(entity: cds.entity | CsnEntity, annotationBase: string) { + const prefixes = new Set(); + for (const key of Object.keys(entity)) { + if (!key.startsWith(annotationBase)) continue; + const dotIndex = key.indexOf('.', annotationBase.length); + if (dotIndex === -1) continue; + prefixes.add(key.substring(0, dotIndex)); + } + + return prefixes; +} + +export function findStartAnnotations(entity: cds.entity): StartAnnotationDescriptor[] { + const results: StartAnnotationDescriptor[] = []; + + const prefixes = getAnnotationPrefixes(entity, PROCESS_START); + + for (const prefix of prefixes) { + const id = entity[`${prefix}${SUFFIX_ID}`] as string | undefined; + const on = entity[`${prefix}${SUFFIX_ON}`] as string | undefined; + + if (!id || !on) continue; + + const qualifier = extractQualifier(prefix, PROCESS_START); + + const ifAnnotation = entity[`${prefix}${SUFFIX_IF}`] as { xpr: expr } | undefined; + const inputs = entity[`${prefix}${SUFFIX_INPUTS}`] as InputCSNEntry[] | undefined; + + const businessKey = (entity[`${BUSINESS_KEY}`] as { '=': string } | undefined)?.['=']; + + results.push({ + qualifier, + id, + on, + conditionExpr: ifAnnotation?.xpr, + businessKey: businessKey, + inputs, + }); + } + + return results; +} diff --git a/lib/types/cds-plugin.d.ts b/lib/types/cds-plugin.d.ts index b891dca1..8ca9b0ea 100644 --- a/lib/types/cds-plugin.d.ts +++ b/lib/types/cds-plugin.d.ts @@ -1,8 +1,19 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as csn from './csn-extensions'; +import { expr } from '@sap/cds'; +import { InputCSNEntry } from '../shared/input-parser'; + +export interface StartAnnotationDescriptor { + qualifier?: string; + id: string; + on: string; + conditionExpr?: expr; + inputs?: InputCSNEntry[]; + businessKey?: string; +} export interface EntityEventCache { - hasStart: boolean; + startAnnotations: StartAnnotationDescriptor[]; hasCancel: boolean; hasSuspend: boolean; hasResume: boolean; diff --git a/package.json b/package.json index 1777d6ba..45cf3d09 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,9 @@ "lint": "npx eslint .", "prettier": "npx -y prettier@3 --write .", "prettier:check": "npx -y prettier@3 --check .", - "import:process": "npm run import:process:annotationLifeCycle && npm run import:process:programmaticLifecycle && npm run import:process:programmaticOutput && npm run import:process:importAttributesOutputs && npm run import:process:importComplex && npm run import:process:importSimple", + "import:process": "npm run import:process:annotationLifeCycle && npm run import:process:annotationLifeCycle_Two && npm run import:process:programmaticLifecycle && npm run import:process:programmaticOutput && npm run import:process:importAttributesOutputs && npm run import:process:importComplex && npm run import:process:importSimple", "import:process:annotationLifeCycle": "cd tests/bookshop && cds import --from process --name eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process --force", + "import:process:annotationLifeCycle_Two": "cd tests/bookshop && cds import --from process --name eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two --force", "import:process:programmaticLifecycle": "cd tests/bookshop && cds import --from process --name eu12.cdsmunich.capprocesspluginhybridtest.programmatic_Lifecycle_Process --force", "import:process:programmaticOutput": "cd tests/bookshop && cds import --from process --name eu12.cdsmunich.capprocesspluginhybridtest.programmatic_Output_Process --force", "import:process:importAttributesOutputs": "cd tests/bookshop && cds import --from process --name eu12.cdsmunich.capprocesspluginhybridtest.importProcess_Attributes_And_Outputs --force", diff --git a/tests/bookshop/package.json b/tests/bookshop/package.json index 7cff80ea..84ed3437 100644 --- a/tests/bookshop/package.json +++ b/tests/bookshop/package.json @@ -90,6 +90,10 @@ "StartNoInputWithAssocProcessService": { "kind": "process-service", "model": "srv/external/startNoInputWithAssocProcess" + }, + "eu12.cdsmunich.capprocesspluginhybridtest.Annotation_Lifecycle_Process_TwoService": { + "kind": "process-service", + "model": "srv/external/eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two" } } }, diff --git a/tests/bookshop/srv/annotation-hybrid-service.cds b/tests/bookshop/srv/annotation-hybrid-service.cds index 3d5b2501..0660d6d7 100644 --- a/tests/bookshop/srv/annotation-hybrid-service.cds +++ b/tests/bookshop/srv/annotation-hybrid-service.cds @@ -31,6 +31,27 @@ service AnnotationHybridService { year : Integer; } + // Two process starts on create + @bpm.process.start #one : { + id: 'eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process', + on: 'CREATE' + } + @bpm.process.start #two : { + id: 'eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two', + on: 'CREATE', + inputs: [ + { path: $self.ID, as: 'id'} + ] + } + @bpm.process.businessKey: (ID) + entity TwoProcessStarts { + key ID : UUID @mandatory; + model : String(100); + manufacturer : String(100); + mileage : Integer; + year : Integer; + } + action getInstancesByBusinessKey(ID: UUID, status: many String) returns many ProcessInstance; diff --git a/tests/bookshop/srv/annotation-service.cds b/tests/bookshop/srv/annotation-service.cds index 765e13a3..19dd3f3d 100644 --- a/tests/bookshop/srv/annotation-service.cds +++ b/tests/bookshop/srv/annotation-service.cds @@ -1665,6 +1665,85 @@ service AnnotationService { // COMBINATION ENTITIES - Real-world lifecycle scenarios // ============================================ + // ============================================ + // MULTIPLE START ANNOTATION TESTS + // Testing multiple @bpm.process.start with qualifiers + // ============================================ + + // Two start annotations both on CREATE + @bpm.process.start : { + id: 'multiStartCreateProcess1', + on: 'CREATE', + } + @bpm.process.start #two : { + id: 'multiStartCreateProcess2', + on: 'CREATE', + } + entity MultiStartOnCreate as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // Two start annotations on different events (CREATE + UPDATE) + @bpm.process.start #one : { + id: 'multiStartDiffEventProcess1', + on: 'CREATE', + } + @bpm.process.start #two : { + id: 'multiStartDiffEventProcess2', + on: 'UPDATE', + } + entity MultiStartDiffEvents as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // Two start annotations both on DELETE + @bpm.process.start : { + id: 'multiStartDeleteProcess1', + on: 'DELETE', + } + @bpm.process.start #two : { + id: 'multiStartDeleteProcess2', + on: 'DELETE', + } + @bpm.process.businessKey: (ID) + entity MultiStartOnDelete as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // Two start annotations on CREATE, qualified one has an if condition + @bpm.process.start : { + id: 'multiStartIfProcess1', + on: 'CREATE', + } + @bpm.process.start #two : { + id: 'multiStartIfProcess2', + on: 'CREATE', + if: (mileage > 500) + } + entity MultiStartWithCondition as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + // -------------------------------------------- // Scenario 1: Basic Workflow Lifecycle // Start process on CREATE, Cancel on DELETE diff --git a/tests/bookshop/srv/external/eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two.cds b/tests/bookshop/srv/external/eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two.cds new file mode 100644 index 00000000..5329f538 --- /dev/null +++ b/tests/bookshop/srv/external/eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two.cds @@ -0,0 +1,66 @@ +/* checksum : 15602da859ed8169e46688286553aafe */ +namespace eu12.cdsmunich.capprocesspluginhybridtest; + +/** DO NOT EDIT. THIS IS A GENERATED SERVICE THAT WILL BE OVERRIDDEN ON NEXT IMPORT. */ +@protocol : 'none' +@bpm.process : 'eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two' +service Annotation_Lifecycle_Process_TwoService { + type ProcessInputs { + id : String not null; + }; + + type ProcessOutputs { }; + + type ProcessAttribute { + id : String not null; + label : String not null; + value : String; + type : String not null; + }; + + type ProcessAttributes : many ProcessAttribute; + + type ProcessInstance { + definitionId : String; + definitionVersion : String; + id : String; + status : String; + startedAt : String; + startedBy : String; + }; + + type ProcessInstances : many ProcessInstance; + + action start( + inputs : ProcessInputs not null + ); + + function getAttributes( + processInstanceId : String not null + ) returns ProcessAttributes; + + function getOutputs( + processInstanceId : String not null + ) returns ProcessOutputs; + + function getInstancesByBusinessKey( + businessKey : String not null, + status : many String + ) returns ProcessInstances; + + action suspend( + businessKey : String not null, + cascade : Boolean + ); + + action resume( + businessKey : String not null, + cascade : Boolean + ); + + action cancel( + businessKey : String not null, + cascade : Boolean + ); +}; + diff --git a/tests/bookshop/srv/workflows/eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two.json b/tests/bookshop/srv/workflows/eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two.json new file mode 100644 index 00000000..f8c24a4b --- /dev/null +++ b/tests/bookshop/srv/workflows/eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two.json @@ -0,0 +1,70 @@ +{ + "uid": "20427d7d-3290-409a-8cb7-4e3efc7e673a", + "name": "Annotation_Lifecycle_Process_Two", + "description": "", + "type": "bpi.process", + "createdAt": "2026-03-31T12:14:15.040533Z", + "updatedAt": "2026-03-31T13:05:33.831651Z", + "header": { + "inputs": { + "title": "inputs", + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "date": { + "type": "string", + "format": "date" + }, + "dateTime": { + "type": "string", + "format": "date-time" + }, + "password": { + "type": "string", + "password": true + }, + "time": { + "type": "string", + "format": "time" + }, + "documentFolder": { + "type": "string", + "format": "document-folder" + } + }, + "properties": { + "id": { + "type": "string", + "title": "ID", + "description": "" + } + }, + "required": [ + "id" + ] + }, + "outputs": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "outputs", + "type": "object", + "properties": {} + }, + "processAttributes": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "processAttributes", + "type": "object", + "properties": {}, + "required": [] + } + }, + "dependencies": [ + { + "artifactUid": "25ab1d56-63e5-4082-806f-9e41763f40f3", + "type": "content" + } + ], + "identifier": "annotation_Lifecycle_Process_Two", + "valid": true, + "projectId": "eu12.cdsmunich.capprocesspluginhybridtest", + "dataTypes": [] +} \ No newline at end of file diff --git a/tests/hybrid/annotationApproach.test.ts b/tests/hybrid/annotationApproach.test.ts index a2ee1442..9c8a154c 100644 --- a/tests/hybrid/annotationApproach.test.ts +++ b/tests/hybrid/annotationApproach.test.ts @@ -86,4 +86,22 @@ describe('Annotation Approach Hybrid Tests', () => { expect(canceledInstances.length).toBe(1); expect(canceledInstances[0]).toHaveProperty('status', 'CANCELED'); }); + + it('should start two processes on create', async () => { + const ID = generateID(); + + // CREATE triggers start + await POST('/odata/v4/annotation-hybrid/TwoProcessStarts', { + ID, + model: 'Test Model', + manufacturer: 'Test Manufacturer', + mileage: 100, + year: 2020, + }); + + const runningInstances = await waitForInstances(ID, ['RUNNING']); + expect(runningInstances.length).toBe(2); + expect(runningInstances[0]).toHaveProperty('status', 'RUNNING'); + expect(runningInstances[1]).toHaveProperty('status', 'RUNNING'); + }); }); diff --git a/tests/integration/annotations/multipleStartAnnotations.test.ts b/tests/integration/annotations/multipleStartAnnotations.test.ts new file mode 100644 index 00000000..d69e2431 --- /dev/null +++ b/tests/integration/annotations/multipleStartAnnotations.test.ts @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import cds from '@sap/cds'; +const { join } = cds.utils.path; + +const app = join(__dirname, '../../bookshop'); +const { POST, DELETE, PATCH } = cds.test(app); + +describe('Integration tests for multiple @bpm.process.start annotations', () => { + let foundMessages: any[] = []; + + beforeAll(async () => { + const db = await cds.connect.to('db'); + db.before('*', (req) => { + if (req.event === 'CREATE' && req.target?.name === 'cds.outbox.Messages') { + const msg = JSON.parse(req.query?.INSERT?.entries[0].msg); + foundMessages.push(msg); + } + }); + }); + + beforeEach(async () => { + foundMessages = []; + }); + + afterAll(async () => { + await (cds as any).flush(); + }); + + const createTestCar = ({ id, mileage = 100 }: { id?: string; mileage?: number } = {}) => ({ + ID: id || cds.utils.uuid(), + model: 'Test Model', + manufacturer: 'Test Manufacturer', + mileage, + year: 2020, + }); + + const findStartMessages = () => foundMessages.filter((msg) => msg.event === 'start'); + + // ================================================ + // Two starts on CREATE + // ================================================ + describe('Two starts on CREATE', () => { + it('should trigger both start annotations on CREATE', async () => { + const car = createTestCar(); + + const response = await POST('/odata/v4/annotation/MultiStartOnCreate', car); + + expect(response.status).toBe(201); + + const startMsgs = findStartMessages(); + expect(startMsgs.length).toBe(2); + + const definitionIds = startMsgs.map((m: any) => m.data.definitionId).sort(); + expect(definitionIds).toEqual(['multiStartCreateProcess1', 'multiStartCreateProcess2']); + }); + }); + + // ================================================ + // Two starts on different events + // ================================================ + describe('Two starts on different events (CREATE + UPDATE)', () => { + it('should trigger only the CREATE annotation on CREATE', async () => { + const car = createTestCar(); + + const response = await POST('/odata/v4/annotation/MultiStartDiffEvents', car); + + expect(response.status).toBe(201); + + const startMsgs = findStartMessages(); + expect(startMsgs.length).toBe(1); + expect(startMsgs[0].data.definitionId).toBe('multiStartDiffEventProcess1'); + }); + + it('should trigger only the UPDATE annotation on UPDATE', async () => { + const car = createTestCar(); + + await POST('/odata/v4/annotation/MultiStartDiffEvents', car); + foundMessages = []; + + const response = await PATCH(`/odata/v4/annotation/MultiStartDiffEvents('${car.ID}')`, { + mileage: 200, + }); + + expect(response.status).toBe(200); + + const startMsgs = findStartMessages(); + expect(startMsgs.length).toBe(1); + expect(startMsgs[0].data.definitionId).toBe('multiStartDiffEventProcess2'); + }); + }); + + // ================================================ + // Two starts on DELETE + // ================================================ + describe('Two starts on DELETE', () => { + it('should trigger both start annotations on DELETE', async () => { + const car = createTestCar(); + + await POST('/odata/v4/annotation/MultiStartOnDelete', car); + foundMessages = []; + + const response = await DELETE(`/odata/v4/annotation/MultiStartOnDelete('${car.ID}')`); + + expect(response.status).toBe(204); + + const startMsgs = findStartMessages(); + expect(startMsgs.length).toBe(2); + + const definitionIds = startMsgs.map((m: any) => m.data.definitionId).sort(); + expect(definitionIds).toEqual(['multiStartDeleteProcess1', 'multiStartDeleteProcess2']); + }); + }); + + // ================================================ + // Two starts on CREATE with condition + // ================================================ + describe('Two starts on CREATE with condition', () => { + it('should trigger only the unconditional start when condition is NOT met', async () => { + const car = createTestCar({ mileage: 100 }); // mileage <= 500, condition not met + + const response = await POST('/odata/v4/annotation/MultiStartWithCondition', car); + + expect(response.status).toBe(201); + + const startMsgs = findStartMessages(); + expect(startMsgs.length).toBe(1); + expect(startMsgs[0].data.definitionId).toBe('multiStartIfProcess1'); + }); + + it('should trigger both starts when condition IS met', async () => { + const car = createTestCar({ mileage: 600 }); // mileage > 500, condition met + + const response = await POST('/odata/v4/annotation/MultiStartWithCondition', car); + + expect(response.status).toBe(201); + + const startMsgs = findStartMessages(); + expect(startMsgs.length).toBe(2); + + const definitionIds = startMsgs.map((m: any) => m.data.definitionId).sort(); + expect(definitionIds).toEqual(['multiStartIfProcess1', 'multiStartIfProcess2']); + }); + }); +}); diff --git a/tests/integration/build-validation/qualifiedAnnotations.test.ts b/tests/integration/build-validation/qualifiedAnnotations.test.ts new file mode 100644 index 00000000..c933dab8 --- /dev/null +++ b/tests/integration/build-validation/qualifiedAnnotations.test.ts @@ -0,0 +1,116 @@ +import { PROCESS_START } from '../../../lib/constants'; +import { validateModel, wrapEntity } from './helpers'; + +// ============================================================================= +// Qualified Start Annotations +// ============================================================================= +describe('Build Validation: Qualified @bpm.process.start annotations', () => { + it('should PASS with a single qualified start annotation', async () => { + const cdsSource = wrapEntity(` + @bpm.process.start #one: { id: 'process1', on: 'CREATE' } + entity SingleQualifiedStart { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors).toHaveLength(0); + expect(result.buildSucceeded).toBe(true); + }); + + it('should PASS with multiple qualified start annotations on the same entity', async () => { + const cdsSource = wrapEntity(` + @bpm.process.start #one: { id: 'process1', on: 'CREATE' } + @bpm.process.start #two: { id: 'process2', on: 'UPDATE' } + entity MultiQualifiedStart { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors).toHaveLength(0); + expect(result.buildSucceeded).toBe(true); + }); + + it('should PASS with mixed unqualified and qualified start annotations', async () => { + const cdsSource = wrapEntity(` + @bpm.process.start: { id: 'processA', on: 'CREATE' } + @bpm.process.start #alt: { id: 'processB', on: 'UPDATE' } + entity MixedStart { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors).toHaveLength(0); + expect(result.buildSucceeded).toBe(true); + }); + + it('should ERROR when qualified start has id but no on', async () => { + const cdsSource = wrapEntity(` + @bpm.process.start #bad: { id: 'process1' } + entity BadQualifiedStart { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors.length).toBeGreaterThan(0); + expect( + result.errors.some( + (e) => + e.msg.includes(`${PROCESS_START}#bad.id`) && + e.msg.includes('requires') && + e.msg.includes(`${PROCESS_START}#bad.on`), + ), + ).toBe(true); + expect(result.buildSucceeded).toBe(false); + }); + + it('should ERROR when qualified start has on but no id', async () => { + const cdsSource = wrapEntity(` + @bpm.process.start #bad: { on: 'DELETE' } + entity BadQualifiedStart2 { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors.length).toBeGreaterThan(0); + expect( + result.errors.some( + (e) => + e.msg.includes(`${PROCESS_START}#bad.on`) && + e.msg.includes('requires') && + e.msg.includes(`${PROCESS_START}#bad.id`), + ), + ).toBe(true); + expect(result.buildSucceeded).toBe(false); + }); + + it('should validate each qualified start independently — one valid, one invalid', async () => { + const cdsSource = wrapEntity(` + @bpm.process.start #good: { id: 'process1', on: 'CREATE' } + @bpm.process.start #bad: { id: 'process2' } + entity MixedValidity { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors.length).toBeGreaterThan(0); + // Only the #bad qualifier should produce an error + expect(result.errors.some((e) => e.msg.includes('#bad'))).toBe(true); + expect(result.errors.some((e) => e.msg.includes('#good'))).toBe(false); + expect(result.buildSucceeded).toBe(false); + }); + + it('should WARN for unknown sub-annotation on qualified start', async () => { + const cdsSource = wrapEntity(` + @bpm.process.start #q1: { id: 'process1', on: 'CREATE' } + @bpm.process.start#q1.unknown: 'bad' + entity UnknownQualified { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect( + result.warnings.some((w) => w.msg.includes('unknown') || w.msg.includes('Unknown')), + ).toBe(true); + expect(result.buildSucceeded).toBe(true); + }); +});