@sap/cds 9.7.0 → 9.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@
4
4
  - The format is based on [Keep a Changelog](https://keepachangelog.com/).
5
5
  - This project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## Version 9.7.1 - 2026-02-06
8
+
9
+ ### Fixed
10
+
11
+ - `DELETE` requests nulling a `@mandatory` property
12
+ - Correctly call remote collection bound action for `odata-v4` services
13
+ - Flow annotation validation at compile time strictly follows the documentation: only enum status values are allowed
14
+ + Status value validation can be disabled via `cds.features.skip_flows_validation=true`
15
+
7
16
  ## Version 9.7.0 - 2026-02-02
8
17
 
9
18
  ### Added
@@ -147,7 +147,7 @@ function enhanceCSNwithFlowAnnotations4FE(csn) {
147
147
  }
148
148
 
149
149
  module.exports = function cds_compile_for_flows(csn) {
150
- const { history_for_flows } = cds.env.features
150
+ const { history_for_flows, skip_flows_validation } = cds.env.features
151
151
 
152
152
  const _requires_history = !history_for_flows
153
153
  ? () => false
@@ -168,6 +168,61 @@ module.exports = function cds_compile_for_flows(csn) {
168
168
  }
169
169
  }
170
170
 
171
+ const _validate_status_types = skip_flows_validation
172
+ ? () => {}
173
+ : (name, def, status, csn, errors) => {
174
+ // enum
175
+ let enumVals
176
+ if (status.type === 'cds.Association') {
177
+ const target = csn.definitions[status.target]
178
+ if (!status.keys || status.keys.length !== 1) {
179
+ errors.push(`Status element in entity ${name} must have exactly one key when used as association`)
180
+ return
181
+ }
182
+ const code = target.elements[status.keys[0].ref[0]]
183
+ enumVals = code.enum || csn.definitions[code.type]?.enum
184
+ } else {
185
+ enumVals = status.enum ?? csn.definitions[status.type]?.enum
186
+ }
187
+ if (!enumVals) {
188
+ errors.push(`Status element in entity ${name} must be an enum or target an entity with an enum named "code"`)
189
+ return
190
+ }
191
+ // actions
192
+ for (const each in def.actions) {
193
+ const action = def.actions[each]
194
+ const from = action[FROM]
195
+ if (from !== undefined) {
196
+ if (Array.isArray(from)) {
197
+ for (let i = 0; i < from.length; i++) {
198
+ if (from[i] !== null) {
199
+ let val = from[i]['#']
200
+ if (!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val))) {
201
+ errors.push(`Invalid ${FROM} value at position ${i} in action ${each}`)
202
+ }
203
+ }
204
+ }
205
+ } else if (from !== null) {
206
+ let val = from['#']
207
+ if (!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val))) {
208
+ errors.push(`Invalid ${FROM} value in action ${each}`)
209
+ }
210
+ }
211
+ }
212
+ const to = action[TO]
213
+ if (to !== undefined) {
214
+ if (Array.isArray(to)) {
215
+ errors.push(`${TO} must not be an array in action ${each}`)
216
+ } else if (to !== null && to['='] !== FLOW_PREVIOUS) {
217
+ let val = to['#']
218
+ if (!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val))) {
219
+ errors.push(`Invalid ${TO} value in action ${each}`)
220
+ }
221
+ }
222
+ }
223
+ }
224
+ }
225
+
171
226
  /*
172
227
  * 1. propagate flows for well-known actions from extensions to definitions
173
228
  */
@@ -185,11 +240,14 @@ module.exports = function cds_compile_for_flows(csn) {
185
240
  }
186
241
  }
187
242
 
243
+ const errors = []
188
244
  const to_be_extended = new Set()
189
245
 
190
246
  for (const name in csn.definitions) {
191
247
  const def = csn.definitions[name]
192
248
 
249
+ if (!def.kind || def.kind !== 'entity' || !def.elements || !def.actions) continue
250
+
193
251
  /*
194
252
  * 2. propagate @flow.status to respective element and make it @readonly
195
253
  */
@@ -201,8 +259,6 @@ module.exports = function cds_compile_for_flows(csn) {
201
259
  }
202
260
  }
203
261
 
204
- if (!def.kind || def.kind !== 'entity' || !def.actions) continue
205
-
206
262
  /*
207
263
  * 3. normalize @from and @to annotations
208
264
  */
@@ -213,7 +269,19 @@ module.exports = function cds_compile_for_flows(csn) {
213
269
  }
214
270
 
215
271
  /*
216
- * 4. automatically apply aspect FlowHistory if needed and not present yet
272
+ * 4. validate annotations
273
+ */
274
+ let status = Object.values(def.elements).filter(e => e['@flow.status'])
275
+ if (status.length === 0) continue
276
+ if (status.length > 1) {
277
+ errors.push(`Entity ${name} has multiple status elements. Only one @flow.status element is allowed per entity`)
278
+ continue
279
+ }
280
+ status = status[0]
281
+ _validate_status_types(name, def, status, csn, errors)
282
+
283
+ /*
284
+ * 5. automatically apply aspect FlowHistory if needed and not present yet
217
285
  */
218
286
  if (!_requires_history(def)) continue
219
287
 
@@ -226,6 +294,17 @@ module.exports = function cds_compile_for_flows(csn) {
226
294
  to_be_extended.add(base_name)
227
295
  }
228
296
 
297
+ /*
298
+ * 6. throw validation errors, if any
299
+ */
300
+ if (errors.length) {
301
+ if (errors.length === 1) cds.error(errors[0])
302
+ else cds.error('MULTIPLE_ERRORS', { details: errors.map(message => ({ message })) })
303
+ }
304
+
305
+ /*
306
+ * 7. apply the extensions
307
+ */
229
308
  if (to_be_extended.size) {
230
309
  // REVISIT: ensure sap.common.FlowHistory is there
231
310
  csn.definitions['sap.common.FlowHistory'] ??= JSON.parse(FlowHistory)
@@ -254,81 +333,9 @@ module.exports = function cds_compile_for_flows(csn) {
254
333
  csn = dsn
255
334
  }
256
335
 
257
- // validate flow in CSN
258
- const messages = []
259
- const validate = (status, enumVals, action, fromTo) => {
260
- if (status === null) return true
261
- if (enumVals) {
262
- let val = status['#']
263
- if (
264
- !(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val)) &&
265
- !(fromTo === TO && typeof status === 'object' && status['='] === FLOW_PREVIOUS)
266
- ) {
267
- messages.push(`Invalid ${fromTo} value(s) in action ${action}`);
268
- return false;
269
- }
270
- } else {
271
- if (typeof status !== 'string' && !(fromTo === TO && typeof status === 'object' && status['='] === FLOW_PREVIOUS)) {
272
- messages.push(`Invalid ${fromTo} value(s) in action ${action}`)
273
- return false
274
- }
275
- }
276
-
277
- return true
278
- }
279
-
280
- for (const name in csn.definitions) {
281
- const def = csn.definitions[name]
282
- if (!def.kind || def.kind !== 'entity' || !def.actions || !def.elements) continue
283
-
284
- const statusElements = Object.values(def.elements).filter(e => e['@flow.status'])
285
- if (statusElements.length === 0) continue
286
- if (statusElements.length > 1) {
287
- messages.push(`Entity ${name} has multiple status elements. Only one @flow.status element is allowed per entity`)
288
- continue
289
- }
290
- const status = statusElements[0]
291
-
292
- let enumVals
293
- if (status.type === 'cds.Association') {
294
- const target = csn.definitions[status.target]
295
- if (!status.keys || status.keys.length !== 1) {
296
- messages.push(`Status element in entity ${name} must have exactly one key when used as association`)
297
- continue
298
- }
299
- const code = target.elements[status.keys[0].ref[0]]
300
- enumVals = code.enum || csn.definitions[code.type]?.enum
301
- } else if (status.enum) {
302
- enumVals = status.enum
303
- } else if (csn.definitions[status.type]?.enum) {
304
- enumVals = csn.definitions[status.type].enum
305
- }
306
-
307
- for (const each in def.actions) {
308
- const action = def.actions[each]
309
- let froms = action[FROM]
310
- if (froms !== undefined) {
311
- froms = Array.isArray(froms) ? froms : [froms]
312
- for (let from of froms) if (!validate(from, enumVals, each, FROM)) break
313
- }
314
- let tos = action[TO]
315
- if (tos !== undefined) {
316
- if (Array.isArray(tos)) {
317
- messages.push(`${TO} must not be an array in action ${each}`)
318
- continue
319
- }
320
- validate(tos, enumVals, each, TO)
321
- }
322
- }
323
- }
324
- if (messages.length) {
325
- if (messages.length === 1) cds.error(messages[0])
326
- else {
327
- const errors = messages.map(message => ({ message }))
328
- cds.error ('MULTIPLE_ERRORS', { details: errors })
329
- }
330
- }
331
-
336
+ /*
337
+ * 8. exclude transitions_ from draft
338
+ */
332
339
  // REVISIT: annotate all X.transitions_ with @odata.draft.enabled: false
333
340
  for (const name in csn.definitions)
334
341
  if (name.endsWith('.transitions_')) csn.definitions[name]['@odata.draft.enabled'] = false
@@ -51,7 +51,7 @@ class HttpAdapter {
51
51
  const user = cds.context.user
52
52
  if (required.some(role => user.has(role))) return next()
53
53
  else if (user._is_anonymous) return next(401) // request login
54
- else throw Object.assign(new Error, { code: 403, reason: `User '${user.id}' is lacking required roles: [${required}]`, user, required })
54
+ else throw Object.assign(new Error, { code: 403, reason: `User '${user.id}' is lacking required roles: [${required}]` })
55
55
  }
56
56
  }
57
57
 
@@ -45,7 +45,7 @@ const _buildPartialUrlFunctions = (url, data, params, kind = 'odata-v4') => {
45
45
  : `${url}(${funcParams.join(',')})?${queryOptions.join('&')}`
46
46
  }
47
47
 
48
- const _extractParamsFromData = (data, params = {}) => {
48
+ const _extractParamsFromData = (data = {}, params = {}) => {
49
49
  return Object.keys(data).reduce((res, el) => {
50
50
  if (params[el]) Object.assign(res, { [el]: data[el] })
51
51
  return res
@@ -142,7 +142,16 @@ const _addHandlerActionFunction = (srv, def, target) => {
142
142
  srv.on(event, target, async function (req) {
143
143
  const shortEntityName = req.target.name.replace(`${this.definition.name}.`, '')
144
144
  if (this.kind === 'odata-v2') return _handleV2BoundActionFunction(srv, def, req, event, this.kind)
145
- const url = `/${shortEntityName}(${_buildKeys(req, this.kind).join(',')})/${this.definition.name}.${event}`
145
+
146
+ const action = req.target.actions[req.event]
147
+ const onCollection = !!(
148
+ action['@cds.odata.bindingparameter.collection'] ||
149
+ (action?.params && [...action.params].some(p => p?.items?.type === '$self'))
150
+ )
151
+
152
+ const url = onCollection
153
+ ? `/${shortEntityName}/${this.definition.name}.${event}`
154
+ : `/${shortEntityName}(${_buildKeys(req, this.kind).join(',')})/${this.definition.name}.${event}`
146
155
  return _handleBoundActionFunction(srv, def, req, url)
147
156
  })
148
157
  } else {
@@ -38,7 +38,8 @@ module.exports = adapter => {
38
38
  const headers = { ...cds.context.http.req.headers, ...req.headers }
39
39
 
40
40
  // we need a cds.Request for multiple reasons, incl. params, headers, sap-messages, read after write, ...
41
- const cdsReq = adapter.request4({ query, data, headers, params, req, res })
41
+ // for UPDATE: data is already provided via query
42
+ const cdsReq = adapter.request4({ query, data: query.DELETE ? data : undefined, headers, params, req, res })
42
43
 
43
44
  // NOTES:
44
45
  // - only via srv.run in combination with srv.dispatch inside,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "9.7.0",
3
+ "version": "9.7.1",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [