Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 51 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -135,7 +136,7 @@ Both processes are started when a `CREATE` event occurs on the entity, but `noti
- `@bpm.process.<cancel|resume|suspend>` -- Cancel/Suspend/Resume any processes with the given businessKey
- `@bpm.process.<cancel|resume|suspend>.on`
- `@bpm.process.<cancel|resume|suspend>.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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -661,6 +708,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)

Expand All @@ -686,7 +735,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.<cancel|suspend|resume>.cascade` is optional (defaults to false); if provided, must be a boolean
- `@bpm.process.<cancel|suspend|resume>.if` must be a valid CDS expression (if present)
- If any annotation with `@bpm.process.<cancel|suspend|resume>` is defined, a valid business key expression must be defined using `@bpm.process.businessKey`.
- If any annotation with `@bpm.process.<cancel|suspend|resume>` 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.

Expand Down
14 changes: 14 additions & 0 deletions lib/build/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =============================================================================
Expand Down
22 changes: 18 additions & 4 deletions lib/build/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
validateRequiredStartAnnotations,
validateIfAnnotation,
validateBusinessKeyAnnotation,
validateBusinessKeyForProcessStart,
} from './index';
import {
PROCESS_START,
Expand All @@ -21,11 +22,14 @@ 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 {
extractQualifier,
getAnnotationPrefixes,
resolveBusinessKeyAnnotation,
} from '../shared/annotations-helper';

const LOG = cds.log('process-build');

Expand Down Expand Up @@ -101,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);

Expand All @@ -118,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') {
Expand Down Expand Up @@ -155,7 +166,10 @@ 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 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
validateRequiredGenericAnnotations(
Expand All @@ -180,7 +194,7 @@ export class ProcessValidationPlugin extends BuildPluginBase {
}

if (hasOn && hasBusinessKey) {
validateBusinessKeyAnnotation(def, entityName, this);
validateBusinessKeyAnnotation(def, entityName, businessKeyAnnotation, this);
}
}
}
Expand Down
34 changes: 31 additions & 3 deletions lib/build/validations.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -28,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';

Expand Down Expand Up @@ -84,14 +85,41 @@ 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;
}
Comment thread
tilwbr marked this conversation as resolved.
}

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,
);
}
}

Expand Down
26 changes: 23 additions & 3 deletions lib/shared/annotations-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,30 @@ 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 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,
qualifier: string | undefined,
): `@${string}` {
if (qualifier) {
const qualifiedKey: `@${string}` = `${BUSINESS_KEY}#${qualifier}`;
if ((entity as CsnEntity)[qualifiedKey] !== undefined) return qualifiedKey;
}
return BUSINESS_KEY;
}
Comment thread
tilwbr marked this conversation as resolved.

/**
* Scans all keys on a CDS entity object and returns the unique annotation prefixes
* that match the given base annotation.
Expand Down Expand Up @@ -56,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 = (entity[`${BUSINESS_KEY}`] as { '=': string } | undefined)?.['='];
const businessKeyAnnotation = resolveBusinessKeyAnnotation(entity, qualifier);
const businessKey = (entity[businessKeyAnnotation] as { '=': string } | undefined)?.['='];

results.push({
qualifier,
Expand Down Expand Up @@ -88,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 = (entity[`${BUSINESS_KEY}`] as { '=': string } | undefined)?.['='];
const businessKeyAnnotation = resolveBusinessKeyAnnotation(entity, qualifier);
const businessKey = (entity[businessKeyAnnotation] as { '=': string } | undefined)?.['='];

results.push({
qualifier,
Expand Down
26 changes: 22 additions & 4 deletions tests/bookshop/srv/annotation-hybrid-service.cds
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,45 @@ 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'
}
// 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.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 #two: (model)
entity QualifiedAnnotations {
key ID : UUID @mandatory;
model : String(100);
manufacturer : String(100);
mileage : Integer;
year : Integer;
}

action getInstancesByBusinessKey(ID: UUID,
action getInstancesByBusinessKey(ID: String,
status: many String) returns many ProcessInstance;

}
39 changes: 39 additions & 0 deletions tests/bookshop/srv/annotation-service.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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 #one : (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',
Expand Down Expand Up @@ -1825,6 +1844,26 @@ service AnnotationService {
year
}

// Two cancel annotations on DELETE with different qualified business keys
@bpm.process.cancel #one : {
on : 'DELETE',
cascade: true,
}
@bpm.process.cancel #two : {
on : 'DELETE',
cascade: false,
}
@bpm.process.businessKey #one : (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',
Expand Down
Loading
Loading