diff --git a/lib/constants.ts b/lib/constants.ts index 1e86b29..9a0b049 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -79,8 +79,8 @@ export const PROCESS_DEFINITION_ID = '@Process.DefinitionId' as const; export const LOG_MESSAGES = { PROCESS_NOT_STARTED: 'Not starting process as start condition(s) are not met.', - NO_PROCESS_INPUTS_DEFINED: - 'No process start input annotations defined, fetching entire entity row for process start context.', + PROCESS_INPUTS_FROM_DEFINITION: + 'No inputs annotation defined. Filtering entity fields by process definition inputs.', PROCESS_NOT_SUSPENDED: 'Not suspending process as suspend condition(s) are not met.', PROCESS_NOT_RESUMED: 'Not resuming process as resume condition(s) are not met.', PROCESS_NOT_CANCELLED: 'Not canceling process as cancel condition(s) are not met.', diff --git a/lib/handlers/processStart.ts b/lib/handlers/processStart.ts index b021b21..2620793 100644 --- a/lib/handlers/processStart.ts +++ b/lib/handlers/processStart.ts @@ -12,6 +12,7 @@ import { PROCESS_START_INPUTS, LOG_MESSAGES, PROCESS_LOGGER_PREFIX, + PROCESS_PREFIX, BUSINESS_KEY, BUSINESS_KEY_MAX_LENGTH, } from './../constants'; @@ -48,8 +49,13 @@ export function getColumnsForProcessStart(target: Target): (column_expr | string const startSpecs = initStartSpecs(target); startSpecs.inputs = parseInputToTree(target); if (startSpecs.inputs.length === 0) { - LOG.debug(LOG_MESSAGES.NO_PROCESS_INPUTS_DEFINED); - return ['*']; + LOG.debug(LOG_MESSAGES.PROCESS_INPUTS_FROM_DEFINITION); + + if (!startSpecs.id) { + LOG.warn('No process definition id found on target, falling back to wildcard.'); + return [WILDCARD]; + } + return resolveColumnsFromProcessDefinition(startSpecs.id, target); } else { return convertToColumnsExpr(startSpecs.inputs); } @@ -66,11 +72,16 @@ export async function handleProcessStart(req: cds.Request, data: EntityRow): Pro const startSpecs = initStartSpecs(target); startSpecs.inputs = parseInputToTree(target); - // if startSpecs.input = [] --> no input defined, fetch entire row + // if startSpecs.input = [] --> no input annotation defined, resolve from process definition let columns: (column_expr | string)[]; if (startSpecs.inputs.length === 0) { - columns = [WILDCARD]; - LOG.debug(LOG_MESSAGES.NO_PROCESS_INPUTS_DEFINED); + if (!startSpecs.id) { + LOG.warn('No process definition id found on target, falling back to wildcard.'); + columns = [WILDCARD]; + } else { + columns = resolveColumnsFromProcessDefinition(startSpecs.id, target); + } + LOG.debug(LOG_MESSAGES.PROCESS_INPUTS_FROM_DEFINITION); } else { columns = convertToColumnsExpr(startSpecs.inputs); } @@ -188,6 +199,45 @@ function parseInputToTree(target: Target): ProcessStartInput[] { return buildInputTree(parsedEntries, runtimeContext); } +function getProcessInputFieldNames(definitionId: string): string[] | undefined { + const definitions = cds.model?.definitions; + if (!definitions) return undefined; + let serviceName: string | undefined; + for (const name in definitions) { + if (Object.hasOwn(definitions, name)) { + const def = definitions[name] as unknown as Record; + if (def[PROCESS_PREFIX] === definitionId) { + serviceName = name; + break; + } + } + } + + if (!serviceName) return undefined; + + const processInputsType = definitions[`${serviceName}.ProcessInputs`] as + | { elements?: Record } + | undefined; + + if (!processInputsType?.elements) return undefined; + + return Object.keys(processInputsType.elements); +} + +function resolveColumnsFromProcessDefinition( + definitionId: string, + target: Target, +): (column_expr | string)[] { + const processFields = getProcessInputFieldNames(definitionId); + if (processFields) { + const entityElements = Object.keys((target as cds.entity).elements ?? {}); + const matchingFields = processFields.filter((f) => entityElements.includes(f)); + return matchingFields; + } + + return [WILDCARD]; +} + function convertToColumnsExpr(array: ProcessStartInput[]): (column_expr | string)[] { const result: (column_expr | string)[] = []; diff --git a/lib/handlers/utils.ts b/lib/handlers/utils.ts index e792119..2253a80 100644 --- a/lib/handlers/utils.ts +++ b/lib/handlers/utils.ts @@ -41,6 +41,10 @@ async function fetchEntity( results = {}; } + if (columns.length === 0) { + return {}; + } + const keyFields = getKeyFieldsForEntity(request.target as cds.entity); // build where clause diff --git a/tests/bookshop/package.json b/tests/bookshop/package.json index 4697be5..0127c5d 100644 --- a/tests/bookshop/package.json +++ b/tests/bookshop/package.json @@ -33,6 +33,14 @@ "eu12.bpm-horizon-walkme.sdshipmentprocessor.ShipmentHandlerService": { "kind": "process-service", "model": "srv/external/eu12.bpm-horizon-walkme.sdshipmentprocessor.shipmentHandler" + }, + "StartNoInputProcessService": { + "kind": "process-service", + "model": "srv/external/startNoInputProcess" + }, + "StartNoInputWithAssocProcessService": { + "kind": "process-service", + "model": "srv/external/startNoInputWithAssocProcess" } } }, diff --git a/tests/bookshop/srv/annotation-service.cds b/tests/bookshop/srv/annotation-service.cds index ee8d424..2a34969 100644 --- a/tests/bookshop/srv/annotation-service.cds +++ b/tests/bookshop/srv/annotation-service.cds @@ -890,8 +890,8 @@ service AnnotationService { // ============================================ // -------------------------------------------- - // Test 1: No inputs specified - // All entity fields should be included in context + // Test 1: No inputs specified, but ProcessInputs type exists + // Only entity fields matching ProcessInputs should be included // -------------------------------------------- @bpm.process.start: { id: 'startNoInputProcess', @@ -1440,8 +1440,8 @@ service AnnotationService { key ID : UUID; } - // Test 14: No inputs (all fields including Composition and Association) - // Without inputs array, all fields should be included + // Test 14: No inputs with Composition and Association + // ProcessInputs type matches all scalar fields, so all should be included // -------------------------------------------- @bpm.process.start: { id: 'startNoInputWithAssocProcess', @@ -1488,6 +1488,21 @@ service AnnotationService { name : String(100); } + // Test 16: No inputs, ProcessInputs exists but zero entity fields match + // Should send empty context {} + // -------------------------------------------- + @bpm.process.start: { + id: 'startNoInputProcess', + on: 'CREATE' + } + entity StartNoInputZeroMatch { + key ID : UUID; + shipmentDate : Date; + expectedDelivery : Date; + totalValue : Decimal(15, 2); + notes : String(1000); + } + // ============================================ // BUSINESS KEY LENGTH VALIDATION TESTS // Testing businessKey max length (255 chars) on processStart diff --git a/tests/bookshop/srv/external/startNoInputProcess.cds b/tests/bookshop/srv/external/startNoInputProcess.cds new file mode 100644 index 0000000..88fd930 --- /dev/null +++ b/tests/bookshop/srv/external/startNoInputProcess.cds @@ -0,0 +1,35 @@ +/* checksum : test-fixture-start-no-input */ + +/** Test fixture: Process definition for startNoInputProcess. + * Used to test that when no @bpm.process.start.inputs annotation is defined, + * only entity fields matching ProcessInputs element names are sent. */ +@protocol : 'none' +@bpm.process : 'startNoInputProcess' +service StartNoInputProcessService { + + type ProcessInputs { + status : String; + origin : String; + }; + + type ProcessOutputs {}; + + action start( + inputs : ProcessInputs not null + ); + + 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/external/startNoInputWithAssocProcess.cds b/tests/bookshop/srv/external/startNoInputWithAssocProcess.cds new file mode 100644 index 0000000..e7a3c4d --- /dev/null +++ b/tests/bookshop/srv/external/startNoInputWithAssocProcess.cds @@ -0,0 +1,36 @@ +/* checksum : test-fixture-start-no-input-with-assoc */ + +/** Test fixture: Process definition for startNoInputWithAssocProcess. + * Used to test that when no @bpm.process.start.inputs annotation is defined, + * only entity fields matching ProcessInputs element names are sent. */ +@protocol : 'none' +@bpm.process : 'startNoInputWithAssocProcess' +service StartNoInputWithAssocProcessService { + + type ProcessInputs { + ID : UUID; + status : String; + author_ID : UUID; + }; + + type ProcessOutputs {}; + + action start( + inputs : ProcessInputs not null + ); + + 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/integration/annotations/processStart-input.test.ts b/tests/integration/annotations/processStart-input.test.ts index fe00788..7e8395e 100644 --- a/tests/integration/annotations/processStart-input.test.ts +++ b/tests/integration/annotations/processStart-input.test.ts @@ -30,11 +30,11 @@ describe('Integration tests for START annotation with inputs array', () => { }; // ================================================ - // Test 1: No inputs array specified - // All entity fields should be included in context + // Test 1: No inputs array specified, but ProcessInputs type exists + // Only entity fields matching ProcessInputs should be included // ================================================ - describe('Test 1: No inputs array (all fields included)', () => { - it('should include all entity fields in process context', async () => { + describe('Test 1: No inputs array (filtered by ProcessInputs)', () => { + it('should include only entity fields matching ProcessInputs element names', async () => { const shipment = { ID: '550e8400-e29b-41d4-a716-446655440000', status: 'PENDING', @@ -54,8 +54,11 @@ describe('Integration tests for START annotation with inputs array', () => { const context = getStartContext(); expect(context).toBeDefined(); - // All fields should be present - expect(context).toEqual({ ...shipment }); + // Only fields matching ProcessInputs (status, origin) should be present + expect(context).toEqual({ + status: shipment.status, + origin: shipment.origin, + }); }); }); @@ -604,10 +607,10 @@ describe('Integration tests for START annotation with inputs array', () => { // ================================================ // Test 14: No inputs with Composition and Association - // Without inputs array, all fields should be included + // ProcessInputs type matches all scalar fields, so all should be included // ================================================ describe('Test 14: No inputs with Composition and Association', () => { - it('should include all fields including compositions and associations', async () => { + it('should include all scalar fields matching ProcessInputs', async () => { const author = { ID: '550e8400-e29b-41d4-a716-446655440099', }; @@ -629,7 +632,7 @@ describe('Integration tests for START annotation with inputs array', () => { const context = getStartContext(); expect(context).toBeDefined(); - // No inputs specified - should include all fields + // ProcessInputs has {ID, status, author_ID} — all match entity scalar fields expect(context).toEqual({ ID: entity.ID, status: entity.status, @@ -680,4 +683,32 @@ describe('Integration tests for START annotation with inputs array', () => { }); }); }); + + // ================================================ + // Test 16: No inputs, ProcessInputs exists but zero entity fields match + // Should send empty context {} + // ================================================ + describe('Test 16: No inputs, ProcessInputs exists but zero fields match', () => { + it('should send empty context when no entity fields match ProcessInputs', async () => { + const entity = { + ID: '550e8400-e29b-41d4-a716-44665544ff01', + shipmentDate: '2026-01-15', + expectedDelivery: '2026-01-25', + totalValue: 2500.0, + notes: 'Handle with care', + }; + + const response = await POST('/odata/v4/annotation/StartNoInputZeroMatch', entity); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + + const context = getStartContext(); + expect(context).toBeDefined(); + + // ProcessInputs has {status, origin} but entity has {ID, shipmentDate, expectedDelivery, totalValue, notes} + // No field names match, so context should be empty + expect(context).toEqual({}); + }); + }); });