Skip to content
Open
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
38 changes: 31 additions & 7 deletions cds-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
PROCESS_PREFIX,
CUD_EVENTS,
EntityRow,
PROCESS_START_QUALIFIER_PREFIX,
PROCESS_START_QUALIFIER_PATTERN,
} from './lib/index';
import { importProcess } from './lib/processImport';

Expand Down Expand Up @@ -92,13 +94,35 @@ function expandEvent(event: string | undefined, entity: cds.entity): string[] {
function buildAnnotationCache(service: cds.Service) {
const cache = new Map<string, EntityEventCache>();
for (const entity of Object.values(service.entities)) {
const startEvent = entity[PROCESS_START_ON];
const cancelEvent = entity[PROCESS_CANCEL_ON];
const suspendEvent = entity[PROCESS_SUSPEND_ON];
const resumeEvent = entity[PROCESS_RESUME_ON];
const cancelEvent = entity[PROCESS_CANCEL_ON] as string | undefined;
const suspendEvent = entity[PROCESS_SUSPEND_ON] as string | undefined;
const resumeEvent = entity[PROCESS_RESUME_ON] as string | undefined;

const events = new Set<string>();
for (const ev of expandEvent(startEvent, entity)) events.add(ev);
// Collect all events that have a start annotation (non-qualified and qualified)
const startEventsSet = new Set<string>();

// Non-qualified: @bpm.process.start: { id, on }
const nonQualStartOn = entity[PROCESS_START_ON] as string | undefined;
if (nonQualStartOn && entity[PROCESS_START_ID]) {
for (const ev of expandEvent(nonQualStartOn, entity)) startEventsSet.add(ev);
}

// Qualified: @bpm.process.start #qualifier: { id, on }
// CDS stores as @bpm.process.start#qualifier.on, @bpm.process.start#qualifier.id
for (const key of Object.keys(entity)) {
const match = key.match(PROCESS_START_QUALIFIER_PATTERN);
if (match) {
const qualifier = match[1];
const onValue = entity[key as keyof typeof entity] as string | undefined;
const hasId = !!(entity[`${PROCESS_START_QUALIFIER_PREFIX}${qualifier}.id` as keyof typeof entity]);
if (onValue && hasId) {
for (const ev of expandEvent(onValue, entity)) startEventsSet.add(ev);
}
}
}

// Collect unique events across all annotation types
const events = new Set<string>([...startEventsSet]);
for (const ev of expandEvent(cancelEvent, entity)) events.add(ev);
for (const ev of expandEvent(suspendEvent, entity)) events.add(ev);
for (const ev of expandEvent(resumeEvent, entity)) events.add(ev);
Expand All @@ -107,7 +131,7 @@ function buildAnnotationCache(service: cds.Service) {
const matchesEvent = (annotationEvent: string | undefined) =>
annotationEvent === event || annotationEvent === '*';

const hasStart = !!(matchesEvent(startEvent) && entity[PROCESS_START_ID]);
const hasStart = startEventsSet.has(event);
const hasCancel = !!matchesEvent(cancelEvent);
const hasSuspend = !!matchesEvent(suspendEvent);
const hasResume = !!matchesEvent(resumeEvent);
Expand Down
16 changes: 16 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ export const PROCESS_INPUT = '@bpm.process.input' as const;
export const BUILD_PREFIX = '@bpm' as const;
export const PROCESS_PREFIX = '@bpm.process' as const;

/**
* Qualifier prefix for multiple process start annotations
* Usage: @bpm.process.start #qualifier: { id: '...', on: '...' }
* Stored in CDS as: @bpm.process.start#qualifier.id, @bpm.process.start#qualifier.on, etc.
*/
export const PROCESS_START_QUALIFIER_PREFIX = '@bpm.process.start#' as const;

/**
* Regex to match qualified @bpm.process.start#qualifier.on keys on a CDS entity.
* Derived from PROCESS_START_QUALIFIER_PREFIX so it stays in sync if the prefix changes.
* Capture group 1: the qualifier name (e.g. "approval" from "@bpm.process.start#approval.on")
*/
export const PROCESS_START_QUALIFIER_PATTERN = new RegExp(
`^${PROCESS_START_QUALIFIER_PREFIX.replace(/\./g, '\\.')}(\\w+)\\.on$`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not 100% about Regex is there no other way ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok let me check for some alternatives.

);

/**
* Process Event Annotations (Runtime)
* These annotations are used on dynamically created ProcessService events
Expand Down
152 changes: 96 additions & 56 deletions lib/handlers/processStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
PROCESS_INPUT,
LOG_MESSAGES,
PROCESS_LOGGER_PREFIX,
PROCESS_START_QUALIFIER_PREFIX,
PROCESS_START_QUALIFIER_PATTERN,
} from './../constants';

import cds from '@sap/cds';
Expand All @@ -39,12 +41,12 @@
target: Target,
req: cds.Request,
): column_expr[] | string[] {
const startSpecs = initStartSpecs(target, req);
if (startSpecs.inputs.length === 0) {
const sharedInputs = getSharedInputs(target, req);
if (sharedInputs.length === 0) {
LOG.debug(LOG_MESSAGES.NO_PROCESS_INPUTS_DEFINED);
return ['*'];
} else {
return convertToColumnsExpr(startSpecs.inputs);
return convertToColumnsExpr(sharedInputs);
}
}

Expand All @@ -55,70 +57,108 @@
data = ((req as ProcessDeleteRequest)._Process ??
getEntityDataFromRequest(data, req.params)) as EntityRow;

const startSpecs = initStartSpecs(target, req);
const allStartSpecs = getAllStartSpecs(target, req);
if (allStartSpecs.length === 0) {
LOG.debug(LOG_MESSAGES.PROCESS_NOT_STARTED);
return;
}

// if startSpecs.input = [] --> no input defined, fetch entire row
// @build.process.input annotations are element-level, shared across all start specs
const sharedInputs = allStartSpecs[0].inputs;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why you use only the first element of the allStartSpecs?

let columns: column_expr[] | string[] = [];
if (startSpecs.inputs.length === 0) {
if (sharedInputs.length === 0) {
columns = ['*'];
LOG.debug(LOG_MESSAGES.NO_PROCESS_INPUTS_DEFINED);
} else {
columns = convertToColumnsExpr(startSpecs.inputs);
columns = convertToColumnsExpr(sharedInputs);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could also don't know if possible have the for loop here instead of in the covertToColumnExpr then you would not need to rewrite the whole method

}

for (const startSpec of allStartSpecs) {
// Skip specs that don't apply to the current event
if (startSpec.on && startSpec.on !== req.event && startSpec.on !== '*') continue;

// fetch entity (includes per-spec condition check for non-DELETE events)
const row = await resolveEntityRowOrReject(

Check failure on line 81 in lib/handlers/processStart.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected `await` inside a loop
req,
data,
startSpec.conditionExpr,
'PROCESS_START_FETCH_FAILED',
LOG_MESSAGES.PROCESS_NOT_STARTED,
columns,
);
if (!row) continue; // condition not met for this spec — try the next one

// get business key
const businessKey = getBusinessKeyOrReject(
target as cds.entity,
row,
req,
'PROCESS_START_INVALID_KEY',
'PROCESS_START_EMPTY_KEY',
);
if (!businessKey) return; // invalid key is fatal for all specs

const context = { ...row, businesskey: businessKey };

// emit process start for this spec
const payload = { definitionId: startSpec.id!, context };
await emitProcessEvent('start', req, payload, 'PROCESS_START_FAILED', startSpec.id!);

Check failure on line 105 in lib/handlers/processStart.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected `await` inside a loop
}
}

/**
* Returns all start specs for the target entity, including both non-qualified
* (@build.process.start) and qualified (@build.process.start #qualifier) annotations.
* Inputs (@build.process.input) are element-level and shared across all specs.
*/
function getAllStartSpecs(target: Target, req: cds.Request): ProcessStartSpec[] {
const specs: ProcessStartSpec[] = [];
const entityAnnotations = target as unknown as Record<string, unknown>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks weird because we cast it to different types multiple times


// Shared inputs — element-level annotations, same for all start specs
const sharedInputs = getSharedInputs(target, req);

// Non-qualified annotation: @build.process.start: { id, on, if }
if (entityAnnotations[PROCESS_START_ON]) {
specs.push({
id: entityAnnotations[PROCESS_START_ID] as string,
on: entityAnnotations[PROCESS_START_ON] as string,
inputs: sharedInputs,
conditionExpr: entityAnnotations[PROCESS_START_IF]
? ((entityAnnotations[PROCESS_START_IF] as unknown as { xpr: expr }).xpr as expr)
: undefined,
});
}

// Qualified annotations: @bpm.process.start #qualifier: { id, on, if }
// CDS stores these as @bpm.process.start#qualifier.id, @bpm.process.start#qualifier.on, etc.
for (const key of Object.keys(entityAnnotations)) {
const match = key.match(PROCESS_START_QUALIFIER_PATTERN);
if (match) {
const qualifier = match[1];
const prefix = `${PROCESS_START_QUALIFIER_PREFIX}${qualifier}`;
specs.push({
id: entityAnnotations[`${prefix}.id`] as string,
on: entityAnnotations[key] as string,
inputs: sharedInputs,
conditionExpr: entityAnnotations[`${prefix}.if`]
? ((entityAnnotations[`${prefix}.if`] as unknown as { xpr: expr }).xpr as expr)
: undefined,
});
}
}

// fetch entity
const row = await resolveEntityRowOrReject(
req,
data,
startSpecs.conditionExpr,
'Failed to fetch entity for process start.',
LOG_MESSAGES.PROCESS_NOT_STARTED,
columns,
);
if (!row) return;

// get business key
const businessKey = getBusinessKeyOrReject(
target as cds.entity,
row,
req,
'Failed to build business key for process start.',
'Business key is empty for process start.',
);
if (!businessKey) return;

const context = { ...row, businesskey: businessKey };

// emit process start
const payload = { definitionId: startSpecs.id!, context };
await emitProcessEvent(
'start',
req,
payload,
`Failed to start process with definition ID ${startSpecs.id!}.`,
startSpecs.id!,
);
return specs;
}

function initStartSpecs(target: Target, req: cds.Request): ProcessStartSpec {
const startSpecs: ProcessStartSpec = {
id: target[PROCESS_START_ID] as string,
on: target[PROCESS_START_ON] as string,
inputs: [],
conditionExpr: target[PROCESS_START_IF]
? ((target[PROCESS_START_IF] as unknown as { xpr: expr }).xpr as expr)
: undefined,
};
/**
* Extracts shared process input elements from element-level @build.process.input annotations.
* These are the same for all start specs on an entity.
*/
function getSharedInputs(target: Target, req: cds.Request): ProcessStartInput[] {
const elementAnnotations = getElementAnnotations(target as cds.entity);
const entityName = (target as cds.entity).name;
startSpecs.inputs = getInputElements(
elementAnnotations,
new Set([entityName]),
[entityName],
req,
);

return startSpecs;
return getInputElements(elementAnnotations, new Set([entityName]), [entityName], req);
}

function getInputElements(
Expand Down
22 changes: 22 additions & 0 deletions lib/handlers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
PROCESS_START_ON,
PROCESS_SUSPEND_IF,
PROCESS_SUSPEND_ON,
PROCESS_START_QUALIFIER_PREFIX,
PROCESS_START_QUALIFIER_PATTERN,
} from '../constants';
import { getColumnsForProcessStart } from './processStart';
const { SELECT } = cds.ql;
Expand Down Expand Up @@ -262,6 +264,26 @@ export async function addDeletedEntityToRequest(
}
}

// Handle qualified start annotations: @bpm.process.start #qualifier: { on: 'DELETE', if: ... }
// CDS stores as @bpm.process.start#qualifier.on, @bpm.process.start#qualifier.if
if (areStartAnnotationsDefined) {
for (const key of Object.keys(annotatedTarget)) {
const match = key.match(PROCESS_START_QUALIFIER_PATTERN);
if (match && annotatedTarget[key] === 'DELETE') {
const qualifier = match[1];
const conditionExpr = annotatedTarget[`${PROCESS_START_QUALIFIER_PREFIX}${qualifier}.if`] as
| { xpr: expr }
| undefined;
if (conditionExpr) {
where =
Array.isArray(where) && where.length
? [{ xpr: where }, 'and', { xpr: conditionExpr.xpr }]
: conditionExpr.xpr;
}
}
}
}

if (where) {
// Safeguard: use ['*'] if columns array is empty to avoid invalid SQL
const selectColumns = columns.length > 0 ? columns : ['*'];
Expand Down
Loading
Loading