@sap/cds 9.4.5 → 9.5.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/_i18n/messages_en_US_saptrc.properties +1 -1
  3. package/common.cds +5 -2
  4. package/lib/compile/cds-compile.js +1 -0
  5. package/lib/compile/for/assert.js +64 -0
  6. package/lib/compile/for/flows.js +194 -58
  7. package/lib/compile/for/lean_drafts.js +75 -7
  8. package/lib/compile/parse.js +1 -1
  9. package/lib/compile/to/csn.js +6 -2
  10. package/lib/compile/to/edm.js +1 -1
  11. package/lib/compile/to/yaml.js +8 -1
  12. package/lib/dbs/cds-deploy.js +2 -2
  13. package/lib/env/cds-env.js +14 -4
  14. package/lib/env/defaults.js +6 -1
  15. package/lib/i18n/localize.js +1 -1
  16. package/lib/index.js +7 -7
  17. package/lib/req/event.js +4 -0
  18. package/lib/req/validate.js +3 -0
  19. package/lib/srv/cds.Service.js +2 -1
  20. package/lib/srv/middlewares/auth/ias-auth.js +5 -7
  21. package/lib/srv/middlewares/auth/index.js +1 -1
  22. package/lib/srv/protocols/index.js +7 -6
  23. package/lib/srv/srv-handlers.js +7 -0
  24. package/libx/_runtime/common/Service.js +5 -1
  25. package/libx/_runtime/common/constants/events.js +1 -0
  26. package/libx/_runtime/common/generic/assert.js +220 -0
  27. package/libx/_runtime/common/generic/flows.js +168 -108
  28. package/libx/_runtime/common/utils/cqn.js +0 -24
  29. package/libx/_runtime/common/utils/normalizeTimestamp.js +2 -2
  30. package/libx/_runtime/common/utils/resolveView.js +8 -2
  31. package/libx/_runtime/common/utils/templateProcessor.js +10 -1
  32. package/libx/_runtime/common/utils/templateProcessorPathSerializer.js +21 -9
  33. package/libx/_runtime/fiori/lean-draft.js +511 -379
  34. package/libx/_runtime/messaging/enterprise-messaging-utils/registerEndpoints.js +39 -35
  35. package/libx/_runtime/messaging/enterprise-messaging.js +2 -2
  36. package/libx/_runtime/remote/Service.js +4 -5
  37. package/libx/_runtime/ucl/Service.js +111 -15
  38. package/libx/common/utils/streaming.js +1 -1
  39. package/libx/odata/middleware/batch.js +8 -6
  40. package/libx/odata/middleware/create.js +2 -2
  41. package/libx/odata/middleware/delete.js +2 -2
  42. package/libx/odata/middleware/metadata.js +18 -11
  43. package/libx/odata/middleware/read.js +2 -2
  44. package/libx/odata/middleware/service-document.js +1 -1
  45. package/libx/odata/middleware/update.js +1 -1
  46. package/libx/odata/parse/afterburner.js +24 -25
  47. package/libx/odata/parse/cqn2odata.js +2 -6
  48. package/libx/odata/parse/grammar.peggy +90 -12
  49. package/libx/odata/parse/parser.js +1 -1
  50. package/libx/odata/utils/index.js +2 -2
  51. package/libx/odata/utils/readAfterWrite.js +2 -0
  52. package/libx/queue/TaskRunner.js +26 -1
  53. package/libx/queue/index.js +11 -1
  54. package/package.json +1 -1
  55. package/srv/ucl-service.cds +2 -0
@@ -77,7 +77,7 @@ exports.ttl = (parse, strings, ...values) => {
77
77
  // }
78
78
 
79
79
  let cql = values.reduce ((cql,v,i) => {
80
- if (Array.isArray(v) && strings[i].match(/ in $/i)) values[i] = { list: v.map(cxn4) }
80
+ if (Array.isArray(v) && strings[i].match(/\sin\s*$/i)) values[i] = { list: v.map(cxn4) }
81
81
  return cql + strings[i] + (v instanceof cds.entity ? v.name : ':'+i)
82
82
  },'') + strings.at(-1)
83
83
  const cqn = parse (cql) //; cqn.$params = values
@@ -26,8 +26,12 @@ function cds_compile_to_csn (model, options, _flavor) {
26
26
  else return _finalize (cdsc.compileSources(model,o)) //> compile CDL sources
27
27
 
28
28
  function _finalize (csn) {
29
- // REVISIT: experimental implementation to automatically add aspect FlowHistory
30
- if (cds.env.features.history_for_flows) cds.compile.for.flows(csn)
29
+ // REVISIT: should move into compiler
30
+ if (cds.env.features.compile_for_assert) cds.compile.for.assert(csn)
31
+
32
+ // REVISIT: should move into compiler
33
+ csn = cds.compile.for.flows(csn)
34
+
31
35
  if (o.min) csn = cds.minify(csn)
32
36
  // REVISIT: experimental implementation to detect external APIs
33
37
  for (let each in csn.definitions) {
@@ -41,7 +41,7 @@ function cds_compile_to_edmx (csn,_o) {
41
41
  let result
42
42
  const next = () => {
43
43
  if (!result) {
44
- if (cds.env.features.compile_for_flows) enhanceCSNwithFlowAnnotations4FE(csn)
44
+ if (cds.env.features.annotate_for_flows) enhanceCSNwithFlowAnnotations4FE(csn)
45
45
  result = o.service === 'all' ? _many('.xml', cdsc.to.edmx.all(csn, o)) : cdsc.to.edmx(csn, o)
46
46
  }
47
47
  return result
@@ -29,9 +29,16 @@ module.exports = function _2yaml (object, {limit=111}={}) {
29
29
  if (typeof o === 'string') {
30
30
  if (o.indexOf('\n')>=0) return '|'+'\n'+indent+ o.replace(/\n/g,'\n'+indent)
31
31
  let s = o.trim()
32
- return !s || /^[\^@#:,=!<>*+-/|]/.test(s) || /:\s/.test(o) ? '"'+ o.replace(/\\/g,'\\\\') +'"' : s
32
+ return !s || _needs_quoting(s) ? '"'+ o.replace(/\\/g,'\\\\') +'"' : s
33
33
  }
34
34
  if (typeof o === 'function') return
35
35
  else return o
36
36
  }
37
37
  }
38
+
39
+
40
+ const _needs_quoting = s => {
41
+ if (/^[\^@#:,=!<>*+\-/|]/.test(s)) return true
42
+ if (/[{},[\]]/.test(s)) return true
43
+ if (/:\s/.test(s)) return true
44
+ }
@@ -113,8 +113,8 @@ deploy.schema = async function (db, csn = db.model, o) {
113
113
  if (!drops.length && !creas.length) return !o.dry
114
114
 
115
115
  if (schema_log) {
116
- schema_log.log(); for (let each of drops) schema_log.log(each)
117
- schema_log.log(); for (let each of creas) schema_log.log(each, '\n')
116
+ schema_log.log(); for (let each of drops) { schema_log.log(each) }
117
+ schema_log.log(); for (let each of creas) { schema_log.log(each); schema_log.log(); }
118
118
  }
119
119
  if (o.dry) return
120
120
 
@@ -293,16 +293,26 @@ class Config {
293
293
  }
294
294
  }
295
295
 
296
- _add_cloud_service_bindings({ VCAP_SERVICES, SERVICE_BINDING_ROOT }) {
296
+ _add_cloud_service_bindings({ VCAP_SERVICES, VCAP_SERVICES_FILE_PATH, SERVICE_BINDING_ROOT }) {
297
297
  let bindings, bindingsSource
298
298
 
299
299
  if (!this.requires) return
300
- if (VCAP_SERVICES && !(this.features && this.features.vcaps == false)) {
300
+ if (this.features?.vcaps === false) return
301
+
302
+ if (VCAP_SERVICES_FILE_PATH) {
303
+ try {
304
+ bindings = JSON.parse (fs.readFileSync(VCAP_SERVICES_FILE_PATH,'utf-8'))
305
+ bindingsSource = VCAP_SERVICES_FILE_PATH
306
+ } catch(e) {
307
+ throw new Error ('[cds.env] - failed to read/parse VCAP_SERVICES_FILE_PATH', {cause: e})
308
+ }
309
+ }
310
+ else if (VCAP_SERVICES) {
301
311
  try {
302
312
  bindings = JSON.parse(VCAP_SERVICES)
303
313
  bindingsSource = 'process.env.VCAP_SERVICES'
304
314
  } catch(e) {
305
- throw new Error ('[cds.env] - failed to parse VCAP_SERVICES:\n '+ e.message)
315
+ throw new Error ('[cds.env] - failed to parse VCAP_SERVICES', {cause: e})
306
316
  }
307
317
  }
308
318
 
@@ -316,7 +326,7 @@ class Config {
316
326
  const any = this._add_vcap_services_to(bindings)
317
327
  if (any) this._sources.push(bindingsSource)
318
328
  } catch(e) {
319
- throw new Error(`[cds.env] - failed to add service bindings from ${bindingsSource}:\n ${e.message}`);
329
+ throw new Error(`[cds.env] - failed to add service bindings from ${bindingsSource}`, {cause: e});
320
330
  }
321
331
  }
322
332
  }
@@ -27,6 +27,7 @@ module.exports = {
27
27
  'odata-v2' : { path: '/odata/v2' },
28
28
  'rest' : { path: '/rest' },
29
29
  'hcql' : { path: '/hcql' },
30
+ 'data.product' : null, // data products are not http-served protocols
30
31
  },
31
32
 
32
33
  features: {
@@ -46,6 +47,9 @@ module.exports = {
46
47
  precise_timestamps: false,
47
48
  ieee754compatible: undefined,
48
49
  consistent_params: true, //> remove with cds^10
50
+ annotate_for_flows: true,
51
+ history_for_flows: true,
52
+ compile_for_assert: undefined,
49
53
  // compat for db
50
54
  get string_decimals() { return this.ieee754compatible }
51
55
  },
@@ -57,7 +61,8 @@ module.exports = {
57
61
  wrap_multiple_errors: true,
58
62
  draft_lock_timeout: true,
59
63
  draft_deletion_timeout: true,
60
- draft_messages: true
64
+ draft_messages: true,
65
+ draft_new_action: false
61
66
  },
62
67
 
63
68
  ql: {
@@ -85,7 +85,7 @@ exports.edmx = edmx => {
85
85
 
86
86
  exports.json = json => {
87
87
  if (typeof json === 'object') json = JSON.stringify(json)
88
- const _json_replacer = s => s?.replace(/"/g, '\\"')
88
+ const _json_replacer = s => s && JSON.stringify(s).slice(1,-1)
89
89
  return localize(json) .using (_json_replacer)
90
90
  }
91
91
 
package/lib/index.js CHANGED
@@ -116,13 +116,13 @@ const cds = exports = module.exports = global.cds = new class cds extends EventE
116
116
  tx (..._) { return (this.db || this.txs).tx(..._) }
117
117
  run (..._) { return (this.db || typeof _[0] === 'function' && this.txs || this.error._no_primary_db).run(..._) }
118
118
  foreach (..._) { return (this.db || this.error._no_primary_db).foreach(..._) }
119
- read (..._) { return (this.db || this.error._no_primary_db).read(..._) }
120
- create (..._) { return (this.db || this.error._no_primary_db).create(..._) }
121
- insert (..._) { return (this.db || this.error._no_primary_db).insert(..._) }
122
- update (..._) { return (this.db || this.error._no_primary_db).update(..._) }
123
- upsert (..._) { return (this.db || this.error._no_primary_db).upsert(..._) }
124
- delete (..._) { return (this.db || this.error._no_primary_db).delete(..._) }
125
- disconnect (..._) { return (this.db || this.error._no_primary_db).disconnect(..._) }
119
+ read (..._) { return this.ql.SELECT(..._) }
120
+ create (..._) { return this.ql.INSERT.into(..._) }
121
+ insert (..._) { return this.ql.INSERT(..._) }
122
+ upsert (..._) { return this.ql.UPSERT(..._) }
123
+ update (..._) { return this.ql.UPDATE.entity(..._) }
124
+ delete (..._) { return this.ql.DELETE.from(..._) }
125
+ disconnect (..._) { return this.db?.disconnect(..._) }
126
126
 
127
127
  // Deprecated stuff to be removed in upcomming releases...
128
128
  /** @deprecated */ get lazified() { return this.lazify }
package/lib/req/event.js CHANGED
@@ -8,3 +8,7 @@ class EventMessage extends EventContext {}
8
8
 
9
9
  module.exports = exports = EventMessage
10
10
  exports.Context = EventContext
11
+
12
+ exports.CRUD_EVENTS = { CREATE: 1, READ: 1, UPDATE: 1, DELETE: 1 }
13
+ exports.DRAFT_EVENTS = { NEW: 1, SAVE: 1, PATCH: 1, DISCARD: 1, EDIT: 1 }
14
+ exports.WELL_KNOWN_EVENTS = { ...exports.CRUD_EVENTS, ...exports.DRAFT_EVENTS }
@@ -1,5 +1,7 @@
1
1
  const cds = require('..')
2
2
 
3
+ const { WELL_KNOWN_EVENTS } = require('./event')
4
+
3
5
  /** Validates given input data against a request target definition.
4
6
  * @param {entity} target the linked definition to check against, usually an entity definition
5
7
  * @returns {Error[]|undefined} an array of errors or undefined if no errors occurred
@@ -243,6 +245,7 @@ class entity extends struct {
243
245
  /** Actions are struct-like, with their parameters as elements to validate. */
244
246
  class action extends struct {
245
247
  validate (data, path, ctx) {
248
+ if (this.name in WELL_KNOWN_EVENTS) return
246
249
  super.validate (data, path, ctx, this.params || {})
247
250
  }
248
251
 
@@ -175,7 +175,8 @@ class Service extends ReflectionAPI {
175
175
  this._resolve.transitions = (query, abortCondition, skipForbiddenViewCheck) => {
176
176
  const target = query && typeof query === 'object' ? cds.infer.target(query) || query?._target : undefined
177
177
  const _tx = typeof tx === 'function' ? cds.context?.tx : this
178
- return getTransition(target, _tx, skipForbiddenViewCheck, undefined, {
178
+ const event = query?.INSERT ? 'INSERT' : query?.UPDATE ? 'UPDATE' : query?.DELETE ? 'DELETE' : undefined
179
+ return getTransition(target, _tx, skipForbiddenViewCheck, event, {
179
180
  abort: abortCondition ?? (this.isDatabaseService ? this.resolve._abortDB : _defaultAbort(this))
180
181
  })
181
182
  }
@@ -12,14 +12,12 @@ const {
12
12
 
13
13
  module.exports = function ias_auth(config) {
14
14
  // cds.env.requires.auth.known_claims is not an official config!
15
- const { kind, credentials, config: serviceConfig = {}, known_claims = KNOWN_CLAIMS } = config
15
+ const { kind, credentials, config: serviceConfig = {}, known_claims = KNOWN_CLAIMS, xsuaa = 'xsuaa' } = config
16
16
  const skipped_attrs = known_claims.reduce((a, x) => ((a[x] = 1), a), {})
17
17
 
18
18
  if (!credentials)
19
- throw new Error(
20
- `Authentication kind "${kind}" configured, but no IAS instance bound to application. ` +
21
- 'Either bind an IAS instance, or switch to an authentication kind that does not require a binding.'
22
- )
19
+ cds.error(`Authentication kind "${kind}" configured, but no IAS instance bound to application. ` +
20
+ 'Either bind an IAS instance, or switch to an authentication kind that does not require a binding.')
23
21
 
24
22
  // enable signature cache by default
25
23
  serviceConfig.validation ??= {}
@@ -56,8 +54,8 @@ module.exports = function ias_auth(config) {
56
54
  // xsuaa fallback allows to also accept XSUAA tokens during migration to IAS
57
55
  // automatically enabled if xsuaa credentials are available
58
56
  let xsuaa_service, xsuaa_user_factory
59
- if (cds.env.requires.xsuaa?.credentials) {
60
- const { credentials: xsuaa_credentials, config: xsuaa_serviceConfig = {} } = cds.env.requires.xsuaa
57
+ if (cds.env.requires[xsuaa]?.credentials) {
58
+ const { credentials: xsuaa_credentials, config: xsuaa_serviceConfig = {} } = cds.env.requires[xsuaa]
61
59
  xsuaa_service = new XsuaaService(xsuaa_credentials, xsuaa_serviceConfig)
62
60
  const get_xsuaa_user_factory = require('./jwt-auth')._get_user_factory
63
61
  xsuaa_user_factory = get_xsuaa_user_factory(xsuaa_credentials, xsuaa_credentials.xsappname, 'xsuaa')
@@ -17,7 +17,7 @@ for (let b in _builtin) _builtin[b+'-auth'] = _builtin[b]
17
17
  module.exports = function auth_factory (o) {
18
18
 
19
19
  // prepare options
20
- const options = { ...o, ...cds.requires.auth }
20
+ const options = { ...cds.requires.auth, ...o }
21
21
  let { kind, impl } = options
22
22
 
23
23
  // if no impl is given, it's a built-in strategy
@@ -29,6 +29,7 @@ class Protocols {
29
29
  if (typeof p === 'string') p = { path:p }
30
30
  if (merge) p = { ...protocols[kind], ...p }
31
31
  if (!p.impl) p.impl = './'+kind
32
+ if (!p.path) return p
32
33
  if (!p.path.startsWith('/')) p.path = '/'+p.path
33
34
  if (p.path.endsWith('/')) p.path = p.path.slice(0,-1)
34
35
  return p
@@ -108,7 +109,7 @@ class Protocols {
108
109
  else {
109
110
  annos=[]; for (let kind in this) {
110
111
  let path = def['@'+kind] || def['@protocol.'+kind]
111
- if (path) annos.push ({ kind, path })
112
+ if (path) annos.push ({ kind, path: typeof path === 'string' ? path : undefined })
112
113
  }
113
114
  }
114
115
  // no annotations at all -> use default protocol
@@ -116,11 +117,11 @@ class Protocols {
116
117
 
117
118
  // canonicalize to { kind, path } objects
118
119
  const endpoints = annos.map (each => {
119
- let { kind = each['='] || each, path } = each
120
- if (!(kind in this))
121
- return cds.log('adapters').warn ('ignoring unknown protocol:', kind)
122
- if (typeof path !== 'string') path = o?.at || o?.path || def['@path'] || _slugified(srv.name)
123
- if (path[0] !== '/') path = this[kind].path + '/' + path // prefix with protocol path
120
+ let { kind = each['='] || each, path } = each, protocol = this[kind]
121
+ if (protocol == undefined) return cds.log('adapters') .warn ('ignoring unknown protocol:', kind)
122
+ if (protocol.path == undefined) return // a pseudo-protocol, not served to http, e.g. data.product
123
+ if (!path) path = o?.at || o?.path || def['@path'] || _slugified(srv.name)
124
+ if (path[0] !== '/') path = protocol.path + '/' + path // prefix with protocol path
124
125
  return { kind, path }
125
126
  }) .filter (e => e) //> skipping unknown protocols
126
127
 
@@ -108,6 +108,13 @@ class EventHandlers {
108
108
 
109
109
  // Finally register with a filter function to match requests to be handled
110
110
  const handlers = event === 'error' ? this._error : handler._initial ? this._initial : this[phase] // REVISIT: remove _initial handlers
111
+
112
+ // REVISIT: remove compat flag with cds^10
113
+ if (!cds.env.features.async_handler_compat && (phase === 'before' && !handler._initial || phase === 'after') && handler.constructor.name !== 'AsyncFunction') {
114
+ const originalHandler = handler
115
+ handler = async function (...args) { return originalHandler.call(this, ...args) }
116
+ }
117
+
111
118
  handlers.push (new EventHandler (phase, event, path, handler))
112
119
 
113
120
  if (phase === 'on') cds.emit('subscribe',srv,event) //> inform messaging service
@@ -59,6 +59,10 @@ class ApplicationService extends cds.Service {
59
59
  return require('./generic/flows')
60
60
  }
61
61
 
62
+ static get handle_assert() {
63
+ return require('./generic/assert')
64
+ }
65
+
62
66
  // Overload .handle in order to resolve projections up to a definition that is known by the remote service instance.
63
67
  // Result is post processed according to the inverse projection in order to reflect the correct result of the original query.
64
68
  async handle(req) {
@@ -66,7 +70,7 @@ class ApplicationService extends cds.Service {
66
70
  if (!this._requires_resolving?.(req)) return super.handle(req)
67
71
  // rewrite the query to a target entity served by this service...
68
72
  const query = this.resolve(req.query)
69
- if (!query) throw new Error(`Target ${req.target.name} cannot be resolved for service ${this.name}`)
73
+ if (!query) cds.error`Target ${req.target.name} cannot be resolved for service ${this.name}`
70
74
  const target = query._target || req.target
71
75
  // we need to provide target explicitly because it's cached within ensure_target
72
76
  const _req = new cds.Request({ query, target, _resolved: true })
@@ -1,3 +1,4 @@
1
+ // REVISIT: merge with cds/lib/req/event.js
1
2
  exports.MOD_EVENTS = { UPDATE: 1, DELETE: 1, EDIT: 1 }
2
3
  exports.WRITE_EVENTS = { CREATE: 1, NEW: 1, PATCH: 1, CANCEL: 1, ...exports.MOD_EVENTS }
3
4
  exports.CRUD_EVENTS = { READ: 1, ...exports.WRITE_EVENTS }
@@ -0,0 +1,220 @@
1
+ const cds = require('@sap/cds')
2
+ const getTemplate = require('../utils/template')
3
+ const templatePathSerializer = require('../utils/templateProcessorPathSerializer')
4
+
5
+ const $has_asserts = Symbol.for('has_asserts')
6
+
7
+ const { compileUpdatedDraftMessages } = require('../../fiori/lean-draft')
8
+
9
+ const _serialize = obj =>
10
+ JSON.stringify(
11
+ Object.keys(obj)
12
+ .sort()
13
+ .reduce((a, k) => ((a[k] = obj[k]), a), {})
14
+ )
15
+
16
+ const _bufferReviver = (key, value) => {
17
+ if (value && typeof value === 'object' && value.type === 'Buffer' && Array.isArray(value.data)) {
18
+ return Buffer.from(value.data)
19
+ }
20
+ return value
21
+ }
22
+
23
+ module.exports = cds.service.impl(async function () {
24
+ this.after(['INSERT', 'UPSERT', 'UPDATE'], async (res, req) => {
25
+ if (!($has_asserts in req.target)) {
26
+ let has_asserts = false
27
+ for (const each in req.target.elements) {
28
+ const element = req.target.elements[each]
29
+ if (element['@assert']) {
30
+ has_asserts = true
31
+ break
32
+ }
33
+ }
34
+ req.target[$has_asserts] = has_asserts
35
+ }
36
+
37
+ if (!cds.context?.tx || !req.target[$has_asserts]) return
38
+
39
+ const IS_DRAFT_ENTITY = req.target.isDraft
40
+
41
+ if (req.event === 'CREATE' && IS_DRAFT_ENTITY) return
42
+
43
+ let touched
44
+ if (cds.env.features.assert_touched_only !== false && IS_DRAFT_ENTITY && req.event === 'UPDATE')
45
+ touched = Object.keys(res).filter(k => !(k in req.target.keys))
46
+
47
+ const template = getTemplate('assert', this, req.target, {
48
+ pick: element => element['@assert'],
49
+ ignore: element => element.isAssociation && !element.isComposition
50
+ })
51
+
52
+ if (!cds.context.tx.changes) {
53
+ cds.context.tx.changes = {}
54
+
55
+ req.before('commit', async function () {
56
+ const { changes } = cds.context.tx
57
+
58
+ const errors = []
59
+
60
+ for (const [entityName, serializedChanges] of Object.entries(changes)) {
61
+ if (!serializedChanges.size) continue
62
+
63
+ const deserializedChanges = Array.from(serializedChanges).map(([k, v]) => [JSON.parse(k, _bufferReviver), v])
64
+
65
+ const entity = cds.model.definitions[entityName]
66
+ const IS_DRAFT_ENTITY = entity.isDraft
67
+
68
+ // Cache assert query on entity
69
+ if (!Object.hasOwn(entity, 'assert')) {
70
+ const asserts = []
71
+
72
+ for (const element of Object.values(entity.elements)) {
73
+ if (element._foreignKey4) continue
74
+ if (element.isAssociation && !element.isComposition) continue
75
+
76
+ const assert = element['@assert']
77
+ if (!assert) continue
78
+
79
+ // replace $self with $main
80
+ const xpr = JSON.parse(JSON.stringify(assert.xpr).replace(/\$self/g, '$main'))
81
+
82
+ asserts.push({ xpr, as: '@assert:' + element.name })
83
+ }
84
+
85
+ entity.assert = cds.ql.SELECT([...Object.keys(entity.keys), ...asserts]).from(entity)
86
+ }
87
+
88
+ const query = cds.ql.clone(entity.assert)
89
+
90
+ // Select only rows with changes
91
+ const keyNames = Object.keys(entity.keys).filter(
92
+ k => !entity.keys[k].virtual && !entity.keys[k].isAssociation
93
+ )
94
+ const keyMap = Object.fromEntries(keyNames.map(k => [k, true]))
95
+
96
+ query.where([
97
+ { list: keyNames.map(k => ({ ref: [k] })) },
98
+ 'in',
99
+ { list: deserializedChanges.map(([keyKV]) => ({ list: keyNames.map(k => ({ val: keyKV[k] })) })) }
100
+ ])
101
+
102
+ const results = await query
103
+
104
+ for (const row of results) {
105
+ const keyColumns = Object.fromEntries(Object.entries(row).filter(([k]) => k in keyMap))
106
+ const { touched, req, pathSegmentsInfo } = serializedChanges.get(_serialize(keyColumns))
107
+ const failedColumns = Object.entries(row)
108
+ .filter(([k, v]) => v !== null && !(k in keyMap))
109
+ .map(([k, v]) => [k.replace(/^@assert:/, ''), v])
110
+
111
+ if (failedColumns.length === 0) continue
112
+
113
+ const failedAsserts = failedColumns.map(([element, message]) => {
114
+ const error = {
115
+ code: 'ASSERT',
116
+ target: element,
117
+ numericSeverity: 4,
118
+ '@Common.numericSeverity': 4
119
+ }
120
+
121
+ // if error function was used in @assert expression -> use its output
122
+ try {
123
+ // Depending on DB, function result may be JavaScript Object or JSON String
124
+ const parsed = typeof message === 'string' ? JSON.parse(message) : message
125
+ Object.assign(error, parsed)
126
+ if (Array.isArray(error.targets)) {
127
+ const target = error.targets.at(0)
128
+ const additionalTargets = error.targets.slice(1)
129
+ if (target) error.target = target
130
+ if (additionalTargets.length) error.additionalTargets = additionalTargets
131
+ }
132
+ delete error.targets
133
+ } catch {
134
+ error.message = message
135
+ }
136
+
137
+ return error
138
+ })
139
+
140
+ if (IS_DRAFT_ENTITY) {
141
+ const draft = await SELECT.one
142
+ .from({ ref: [req.subject.ref[0]] })
143
+ .columns('DraftAdministrativeData_DraftUUID', 'DraftAdministrativeData.DraftMessages')
144
+ const persistedMessages = draft.DraftAdministrativeData_DraftMessages || []
145
+
146
+ // keep all messages that have targets that were touched in this change
147
+ const newMessages = touched
148
+ ? failedAsserts.filter(a => {
149
+ const targets = [a.target].concat(a.additionalTargets || [])
150
+ return touched.some(t => targets.includes(t))
151
+ })
152
+ : failedAsserts
153
+
154
+ const nextDraftMessages = compileUpdatedDraftMessages(
155
+ newMessages,
156
+ persistedMessages,
157
+ req.data,
158
+ req.subject.ref
159
+ )
160
+
161
+ await UPDATE('DRAFT.DraftAdministrativeData')
162
+ .set({ DraftMessages: nextDraftMessages })
163
+ .where({ DraftUUID: draft.DraftAdministrativeData_DraftUUID })
164
+ } else {
165
+ const isDraftAction = req._.event?.startsWith('draft')
166
+ const prefix = templatePathSerializer('', pathSegmentsInfo)
167
+ failedAsserts.forEach(err => {
168
+ err.target = (isDraftAction ? 'in/' : '') + prefix + err.target
169
+ })
170
+
171
+ errors.push(...failedAsserts)
172
+ }
173
+ }
174
+ }
175
+
176
+ if (errors.length) {
177
+ if (errors.length === 1) throw errors[0]
178
+ const err = new cds.error('MULTIPLE_ERRORS', { details: errors })
179
+ delete err.stack
180
+ throw err
181
+ }
182
+ })
183
+ }
184
+
185
+ const templateProcessOptions = {
186
+ pathSegmentsInfo: [],
187
+ includeKeyValues: true
188
+ }
189
+ if (req._.event?.startsWith('draft')) {
190
+ const IsActiveEntity = req.data.IsActiveEntity || false
191
+ templateProcessOptions.draftKeys = { IsActiveEntity }
192
+ }
193
+ // Collect entity keys and their values of changed rows
194
+ template.process(
195
+ req.data,
196
+ elementInfo => {
197
+ const { row, target, pathSegmentsInfo } = elementInfo
198
+ const targetName = target.name
199
+
200
+ cds.context.tx.changes[targetName] ??= new Map()
201
+
202
+ const keys = {}
203
+ for (const key in target.keys) {
204
+ if (key === 'IsActiveEntity') continue
205
+ if (!(key in row)) continue
206
+ keys[key] = row[key]
207
+ }
208
+
209
+ if (!Object.keys(keys).length) return
210
+
211
+ const serialized = _serialize(keys)
212
+ const changes = cds.context.tx.changes[targetName]
213
+ if (changes.has(serialized)) return
214
+
215
+ changes.set(serialized, { touched, req, pathSegmentsInfo: [...pathSegmentsInfo] })
216
+ },
217
+ templateProcessOptions
218
+ )
219
+ })
220
+ })