From b83bf3545aa359b5d54c0fbfd5ab9f8882640d32 Mon Sep 17 00:00:00 2001 From: Til Weber Date: Wed, 1 Apr 2026 16:27:48 +0200 Subject: [PATCH 01/10] initial commit --- lib/build/plugin.ts | 9 +++-- lib/build/validations.ts | 9 +++-- lib/shared/annotations-helper.ts | 38 +++++++++++++++++- tests/bookshop/srv/annotation-service.cds | 39 +++++++++++++++++++ .../multipleLifecycleAnnotations.test.ts | 31 +++++++++++++++ .../multipleStartAnnotations.test.ts | 28 +++++++++++++ .../qualifiedAnnotations.test.ts | 27 +++++++++++++ 7 files changed, 172 insertions(+), 9 deletions(-) diff --git a/lib/build/plugin.ts b/lib/build/plugin.ts index b92906a..5417817 100644 --- a/lib/build/plugin.ts +++ b/lib/build/plugin.ts @@ -21,11 +21,10 @@ import { SUFFIX_ON, SUFFIX_IF, SUFFIX_INPUTS, - BUSINESS_KEY, SUFFIX_CASCADE, } from '../constants'; import { CsnDefinition, CsnEntity } from '../types/csn-extensions'; -import { getAnnotationPrefixes } from '../shared/annotations-helper'; +import { getAnnotationPrefixes, resolveBusinessKeyAnnotation } from '../shared/annotations-helper'; const LOG = cds.log('process-build'); @@ -155,7 +154,9 @@ export class ProcessValidationPlugin extends BuildPluginBase { const hasOn = def[annotationOn] !== undefined; const hasCascade = def[annotationCascade] !== undefined; const hasIf = def[annotationIf] !== undefined; - const hasBusinessKey = def[BUSINESS_KEY] !== undefined; + + const businessKeyAnnotation = resolveBusinessKeyAnnotation(def, prefix, annotationBase); + const hasBusinessKey = def[businessKeyAnnotation] !== undefined; // required fields - .on is required if any annotation with this prefix is defined validateRequiredGenericAnnotations( @@ -180,7 +181,7 @@ export class ProcessValidationPlugin extends BuildPluginBase { } if (hasOn && hasBusinessKey) { - validateBusinessKeyAnnotation(def, entityName, this); + validateBusinessKeyAnnotation(def, entityName, businessKeyAnnotation, this); } } } diff --git a/lib/build/validations.ts b/lib/build/validations.ts index efa7f49..61faad6 100644 --- a/lib/build/validations.ts +++ b/lib/build/validations.ts @@ -1,7 +1,6 @@ import cds from '@sap/cds'; import { ProcessValidationPlugin } from './plugin'; import { CsnDefinition, CsnElement, CsnEntity } from '../types/csn-extensions'; -import { BUSINESS_KEY } from '../constants'; import { createCsnEntityContext, ElementType, @@ -87,11 +86,15 @@ export function validateIfAnnotation( export function validateBusinessKeyAnnotation( def: CsnEntity, entityName: string, + businessKeyAnnotation: `@${string}`, buildPlugin: ProcessValidationPlugin, ) { - const bKeyExpr = def[BUSINESS_KEY]; + const bKeyExpr = def[businessKeyAnnotation]; if (!bKeyExpr || !bKeyExpr['='] || (!bKeyExpr['xpr'] && !bKeyExpr['ref'])) { - buildPlugin.pushMessage(ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION(entityName, BUSINESS_KEY), ERROR); + buildPlugin.pushMessage( + ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION(entityName, businessKeyAnnotation), + ERROR, + ); } } diff --git a/lib/shared/annotations-helper.ts b/lib/shared/annotations-helper.ts index 78f401e..14f527a 100644 --- a/lib/shared/annotations-helper.ts +++ b/lib/shared/annotations-helper.ts @@ -24,6 +24,40 @@ function extractQualifier(prefix: string, annotationBase: string): string | unde return remainder.startsWith('#') ? remainder.substring(1) : undefined; } +/** + * Resolves the business key expression for a given qualifier. + * First tries the qualified annotation (e.g. '@bpm.process.businessKey#one'), + * then falls back to the unqualified '@bpm.process.businessKey'. + */ +function resolveBusinessKey(entity: cds.entity, qualifier: string | undefined): string | undefined { + if (qualifier) { + const qualifiedKey = `${BUSINESS_KEY}#${qualifier}`; + const qualified = (entity[qualifiedKey] as { '=': string } | undefined)?.['=']; + if (qualified) return qualified; + } + return (entity[`${BUSINESS_KEY}`] as { '=': string } | undefined)?.['=']; +} + +/** + * Resolves the business key annotation key for a given annotation prefix. + * First checks for a qualified businessKey matching the qualifier of the prefix + * (e.g. '@bpm.process.businessKey#one' for prefix '@bpm.process.cancel#one'), + * then falls back to the unqualified '@bpm.process.businessKey'. + * Returns the annotation key that exists on the entity, or the unqualified key if neither exists. + */ +export function resolveBusinessKeyAnnotation( + entity: cds.entity | CsnEntity, + prefix: string, + annotationBase: string, +): `@${string}` { + const qualifier = extractQualifier(prefix, annotationBase); + if (qualifier) { + const qualifiedKey: `@${string}` = `${BUSINESS_KEY}#${qualifier}`; + if ((entity as CsnEntity)[qualifiedKey] !== undefined) return qualifiedKey; + } + return BUSINESS_KEY; +} + /** * Scans all keys on a CDS entity object and returns the unique annotation prefixes * that match the given base annotation. @@ -56,7 +90,7 @@ export function findStartAnnotations(entity: cds.entity): StartAnnotationDescrip 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)?.['=']; + const businessKey = resolveBusinessKey(entity, qualifier); results.push({ qualifier, @@ -88,7 +122,7 @@ export function findLifecycleAnnotations( const cascade = (entity[`${prefix}${SUFFIX_CASCADE}`] as boolean) ?? false; const ifAnnotation = entity[`${prefix}${SUFFIX_IF}`] as { xpr: expr } | undefined; - const businessKey = (entity[`${BUSINESS_KEY}`] as { '=': string } | undefined)?.['=']; + const businessKey = resolveBusinessKey(entity, qualifier); results.push({ qualifier, diff --git a/tests/bookshop/srv/annotation-service.cds b/tests/bookshop/srv/annotation-service.cds index 4011106..651464c 100644 --- a/tests/bookshop/srv/annotation-service.cds +++ b/tests/bookshop/srv/annotation-service.cds @@ -1725,6 +1725,25 @@ service AnnotationService { year } + // Two start annotations on CREATE with different qualified business keys + @bpm.process.start #one : { + id: 'multiStartBkProcess1', + on: 'CREATE', + } + @bpm.process.start #two : { + id: 'multiStartBkProcess2', + on: 'CREATE', + } + @bpm.process.businessKey : (ID) + @bpm.process.businessKey#two: (model || '-' || manufacturer) + entity MultiStartDiffBusinessKeys 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', @@ -1825,6 +1844,26 @@ service AnnotationService { year } + // Two cancel annotations on DELETE with different qualified business keys + @bpm.process.cancel : { + on : 'DELETE', + cascade: true, + } + @bpm.process.cancel #two : { + on : 'DELETE', + cascade: false, + } + @bpm.process.businessKey : (ID) + @bpm.process.businessKey#two : (model || '-' || manufacturer) + entity MultiCancelDiffBusinessKeys as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + // Two cancel annotations on DELETE, qualified one has an if condition @bpm.process.cancel #one : { on : 'DELETE', diff --git a/tests/integration/annotations/multipleLifecycleAnnotations.test.ts b/tests/integration/annotations/multipleLifecycleAnnotations.test.ts index 895483a..259cfe1 100644 --- a/tests/integration/annotations/multipleLifecycleAnnotations.test.ts +++ b/tests/integration/annotations/multipleLifecycleAnnotations.test.ts @@ -168,6 +168,37 @@ describe('Integration tests for multiple lifecycle annotations (cancel/suspend/r }); }); }); + // ================================================ + // Two cancels on DELETE with different business keys + // ================================================ + describe('Two cancels on DELETE with different business keys', () => { + it('should trigger both cancels with their respective business keys', async () => { + const car = createTestCar(); + + await POST('/odata/v4/annotation/MultiCancelDiffBusinessKeys', car); + foundMessages = []; + + const response = await DELETE( + `/odata/v4/annotation/MultiCancelDiffBusinessKeys('${car.ID}')`, + ); + + expect(response.status).toBe(204); + + const cancelMsgs = findCancelMessages(); + expect(cancelMsgs.length).toBe(2); + + // Unqualified cancel: @bpm.process.businessKey: (ID), cascade: true + const msg1 = cancelMsgs.find((m: any) => m.data.cascade === true); + // Qualified cancel #two: @bpm.process.businessKey#two: (model || '-' || manufacturer), cascade: false + const msg2 = cancelMsgs.find((m: any) => m.data.cascade === false); + + expect(msg1).toBeDefined(); + expect(msg2).toBeDefined(); + + expect(msg1!.data.businessKey).toBe(car.ID); + expect(msg2!.data.businessKey).toBe(`${car.model}-${car.manufacturer}`); + }); + }); // ================================================ // Two cancels on DELETE with condition diff --git a/tests/integration/annotations/multipleStartAnnotations.test.ts b/tests/integration/annotations/multipleStartAnnotations.test.ts index d69e243..96df096 100644 --- a/tests/integration/annotations/multipleStartAnnotations.test.ts +++ b/tests/integration/annotations/multipleStartAnnotations.test.ts @@ -110,6 +110,34 @@ describe('Integration tests for multiple @bpm.process.start annotations', () => expect(definitionIds).toEqual(['multiStartDeleteProcess1', 'multiStartDeleteProcess2']); }); }); + // ================================================ + // Two starts on CREATE with different business keys + // ================================================ + describe('Two starts on CREATE with different business keys', () => { + it('should trigger both starts with their respective business keys', async () => { + const car = createTestCar(); + + const response = await POST('/odata/v4/annotation/MultiStartDiffBusinessKeys', car); + + expect(response.status).toBe(201); + + const startMsgs = findStartMessages(); + expect(startMsgs.length).toBe(2); + + // Find each start message by definitionId + const msg1 = startMsgs.find((m: any) => m.data.definitionId === 'multiStartBkProcess1'); + const msg2 = startMsgs.find((m: any) => m.data.definitionId === 'multiStartBkProcess2'); + + expect(msg1).toBeDefined(); + expect(msg2).toBeDefined(); + + // Unqualified start: @bpm.process.businessKey: (ID) + expect(msg1!.headers.businessKey).toBe(car.ID); + + // Qualified start #two: @bpm.process.businessKey#two: (model || '-' || manufacturer) + expect(msg2!.headers.businessKey).toBe(`${car.model}-${car.manufacturer}`); + }); + }); // ================================================ // Two starts on CREATE with condition diff --git a/tests/integration/build-validation/qualifiedAnnotations.test.ts b/tests/integration/build-validation/qualifiedAnnotations.test.ts index 08edaa7..50c0064 100644 --- a/tests/integration/build-validation/qualifiedAnnotations.test.ts +++ b/tests/integration/build-validation/qualifiedAnnotations.test.ts @@ -160,6 +160,33 @@ describe('Build Validation: Qualified lifecycle annotations', () => { expect(result.buildSucceeded).toBe(true); }); + it('should PASS with qualified annotation using matching qualified businessKey', async () => { + const cdsSource = wrapEntity(` + ${annotationBase} #special: { on: 'UPDATE' } + @bpm.process.businessKey #special: (name) + entity QualifiedBizKey${label} { key ID: UUID; name: String; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors).toHaveLength(0); + expect(result.buildSucceeded).toBe(true); + }); + + it('should PASS with qualified annotation falling back to unqualified businessKey', async () => { + // No #myq qualified businessKey, so falls back to unqualified + const cdsSource = wrapEntity(` + ${annotationBase} #myq: { on: 'UPDATE' } + @bpm.process.businessKey: (ID) + entity FallbackBizKey${label} { 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 annotations', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { on: 'DELETE' } From 440bfc1d9da35c7351d39374edf10280cfd217e8 Mon Sep 17 00:00:00 2001 From: Til Weber Date: Wed, 1 Apr 2026 16:39:23 +0200 Subject: [PATCH 02/10] code improvements --- lib/build/plugin.ts | 5 +++-- lib/shared/annotations-helper.ts | 32 +++++++++----------------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/lib/build/plugin.ts b/lib/build/plugin.ts index 5417817..1cd2d01 100644 --- a/lib/build/plugin.ts +++ b/lib/build/plugin.ts @@ -24,7 +24,7 @@ import { SUFFIX_CASCADE, } from '../constants'; import { CsnDefinition, CsnEntity } from '../types/csn-extensions'; -import { getAnnotationPrefixes, resolveBusinessKeyAnnotation } from '../shared/annotations-helper'; +import { extractQualifier, getAnnotationPrefixes, resolveBusinessKeyAnnotation } from '../shared/annotations-helper'; const LOG = cds.log('process-build'); @@ -155,7 +155,8 @@ export class ProcessValidationPlugin extends BuildPluginBase { const hasCascade = def[annotationCascade] !== undefined; const hasIf = def[annotationIf] !== undefined; - const businessKeyAnnotation = resolveBusinessKeyAnnotation(def, prefix, annotationBase); + const qualifier = extractQualifier(prefix, annotationBase); + const businessKeyAnnotation = resolveBusinessKeyAnnotation(def, qualifier); const hasBusinessKey = def[businessKeyAnnotation] !== undefined; // required fields - .on is required if any annotation with this prefix is defined diff --git a/lib/shared/annotations-helper.ts b/lib/shared/annotations-helper.ts index 14f527a..f99f92e 100644 --- a/lib/shared/annotations-helper.ts +++ b/lib/shared/annotations-helper.ts @@ -18,39 +18,23 @@ import { InputCSNEntry } from './input-parser'; * 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 { +export 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; } /** - * Resolves the business key expression for a given qualifier. - * First tries the qualified annotation (e.g. '@bpm.process.businessKey#one'), - * then falls back to the unqualified '@bpm.process.businessKey'. - */ -function resolveBusinessKey(entity: cds.entity, qualifier: string | undefined): string | undefined { - if (qualifier) { - const qualifiedKey = `${BUSINESS_KEY}#${qualifier}`; - const qualified = (entity[qualifiedKey] as { '=': string } | undefined)?.['=']; - if (qualified) return qualified; - } - return (entity[`${BUSINESS_KEY}`] as { '=': string } | undefined)?.['=']; -} - -/** - * Resolves the business key annotation key for a given annotation prefix. - * First checks for a qualified businessKey matching the qualifier of the prefix - * (e.g. '@bpm.process.businessKey#one' for prefix '@bpm.process.cancel#one'), + * Resolves the business key annotation key for a given qualifier. + * First checks for a qualified businessKey matching the qualifier + * (e.g. '@bpm.process.businessKey#one' for qualifier 'one'), * then falls back to the unqualified '@bpm.process.businessKey'. * Returns the annotation key that exists on the entity, or the unqualified key if neither exists. */ export function resolveBusinessKeyAnnotation( entity: cds.entity | CsnEntity, - prefix: string, - annotationBase: string, + qualifier: string | undefined, ): `@${string}` { - const qualifier = extractQualifier(prefix, annotationBase); if (qualifier) { const qualifiedKey: `@${string}` = `${BUSINESS_KEY}#${qualifier}`; if ((entity as CsnEntity)[qualifiedKey] !== undefined) return qualifiedKey; @@ -90,7 +74,8 @@ export function findStartAnnotations(entity: cds.entity): StartAnnotationDescrip const ifAnnotation = entity[`${prefix}${SUFFIX_IF}`] as { xpr: expr } | undefined; const inputs = entity[`${prefix}${SUFFIX_INPUTS}`] as InputCSNEntry[] | undefined; - const businessKey = resolveBusinessKey(entity, qualifier); + const businessKeyAnnotation = resolveBusinessKeyAnnotation(entity, qualifier); + const businessKey = (entity[businessKeyAnnotation] as { '=': string } | undefined)?.['=']; results.push({ qualifier, @@ -122,7 +107,8 @@ export function findLifecycleAnnotations( const cascade = (entity[`${prefix}${SUFFIX_CASCADE}`] as boolean) ?? false; const ifAnnotation = entity[`${prefix}${SUFFIX_IF}`] as { xpr: expr } | undefined; - const businessKey = resolveBusinessKey(entity, qualifier); + const businessKeyAnnotation = resolveBusinessKeyAnnotation(entity, qualifier); + const businessKey = (entity[businessKeyAnnotation] as { '=': string } | undefined)?.['=']; results.push({ qualifier, From 128f86246c015186d8321d4a13027c4cd2d7ae3c Mon Sep 17 00:00:00 2001 From: Til Weber Date: Wed, 1 Apr 2026 16:59:22 +0200 Subject: [PATCH 03/10] added hybrid test for qualified annotations --- .../srv/annotation-hybrid-service.cds | 21 +++++++- tests/hybrid/annotationApproach.test.ts | 50 ++++++++++++++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/tests/bookshop/srv/annotation-hybrid-service.cds b/tests/bookshop/srv/annotation-hybrid-service.cds index 0660d6d..2cb65e8 100644 --- a/tests/bookshop/srv/annotation-hybrid-service.cds +++ b/tests/bookshop/srv/annotation-hybrid-service.cds @@ -32,10 +32,15 @@ service AnnotationHybridService { } // Two process starts on create + // Process one: cancel on delete, with business Key = ID + // Process two: suspend/resume on update, with business key = model @bpm.process.start #one : { id: 'eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process', on: 'CREATE' } + @bpm.process.cancel #one: { + on: 'DELETE' + } @bpm.process.start #two : { id: 'eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two', on: 'CREATE', @@ -43,8 +48,20 @@ service AnnotationHybridService { { path: $self.ID, as: 'id'} ] } - @bpm.process.businessKey: (ID) - entity TwoProcessStarts { + @bpm.process.suspend #two: { + on: 'UPDATE', + if: (mileage < 800) + } + @bpm.process.resume #two: { + on: 'UPDATE', + if: (mileage >= 800) + } + @bpm.process.cancel #two: { + on: 'DELETE' + } + @bpm.process.businessKey #one: (ID) + @bpm.process.businessKey: (model) + entity QualifiedAnnotations { key ID : UUID @mandatory; model : String(100); manufacturer : String(100); diff --git a/tests/hybrid/annotationApproach.test.ts b/tests/hybrid/annotationApproach.test.ts index 9c8a154..f7cb749 100644 --- a/tests/hybrid/annotationApproach.test.ts +++ b/tests/hybrid/annotationApproach.test.ts @@ -89,19 +89,53 @@ describe('Annotation Approach Hybrid Tests', () => { it('should start two processes on create', async () => { const ID = generateID(); - - // CREATE triggers start - await POST('/odata/v4/annotation-hybrid/TwoProcessStarts', { + const mock = { 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'); + // CREATE triggers start + await POST('/odata/v4/annotation-hybrid/QualifiedAnnotations', mock); + + let runningInstancesOne = await waitForInstances(mock.ID, ['RUNNING']); + expect(runningInstancesOne.length).toBe(1); + expect(runningInstancesOne[0]).toHaveProperty('status', 'RUNNING'); + + const runningInstancesTwo = await waitForInstances(mock.model, ['RUNNING']); + expect(runningInstancesTwo.length).toBe(1); + expect(runningInstancesTwo[0]).toHaveProperty('status', 'RUNNING'); + + // UPDATE mileage < 800 should suspend process Two + await PATCH(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${ID}')`, { mileage: 200 }); + runningInstancesOne = await waitForInstances(mock.ID, ['RUNNING']); + expect(runningInstancesOne.length).toBe(1); + expect(runningInstancesOne[0]).toHaveProperty('status', 'RUNNING'); + + const suspendedInstancesTwo = await waitForInstances(mock.model, ['SUSPENDED']); + expect(suspendedInstancesTwo.length).toBe(1); + expect(suspendedInstancesTwo[0]).toHaveProperty('status', 'SUSPENDED'); + + // UPDATE mileage >= 800 should resume process Two + await PATCH(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${ID}')`, { mileage: 900 }); + runningInstancesOne = await waitForInstances(mock.ID, ['RUNNING']); + expect(runningInstancesOne.length).toBe(1); + expect(runningInstancesOne[0]).toHaveProperty('status', 'RUNNING'); + + const resumedInstancesTwo = await waitForInstances(mock.model, ['RUNNING']); + expect(resumedInstancesTwo.length).toBe(1); + expect(resumedInstancesTwo[0]).toHaveProperty('status', 'RUNNING'); + + // DELETE should cancel both processes + await DELETE(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${ID}')`); + const cancelledInstancesOne = await waitForInstances(mock.ID, ['CANCELED']); + expect(cancelledInstancesOne.length).toBe(1); + expect(cancelledInstancesOne[0]).toHaveProperty('status', 'CANCELED'); + + const cancelledInstancesTwo = await waitForInstances(mock.model, ['CANCELED']); + expect(cancelledInstancesTwo.length).toBe(1); + expect(cancelledInstancesTwo[0]).toHaveProperty('status', 'CANCELED'); }); }); From ff05386a1e32d9e18e9ba11e4fba22693b00c298 Mon Sep 17 00:00:00 2001 From: Til Weber Date: Wed, 1 Apr 2026 17:02:25 +0200 Subject: [PATCH 04/10] minor fix --- tests/hybrid/annotationApproach.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/hybrid/annotationApproach.test.ts b/tests/hybrid/annotationApproach.test.ts index f7cb749..44d467b 100644 --- a/tests/hybrid/annotationApproach.test.ts +++ b/tests/hybrid/annotationApproach.test.ts @@ -109,7 +109,7 @@ describe('Annotation Approach Hybrid Tests', () => { expect(runningInstancesTwo[0]).toHaveProperty('status', 'RUNNING'); // UPDATE mileage < 800 should suspend process Two - await PATCH(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${ID}')`, { mileage: 200 }); + await PATCH(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${mock.ID}')`, { mileage: 200 }); runningInstancesOne = await waitForInstances(mock.ID, ['RUNNING']); expect(runningInstancesOne.length).toBe(1); expect(runningInstancesOne[0]).toHaveProperty('status', 'RUNNING'); @@ -119,7 +119,7 @@ describe('Annotation Approach Hybrid Tests', () => { expect(suspendedInstancesTwo[0]).toHaveProperty('status', 'SUSPENDED'); // UPDATE mileage >= 800 should resume process Two - await PATCH(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${ID}')`, { mileage: 900 }); + await PATCH(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${mock.ID}')`, { mileage: 900 }); runningInstancesOne = await waitForInstances(mock.ID, ['RUNNING']); expect(runningInstancesOne.length).toBe(1); expect(runningInstancesOne[0]).toHaveProperty('status', 'RUNNING'); @@ -129,7 +129,7 @@ describe('Annotation Approach Hybrid Tests', () => { expect(resumedInstancesTwo[0]).toHaveProperty('status', 'RUNNING'); // DELETE should cancel both processes - await DELETE(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${ID}')`); + await DELETE(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${mock.ID}')`); const cancelledInstancesOne = await waitForInstances(mock.ID, ['CANCELED']); expect(cancelledInstancesOne.length).toBe(1); expect(cancelledInstancesOne[0]).toHaveProperty('status', 'CANCELED'); From 022a0ae7f140f886909715b7024c6274cbedc093 Mon Sep 17 00:00:00 2001 From: Til Weber Date: Wed, 1 Apr 2026 17:07:56 +0200 Subject: [PATCH 05/10] changelog and readme --- CHANGELOG.md | 1 + README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc2d4a..f2450bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Allowing the import of downloaded process models - Support for multiple start annotations on the same entity using qualifier - Support for multiple resume, cancel, and suspend annotations on the same entity using qualifier +- Support for multiple businessKey annotations on the same entity using qualifier ## Version 0.1.1 - 2026-03-27 diff --git a/README.md b/README.md index de066ee..5cc4b9c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ CAP Plugin to interact with SAP Build Process Automation to manage processes. - [Multiple Start Annotations](#multiple-start-annotations) - [Cancelling, Resuming, or Suspending a Process](#cancelling-resuming-or-suspending-a-process) - [Multiple Cancel/Resume/Suspend Annotations](#multiple-cancelresumesuspend-annotations) + - [Multiple Business Key Annotations](#multiple-business-key-annotations) - [Conditional Execution](#conditional-execution) - [Input Mapping](#input-mapping) - [Programmatic Approach](#programmatic-approach) @@ -135,7 +136,7 @@ Both processes are started when a `CREATE` event occurs on the entity, but `noti - `@bpm.process.` -- Cancel/Suspend/Resume any processes with the given businessKey - `@bpm.process..on` - `@bpm.process..cascade` -- Boolean (optional, defaults to false) -- For cancelling, resuming, or suspending, it is required to have a business key expression annotated on the entity using `@bpm.process.businessKey`. If no business key is annotated, the request will be rejected. +- For cancelling, resuming, or suspending, it is required to have a business key expression annotated on the entity using `@bpm.process.businessKey`. If no business key is annotated, the request will be rejected. When using qualified annotations, a qualified business key (e.g. `@bpm.process.businessKey #one`) is resolved first; if not found, the unqualified `@bpm.process.businessKey` is used as a fallback. - Example: `@bpm.process.businessKey: (id || '-' || name)` Example: @@ -181,6 +182,52 @@ service MyService { } ``` +#### Multiple Business Key Annotations + +When using multiple qualified annotations on the same entity, you can define different business key expressions per qualifier using `@bpm.process.businessKey #qualifier`. Each qualified process annotation first looks for a business key with a matching qualifier. If none is found, the unqualified `@bpm.process.businessKey` is used as a fallback. + +```cds +service MyService { + + @bpm.process.cancel #cancelByName : { + on: 'DELETE', + } + @bpm.process.cancel #cancelById : { + on: 'UPDATE', + } + @bpm.process.businessKey #cancelByName : (name) + @bpm.process.businessKey #cancelById : (ID) + entity MyEntity { + key ID : UUID; + name : String; + }; + +} +``` + +In this example, `@bpm.process.cancel #cancelByName` uses `name` as business key, while `@bpm.process.cancel #cancelById` uses `ID`. + +You can also mix qualified and unqualified business keys. Qualified annotations without a matching qualified business key fall back to the unqualified one: + +```cds +service MyService { + + @bpm.process.cancel #one : { on: 'DELETE' } + @bpm.process.cancel #two : { on: 'UPDATE' } + @bpm.process.businessKey #one : (name) + @bpm.process.businessKey : (ID) + entity MyEntity { + key ID : UUID; + name : String; + }; + +} +``` + +Here, `#one` uses `name` as its business key, while `#two` falls back to the unqualified `@bpm.process.businessKey` and uses `ID`. + +This also applies to `@bpm.process.start`, `@bpm.process.suspend`, and `@bpm.process.resume` annotations. + ### Conditional Execution The `.if` annotation is available on all process operations (`start`, `cancel`, `suspend`, `resume`). It accepts a CDS expression and ensures the operation is only triggered when the expression evaluates to true. @@ -680,7 +727,7 @@ When both `@bpm.process.start.id` and `@bpm.process.start.on` are present and th - A bound action defined on the entity - `@bpm.process..cascade` is optional (defaults to false); if provided, must be a boolean - `@bpm.process..if` must be a valid CDS expression (if present) -- If any annotation with `@bpm.process.` is defined, a valid business key expression must be defined using `@bpm.process.businessKey`. +- If any annotation with `@bpm.process.` is defined, a valid business key expression must be defined using `@bpm.process.businessKey`. When using qualified annotations (e.g. `@bpm.process.cancel #one`), the validation first checks for a matching qualified business key (`@bpm.process.businessKey #one`), then falls back to the unqualified `@bpm.process.businessKey`. - Example: `@bpm.process.businessKey: (id || '-' || name)` would concatenate `id` and `name` with a `-` separator as a business key. - The business key definition must match the one configured in the SBPA Process Builder. From 3098047a5048a3a1222b94b7acf320e824d7951d Mon Sep 17 00:00:00 2001 From: Til Weber Date: Wed, 1 Apr 2026 17:12:07 +0200 Subject: [PATCH 06/10] prettier --- lib/build/plugin.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/build/plugin.ts b/lib/build/plugin.ts index 1cd2d01..8e31a64 100644 --- a/lib/build/plugin.ts +++ b/lib/build/plugin.ts @@ -24,7 +24,11 @@ import { SUFFIX_CASCADE, } from '../constants'; import { CsnDefinition, CsnEntity } from '../types/csn-extensions'; -import { extractQualifier, getAnnotationPrefixes, resolveBusinessKeyAnnotation } from '../shared/annotations-helper'; +import { + extractQualifier, + getAnnotationPrefixes, + resolveBusinessKeyAnnotation, +} from '../shared/annotations-helper'; const LOG = cds.log('process-build'); From 490d0a1b8a13809e53d4b699674cc2f1ecb634e5 Mon Sep 17 00:00:00 2001 From: Til Weber Date: Thu, 2 Apr 2026 08:02:34 +0200 Subject: [PATCH 07/10] test fix --- tests/bookshop/srv/annotation-hybrid-service.cds | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bookshop/srv/annotation-hybrid-service.cds b/tests/bookshop/srv/annotation-hybrid-service.cds index 2cb65e8..f7ae893 100644 --- a/tests/bookshop/srv/annotation-hybrid-service.cds +++ b/tests/bookshop/srv/annotation-hybrid-service.cds @@ -41,11 +41,12 @@ service AnnotationHybridService { @bpm.process.cancel #one: { on: 'DELETE' } + // need to set model as input field "id" because process is designed to use input id as businessKey @bpm.process.start #two : { id: 'eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two', on: 'CREATE', inputs: [ - { path: $self.ID, as: 'id'} + { path: $self.model, as: 'id'} ] } @bpm.process.suspend #two: { From 2caa4137aead8a0a753204c7d28fb6dbfdd93c53 Mon Sep 17 00:00:00 2001 From: Til Weber Date: Thu, 2 Apr 2026 08:31:21 +0200 Subject: [PATCH 08/10] fix tests final --- tests/bookshop/srv/annotation-hybrid-service.cds | 2 +- tests/hybrid/annotationApproach.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/bookshop/srv/annotation-hybrid-service.cds b/tests/bookshop/srv/annotation-hybrid-service.cds index f7ae893..515ac7e 100644 --- a/tests/bookshop/srv/annotation-hybrid-service.cds +++ b/tests/bookshop/srv/annotation-hybrid-service.cds @@ -70,7 +70,7 @@ service AnnotationHybridService { year : Integer; } - action getInstancesByBusinessKey(ID: UUID, + action getInstancesByBusinessKey(ID: String, status: many String) returns many ProcessInstance; } diff --git a/tests/hybrid/annotationApproach.test.ts b/tests/hybrid/annotationApproach.test.ts index 44d467b..5ce1a35 100644 --- a/tests/hybrid/annotationApproach.test.ts +++ b/tests/hybrid/annotationApproach.test.ts @@ -89,9 +89,11 @@ describe('Annotation Approach Hybrid Tests', () => { it('should start two processes on create', async () => { const ID = generateID(); + // model name should be unique to avoid conflicts with other tests since process Two uses model as businessKey + const mockModel = `${ID}-Test Model`; const mock = { ID, - model: 'Test Model', + model: mockModel, manufacturer: 'Test Manufacturer', mileage: 100, year: 2020, From 6bf2a43159b2fd70f0b5796751dd4066144261f9 Mon Sep 17 00:00:00 2001 From: Til Weber Date: Thu, 2 Apr 2026 09:19:00 +0200 Subject: [PATCH 09/10] added check for businessKey on processStart --> warning --- README.md | 2 + lib/build/constants.ts | 14 ++++++ lib/build/plugin.ts | 8 +++ lib/build/validations.ts | 25 ++++++++++ .../build-validation/processStart.test.ts | 50 ++++++++++++++++++- 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5cc4b9c..51ffeb5 100644 --- a/README.md +++ b/README.md @@ -702,6 +702,8 @@ Validation occurs during `cds build` and produces **errors** (hard failures that - Unknown annotations under `@bpm.process.start.*` trigger a warning listing allowed annotations - If no imported process definition is found for the given `id`, a warning is issued as input validation is skipped +- If `@bpm.process.businessKey` is not defined on an entity with a valid start annotation (`id` and `on`), a warning is issued and the business key length check is skipped +- If `@bpm.process.businessKey` is defined but is not a valid CDS expression, a warning is issued and the business key length check is skipped #### Input Validation (when process definition is found) diff --git a/lib/build/constants.ts b/lib/build/constants.ts index aa9d636..5eb44b7 100644 --- a/lib/build/constants.ts +++ b/lib/build/constants.ts @@ -39,6 +39,20 @@ export const ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION = ( return `${entityName}: ${annotationBKey} must be a valid expression`; }; +export const WARNING_BUSINESS_KEY_MUST_BE_EXPRESSION = ( + entityName: string, + annotationBKey: string, +): string => { + return `${entityName}: ${annotationBKey} must be a valid expression. Length check will be skipped.`; +}; + +export const WARNING_BUSINESS_KEY_NOT_FOUND = ( + entityName: string, + annotationBKey: string, +): string => { + return `${entityName}: ${annotationBKey} not found for process start. Length check will be skipped.`; +}; + // ============================================================================= // Start Annotation Validation Messages // ============================================================================= diff --git a/lib/build/plugin.ts b/lib/build/plugin.ts index 8e31a64..6c6f0fe 100644 --- a/lib/build/plugin.ts +++ b/lib/build/plugin.ts @@ -10,6 +10,7 @@ import { validateRequiredStartAnnotations, validateIfAnnotation, validateBusinessKeyAnnotation, + validateBusinessKeyForProcessStart, } from './index'; import { PROCESS_START, @@ -104,6 +105,9 @@ export class ProcessValidationPlugin extends BuildPluginBase { const hasOn = def[annotationOn] !== undefined; const hasIf = def[annotationIf] !== undefined; + const qualifier = extractQualifier(prefix, PROCESS_START); + const businessKeyAnnotation = resolveBusinessKeyAnnotation(def, qualifier); + // required fields validateRequiredStartAnnotations(hasOn, hasId, entityName, annotationOn, annotationId, this); @@ -121,6 +125,10 @@ export class ProcessValidationPlugin extends BuildPluginBase { validateIfAnnotation(def, entityName, annotationIf, this); } + if (hasId && hasOn) { + validateBusinessKeyForProcessStart(def, entityName, businessKeyAnnotation, this); + } + if (hasId && hasOn && processDef) { const processInputs = allDefinitions[`${processDef.name}.ProcessInputs`]; if (typeof processInputs !== 'undefined') { diff --git a/lib/build/validations.ts b/lib/build/validations.ts index 61faad6..b1c0507 100644 --- a/lib/build/validations.ts +++ b/lib/build/validations.ts @@ -27,6 +27,8 @@ import { WARNING_NO_PROCESS_DEFINITION, WARNING_INPUT_PATH_NOT_IN_ENTITY, ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION, + WARNING_BUSINESS_KEY_MUST_BE_EXPRESSION, + WARNING_BUSINESS_KEY_NOT_FOUND, } from './constants'; import { EntityContext, ParsedInputEntry } from '../shared/input-parser'; @@ -83,6 +85,29 @@ export function validateIfAnnotation( buildPlugin.pushMessage(ERROR_IF_MUST_BE_EXPRESSION(entityName, annotationIf), ERROR); } } +export function validateBusinessKeyForProcessStart( + def: CsnEntity, + entityName: string, + businessKeyAnnotation: `@${string}`, + buildPlugin: ProcessValidationPlugin, +) { + const bKeyExpr = def[businessKeyAnnotation]; + if (!bKeyExpr) { + buildPlugin.pushMessage( + WARNING_BUSINESS_KEY_NOT_FOUND(entityName, businessKeyAnnotation), + WARNING, + ); + return; + } + if (!bKeyExpr['='] || (!bKeyExpr['xpr'] && !bKeyExpr['ref'])) { + buildPlugin.pushMessage( + WARNING_BUSINESS_KEY_MUST_BE_EXPRESSION(entityName, businessKeyAnnotation), + WARNING, + ); + return; + } +} + export function validateBusinessKeyAnnotation( def: CsnEntity, entityName: string, diff --git a/tests/integration/build-validation/processStart.test.ts b/tests/integration/build-validation/processStart.test.ts index 3f52699..e93287b 100644 --- a/tests/integration/build-validation/processStart.test.ts +++ b/tests/integration/build-validation/processStart.test.ts @@ -1,4 +1,9 @@ -import { PROCESS_START, PROCESS_START_ID, PROCESS_START_INPUTS } from '../../../lib/constants'; +import { + BUSINESS_KEY, + PROCESS_START, + PROCESS_START_ID, + PROCESS_START_INPUTS, +} from '../../../lib/constants'; import { validateModel, withProcessDefinition, wrapEntity } from './helpers'; // Tests additional annotation validation specific for start annotation @@ -801,4 +806,47 @@ describe(`Build Validation: @bpm.process.start annotations`, () => { ); }); }); + + describe('BusinessKey annotation for process start', () => { + it('should WARN when businessKey annotation is missing on process start entity', async () => { + const cdsSource = wrapEntity(` + ${PROCESS_START}: { id: 'someProcess', on: 'CREATE' } + entity NoBusinessKeyEntity { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.warnings.length).toBeGreaterThan(0); + expect( + result.warnings.some( + (w) => + w.msg.includes(BUSINESS_KEY) && + w.msg.includes('not found for process start') && + w.msg.includes('Length check will be skipped'), + ), + ).toBe(true); + expect(result.buildSucceeded).toBe(true); + }); + + it('should WARN when businessKey annotation is not a valid expression', async () => { + const cdsSource = wrapEntity(` + ${PROCESS_START}: { id: 'someProcess', on: 'CREATE' } + ${BUSINESS_KEY}: 'notAnExpression' + entity InvalidBusinessKeyEntity { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.warnings.length).toBeGreaterThan(0); + expect( + result.warnings.some( + (w) => + w.msg.includes(BUSINESS_KEY) && + w.msg.includes('must be a valid expression') && + w.msg.includes('Length check will be skipped'), + ), + ).toBe(true); + expect(result.buildSucceeded).toBe(true); + }); + }); }); From 556734ca0ec081ea0d2080ab8e72b1abf83a0053 Mon Sep 17 00:00:00 2001 From: Til Weber Date: Thu, 2 Apr 2026 13:54:19 +0200 Subject: [PATCH 10/10] fix tests --- tests/bookshop/srv/annotation-hybrid-service.cds | 2 +- tests/bookshop/srv/annotation-service.cds | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/bookshop/srv/annotation-hybrid-service.cds b/tests/bookshop/srv/annotation-hybrid-service.cds index 515ac7e..861fbbc 100644 --- a/tests/bookshop/srv/annotation-hybrid-service.cds +++ b/tests/bookshop/srv/annotation-hybrid-service.cds @@ -61,7 +61,7 @@ service AnnotationHybridService { on: 'DELETE' } @bpm.process.businessKey #one: (ID) - @bpm.process.businessKey: (model) + @bpm.process.businessKey #two: (model) entity QualifiedAnnotations { key ID : UUID @mandatory; model : String(100); diff --git a/tests/bookshop/srv/annotation-service.cds b/tests/bookshop/srv/annotation-service.cds index 651464c..d21ad0c 100644 --- a/tests/bookshop/srv/annotation-service.cds +++ b/tests/bookshop/srv/annotation-service.cds @@ -1734,8 +1734,8 @@ service AnnotationService { id: 'multiStartBkProcess2', on: 'CREATE', } - @bpm.process.businessKey : (ID) - @bpm.process.businessKey#two: (model || '-' || manufacturer) + @bpm.process.businessKey #one : (ID) + @bpm.process.businessKey #two: (model || '-' || manufacturer) entity MultiStartDiffBusinessKeys as projection on my.Car { ID, @@ -1845,7 +1845,7 @@ service AnnotationService { } // Two cancel annotations on DELETE with different qualified business keys - @bpm.process.cancel : { + @bpm.process.cancel #one : { on : 'DELETE', cascade: true, } @@ -1853,8 +1853,8 @@ service AnnotationService { on : 'DELETE', cascade: false, } - @bpm.process.businessKey : (ID) - @bpm.process.businessKey#two : (model || '-' || manufacturer) + @bpm.process.businessKey #one : (ID) + @bpm.process.businessKey #two : (model || '-' || manufacturer) entity MultiCancelDiffBusinessKeys as projection on my.Car { ID,