@sap/cds 9.4.4 → 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 (56) hide show
  1. package/CHANGELOG.md +81 -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 +4 -1
  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/generic/input.js +6 -4
  29. package/libx/_runtime/common/utils/cqn.js +0 -24
  30. package/libx/_runtime/common/utils/normalizeTimestamp.js +2 -2
  31. package/libx/_runtime/common/utils/resolveView.js +8 -2
  32. package/libx/_runtime/common/utils/templateProcessor.js +10 -1
  33. package/libx/_runtime/common/utils/templateProcessorPathSerializer.js +21 -9
  34. package/libx/_runtime/fiori/lean-draft.js +511 -379
  35. package/libx/_runtime/messaging/enterprise-messaging-utils/registerEndpoints.js +39 -35
  36. package/libx/_runtime/messaging/enterprise-messaging.js +2 -2
  37. package/libx/_runtime/remote/Service.js +4 -5
  38. package/libx/_runtime/ucl/Service.js +111 -15
  39. package/libx/common/utils/streaming.js +1 -1
  40. package/libx/odata/middleware/batch.js +8 -6
  41. package/libx/odata/middleware/create.js +2 -2
  42. package/libx/odata/middleware/delete.js +2 -2
  43. package/libx/odata/middleware/metadata.js +18 -11
  44. package/libx/odata/middleware/read.js +2 -2
  45. package/libx/odata/middleware/service-document.js +1 -1
  46. package/libx/odata/middleware/update.js +1 -1
  47. package/libx/odata/parse/afterburner.js +46 -36
  48. package/libx/odata/parse/cqn2odata.js +2 -6
  49. package/libx/odata/parse/grammar.peggy +91 -13
  50. package/libx/odata/parse/parser.js +1 -1
  51. package/libx/odata/utils/index.js +2 -2
  52. package/libx/odata/utils/readAfterWrite.js +2 -0
  53. package/libx/queue/TaskRunner.js +26 -1
  54. package/libx/queue/index.js +11 -1
  55. package/package.json +1 -1
  56. package/srv/ucl-service.cds +2 -0
@@ -11,14 +11,18 @@ const { addEtagColumns } = require('../common/utils/etag')
11
11
  const { handleStreamProperties } = require('../common/utils/streamProp')
12
12
 
13
13
  const { getLocalizedMessages } = require('../../odata/middleware/error')
14
- const { Responses } = require('../../../lib/req/response')
14
+ const { Responses, prepareError } = require('../../../lib/req/response')
15
15
  const location4 = require('../../http/location')
16
16
 
17
17
  const $original = Symbol('original')
18
18
  const $draftParams = Symbol('draftParams')
19
19
 
20
+ const { WELL_KNOWN_EVENTS } = require('../../../lib/req/event')
21
+
20
22
  const AGGREGATION_FUNCTIONS = ['sum', 'min', 'max', 'avg', 'average', 'count']
21
23
  const MAX_RECURSION_DEPTH = cds.env.features.recursion_depth != null ? Number(cds.env.features.recursion_depth) : 4
24
+ const IS_PERSISTED_DRAFT_MESSAGES_ENABLED =
25
+ !!cds.model?.definitions?.['DRAFT.DraftAdministrativeData']?.elements?.DraftMessages
22
26
 
23
27
  const _config_to_ms = (config, _default) => {
24
28
  const timeout = cds.env.fiori?.[config]
@@ -28,10 +32,10 @@ const _config_to_ms = (config, _default) => {
28
32
  } else if (typeof timeout === 'string') {
29
33
  timeout_ms = cds.utils.ms4(timeout)
30
34
  if (!timeout_ms)
31
- throw new Error(`
35
+ cds.error`
32
36
  ${timeout} is an invalid value for \`cds.fiori.${config}\`.
33
37
  Please provide a value in format /^([0-9]+)(w|d|h|hrs|min)$/.
34
- `)
38
+ `
35
39
  } else {
36
40
  timeout_ms = timeout
37
41
  }
@@ -56,11 +60,11 @@ const LOCK_TIMEOUT = {
56
60
  }
57
61
  }
58
62
 
59
- const reject_bypassed_draft = req => {
63
+ const reject_bypassed_draft = () => {
60
64
  const message =
61
65
  !cds.profiles?.includes('production') &&
62
66
  '`cds.env.fiori.bypass_draft` must be enabled or the entity must be annotated with `@odata.draft.bypass` to support direct modifications of active instances.'
63
- return req.reject({ code: 501, statusCode: 501, message })
67
+ cds.error({ status: 501, message })
64
68
  }
65
69
 
66
70
  const DRAFT_ELEMENTS = new Set([
@@ -382,15 +386,7 @@ const _replaceStreams = result => {
382
386
  }
383
387
  }
384
388
 
385
- const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestData, draftsRef) => {
386
- const txModel = cds.context.tx.model
387
- let targetEntity = txModel.definitions[draftsRef[0].id || draftsRef[0]]
388
-
389
- // The 'target' of validation errors needs to specify the full path from the draft root
390
- // > Since success of establishment of containment can't be guaranteed, we clean up messages
391
- if (!targetEntity.actions.draftActivate) return []
392
-
393
- // Determine the path prefix required for a fully qualified validation message 'target'
389
+ const _extractPrefixRef = (draftsRef, targetEntity, requestData = null) => {
394
390
  const prefixRef = []
395
391
  for (let refIdx = 0; refIdx < draftsRef.length; refIdx++) {
396
392
  const dRef = draftsRef[refIdx],
@@ -408,7 +404,7 @@ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestDat
408
404
 
409
405
  if (targetEntity.isDraft) targetEntity = targetEntity.actives
410
406
 
411
- if (typeof dRef === 'string' || !dRef.where?.length) {
407
+ if ((typeof dRef === 'string' || !dRef.where?.length) && requestData) {
412
408
  // In case of CREATE: The WHERE for the created entity must be constructed for use in 'prefixRef'
413
409
 
414
410
  for (const k in targetEntity.keys) {
@@ -419,29 +415,59 @@ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestDat
419
415
  else return null
420
416
  if (v instanceof Buffer) v = v.toString('base64') //> convert binary keys to base64
421
417
  if (pRef.where.length > 0) pRef.where.push('and')
422
- pRef.where.push(key.name, '=', v)
418
+ pRef.where.push({ ref: [key.name] }, '=', { val: v })
423
419
  }
424
- } else {
425
- // 'dRef.where' regards the draft and will never contain 'IsActiveEntity=false'
426
- pRef.where.push(...dRef.where, 'and', 'IsActiveEntity', '=', false)
420
+
421
+ prefixRef.push(pRef)
422
+ continue
423
+ }
424
+
425
+ if (dRef.where?.length) {
426
+ // Use the existing where clause from draftRef
427
+ pRef.where.push(...dRef.where)
428
+
429
+ if (!pRef.where.some(w => w === 'IsActiveEntity' || w.ref?.[0] === 'IsActiveEntity'))
430
+ pRef.where.push('and', { ref: ['IsActiveEntity'] }, '=', { val: false })
431
+
432
+ prefixRef.push(pRef)
433
+ continue
427
434
  }
428
435
 
429
- prefixRef.push(pRef)
436
+ prefixRef.push(pRef.id)
430
437
  }
431
438
 
439
+ return prefixRef
440
+ }
441
+
442
+ const compileUpdatedDraftMessages = (newMessages, persistedMessages, requestData, draftsRef) => {
443
+ const txModel = cds.context.tx.model
444
+ let targetEntity = txModel.definitions[draftsRef[0].id || draftsRef[0]]
445
+
446
+ // The 'target' of validation errors needs to specify the full path from the draft root
447
+ // > Since success of establishment of containment can't be guaranteed, we clean up messages
448
+ if (!targetEntity['@Common.DraftRoot.ActivationAction']) return []
449
+
450
+ // Determine the path prefix required for a fully qualified validation message 'target'
451
+ const prefixRef = _extractPrefixRef(draftsRef, targetEntity, requestData)
452
+ if (!prefixRef) return null
453
+
454
+ const draftMessageTargetPrefix = cds.odata.urlify(
455
+ { SELECT: { from: { ref: prefixRef } } },
456
+ { kind: 'odata-v4', model: txModel }
457
+ ).path
458
+
432
459
  const nextMessages = []
433
460
 
434
461
  // Collect messages that were created during the most recent validation run
435
462
  const newMessagesByMessageKeyAndTarget = newMessages.reduce((acc, message) => {
463
+ if (!message.target) return acc //> silently ignore messages without target
464
+
436
465
  message.numericSeverity ??= message['@Common.numericSeverity'] ?? 4
437
466
  if (message['@Common.additionalTargets']) message.additionalTargets ??= message['@Common.additionalTargets']
438
467
  message.additionalTargets = message.additionalTargets?.map(t => (t.startsWith('in/') ? t.slice(3) : t))
439
468
  delete message['@Common.additionalTargets']
440
469
 
441
- if (!message.target) return acc //> silently ignore messages without target
442
-
443
- // Handle validation messages produced during draftActivate, that went through error normalization already
444
- // > We must not store pre-localized data in DraftAdministrativeData.DraftMessages
470
+ // Remove prefix 'in/' in favor of fully qualified path to the target
445
471
  const messageTarget = message.target.startsWith('in/') ? message.target.slice(3) : message.target
446
472
  if (message.code) delete message.code // > Expect _only_ message to be set and contain the code
447
473
 
@@ -468,11 +494,6 @@ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestDat
468
494
  return acc.set(`${message.message}:${message.prefix}:${message.target}`, message)
469
495
  }, new Map())
470
496
 
471
- const draftMessageTargetPrefix = cds.odata.urlify(
472
- { SELECT: { from: { ref: prefixRef } } },
473
- { kind: 'odata-v4', model: txModel }
474
- ).path
475
-
476
497
  // Merge new messages with persisted ones & eliminate outdated ones
477
498
  const simplePathElements = prefixRef.map(pRef => pRef.id)
478
499
  for (const message of persistedMessages) {
@@ -483,13 +504,13 @@ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestDat
483
504
  )
484
505
  if (hasNewMessageForAdditionalTarget) continue
485
506
 
486
- // Drop persisted draft messages where the value of the target field changed without a new error
507
+ // Drop persisted draft messages where the value of the target field changed
487
508
  if (message.prefix === draftMessageTargetPrefix) {
488
509
  if (requestData[message.target] !== undefined) continue
489
510
  if (message.additionalTargets?.some(target => requestData[target] !== undefined)) continue
490
511
  }
491
512
 
492
- // Drop persisted draft messages, whose target's use navigations, where the value of the target field changed without a new error
513
+ // Drop persisted draft messages, whose target's use navigations, where the value of the target field changed
493
514
  const messageSimplePathElements = message.prefix.replaceAll(/\([^(]*\)/g, '').split('/')
494
515
  if (messageSimplePathElements.length > simplePathElements.length)
495
516
  if (requestData[messageSimplePathElements[simplePathElements.length]] !== undefined) continue
@@ -504,9 +525,88 @@ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestDat
504
525
  return nextMessages
505
526
  }
506
527
 
528
+ const _createNewDraftData = (obj, target, draftUUID) => {
529
+ const newDraftData = Object.assign({}, obj, {
530
+ DraftAdministrativeData_DraftUUID: draftUUID,
531
+ HasActiveEntity: false
532
+ })
533
+ if (!target) return newDraftData
534
+
535
+ for (const key in newDraftData) {
536
+ if (!target.elements[key]?.isComposition) continue
537
+
538
+ // Recurse into payload entries for nested associated entities
539
+ if (Array.isArray(newDraftData[key]))
540
+ newDraftData[key] = newDraftData[key].map(v => _createNewDraftData(v, target.elements[key]._target, draftUUID))
541
+ else if (typeof newDraftData[key] === 'object')
542
+ newDraftData[key] = _createNewDraftData(newDraftData[key], target.elements[key]._target, draftUUID)
543
+ }
544
+
545
+ return newDraftData
546
+ }
547
+
548
+ const _newReq = (req, query, draftParams, { event, headers }) => {
549
+ // REVISIT: This is a bit hacky -> better way?
550
+ query._target = undefined
551
+ query[$draftParams] = draftParams
552
+
553
+ // REVISIT: This is extremely bad. We should be able to just create a copy without such hacks.
554
+ const _req = cds.Request.for(req._) // REVISIT: this causes req._.data of WRITE reqs copied to READ reqs
555
+ // To create a `READ` event based on a modifying request, we need to delete req.data
556
+ if (event === 'READ' && req.event !== 'READ') delete _req.data
557
+
558
+ if (headers) {
559
+ _req.headers = Object.create(req.headers)
560
+ Object.assign(_req.headers, headers)
561
+ }
562
+
563
+ // Set relevant attributes of the inner request based on the outer
564
+ _req.target = cds.infer.target(query)
565
+ _req.query = query
566
+ _req.params = req.params
567
+ _req._ = req._
568
+
569
+ const cqnData = _req.query.UPDATE?.data || _req.query.INSERT?.entries?.[0]
570
+ if (cqnData) _req.data = cqnData // must point to the same object
571
+
572
+ if (req.protocol) _req.protocol = req.protocol
573
+ if (!_req._.event) _req._.event = req.event
574
+ if (req.tx && !_req.tx) _req.tx = req.tx
575
+
576
+ // Determine the proper event for the inner request
577
+ if (event) _req.event = event
578
+ else if (query.SELECT) _req.event = 'READ'
579
+ else if (query.INSERT) _req.event = 'CREATE'
580
+ else if (query.UPDATE) _req.event = 'UPDATE'
581
+ else if (query.DELETE) _req.event = 'DELETE'
582
+ else _req.event = req.event
583
+
584
+ // Divert messages and validation errors to outer request
585
+ /* prettier-ignore */ Object.defineProperty(_req, '_messages', { get() { return req._messages } })
586
+ /* prettier-ignore */ Object.defineProperty(_req, '_validationErrors', { get() { return req._validationErrors } })
587
+
588
+ const shouldCollectValidationErrorsSeparately =
589
+ _req.target.isDraft && IS_PERSISTED_DRAFT_MESSAGES_ENABLED && req.protocol && ['UPDATE', 'NEW'].includes(_req.event)
590
+ if (shouldCollectValidationErrorsSeparately) {
591
+ // Add a separate collection for validation errors, to the outer request
592
+ req._validationErrors ??= new Responses()
593
+
594
+ // Override .error to collect validation errors separately, for the inner request
595
+ // > Validation errors being classified by having a 'target'
596
+ _req.error = (...args) => {
597
+ const err = prepareError(4, ...args)
598
+ if (err.target) _req._validationErrors.push(err)
599
+ else _req._errors.add(null, ...args)
600
+ return err
601
+ }
602
+ }
603
+
604
+ return _req
605
+ }
606
+
507
607
  // REVISIT: Can we do a regular handler function instead of monky patching?
508
- const h = cds.ApplicationService.prototype.handle
509
- const handle = async function (req) {
608
+ const protoHandle = cds.ApplicationService.prototype.handle
609
+ const draftHandle = async function (req) {
510
610
  if (req.event === 'DISCARD') req.event = 'CANCEL'
511
611
  else if (req.event === 'SAVE') {
512
612
  req.event = 'draftActivate'
@@ -515,15 +615,15 @@ const handle = async function (req) {
515
615
 
516
616
  // Fast exit for non-draft requests
517
617
  // REVISIT: should also start with else, but then this test fails: cds/tests/_runtime/odata/__tests__/integration/draft-custom-handlers.test.js
518
- if (!req.query) return h.call(this, req)
519
- else if ($draftParams in req.query) return h.call(this, req)
520
- /* prettier-ignore */ else if (!(
618
+ if (!req.query) return protoHandle.call(this, req)
619
+ if ($draftParams in req.query) return protoHandle.call(this, req)
620
+ /* prettier-ignore */ if (!(
521
621
  // Note: we skip UPSERTs as these might have an additional INSERT
522
622
  'SELECT' in req.query ||
523
623
  'INSERT' in req.query ||
524
624
  'UPDATE' in req.query ||
525
625
  'DELETE' in req.query
526
- )) return h.call(this, req)
626
+ )) return protoHandle.call(this, req)
527
627
  // TODO: also skip quickly if no draft-enabled entities are involved ?!?
528
628
  // TODO: also skip quickly if no isActiveEntity is part of the query ?!?
529
629
  // TODO: also skip quickly for CREATE request not from Fiori clients ???
@@ -539,64 +639,9 @@ const handle = async function (req) {
539
639
  if (req.data) _cleanseParams(req.data, req.target)
540
640
  const draftParams = query[$draftParams]
541
641
 
542
- const _newReq = (req, query, draftParams, { event, headers }) => {
543
- // REVISIT: This is a bit hacky -> better way?
544
- query._target = undefined
545
- query[$draftParams] = draftParams
546
-
547
- // REVISIT: This is extremely bad. We should be able to just create a copy without such hacks.
548
- const _req = cds.Request.for(req._) // REVISIT: this causes req._.data of WRITE reqs copied to READ reqs
549
-
550
- if (headers) {
551
- _req.headers = Object.create(req.headers)
552
- Object.assign(_req.headers, headers)
553
- }
554
-
555
- // If we create a `READ` event based on a modifying request, we delete data
556
- if (event === 'READ' && req.event !== 'READ') delete _req.data // which we fix here -> but this is an ugly workaround
557
-
558
- _req.target = cds.infer.target(query)
559
- _req.query = query
560
- _req.event =
561
- event ||
562
- (query.SELECT && 'READ') ||
563
- (query.INSERT && 'CREATE') ||
564
- (query.UPDATE && 'UPDATE') ||
565
- (query.DELETE && 'DELETE') ||
566
- req.event
567
- _req.params = req.params
568
- if (req.protocol) _req.protocol = req.protocol
569
- _req._ = req._
570
- if (!_req._.event) _req._.event = req.event
571
- const cqnData = _req.query.UPDATE?.data || _req.query.INSERT?.entries?.[0]
572
- if (cqnData) _req.data = cqnData // must point to the same object
573
- if (req.tx && !_req.tx) _req.tx = req.tx
574
-
575
- // Ensure messages are added to the original request
576
- Object.defineProperty(_req, '_messages', {
577
- get: function () {
578
- return req._messages
579
- }
580
- })
581
-
582
- if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
583
- if (_req.target.isDraft && (_req.event === 'UPDATE' || _req.event === 'NEW')) {
584
- // Degrade all errors to messages & prevent !!req.errors into req.reject() in dispatch
585
- const originalError = _req.error.bind(_req)
586
- _req.error = (...args) => {
587
- // REVISIT: re-consider target variants
588
- if (args[0]?.target || args[2]) return _req._messages.add(4, ...args)
589
- return originalError(...args)
590
- }
591
- }
592
- }
593
-
594
- return _req
595
- }
596
-
597
642
  const run = (query, options = {}) => {
598
643
  const _req = _newReq(req, query, draftParams, options)
599
- return h.call(this, _req)
644
+ return protoHandle.call(this, _req)
600
645
  }
601
646
 
602
647
  if (req.event === 'READ') {
@@ -606,84 +651,103 @@ const handle = async function (req) {
606
651
  !req.query._target.drafts
607
652
  ) {
608
653
  req.query = query
609
- return h.call(this, req)
654
+ return protoHandle.call(this, req)
610
655
  }
611
656
 
612
657
  // apply paging and sorting on original query for protocol adapters relying on it
613
658
  commonGenericPaging(req)
614
659
  commonGenericSorting(req)
615
660
 
616
- const read =
617
- draftParams.IsActiveEntity === false &&
618
- _hasStreaming(query.SELECT.columns, query._target) &&
619
- !cds.env.features.binary_draft_compat
620
- ? Read.draftStream
621
- : req.query._target.name.endsWith('.drafts')
622
- ? Read.ownDrafts
623
- : draftParams.IsActiveEntity === false && draftParams.SiblingEntity_IsActiveEntity === null
624
- ? Read.all
625
- : draftParams.IsActiveEntity === true &&
626
- draftParams.SiblingEntity_IsActiveEntity === null &&
627
- (draftParams.DraftAdministrativeData_InProcessByUser === 'not null' ||
628
- draftParams.DraftAdministrativeData_InProcessByUser === 'not ')
629
- ? Read.lockedByAnotherUser
630
- : draftParams.IsActiveEntity === true &&
631
- draftParams.SiblingEntity_IsActiveEntity === null &&
632
- draftParams.DraftAdministrativeData_InProcessByUser === ''
633
- ? Read.unsavedChangesByAnotherUser
634
- : draftParams.IsActiveEntity === true && draftParams.HasDraftEntity === false
635
- ? Read.unchanged
636
- : draftParams.IsActiveEntity === true
637
- ? Read.onlyActives
638
- : draftParams.IsActiveEntity === false
639
- ? Read.ownDrafts
640
- : Read.onlyActives
661
+ const determineRead = () => {
662
+ // 'draftParams' as filled in '_cleanseQuery'
663
+ const isActiveEntity = draftParams.IsActiveEntity
664
+ const hasDraftEntity = draftParams.HasDraftEntity
665
+ const hasStreaming = _hasStreaming(query.SELECT.columns, query._target)
666
+ const isBinaryDraftCompat = cds.env.features.binary_draft_compat
667
+ const isTargetDraft = req.query._target.name.endsWith('.drafts')
668
+ const inProcessByUserEqNullString = ['not ', 'not null'].includes(
669
+ draftParams.DraftAdministrativeData_InProcessByUser
670
+ )
671
+ const inProcessByUserEqEmptyString = draftParams.DraftAdministrativeData_InProcessByUser === ''
672
+ const isSiblingIsActiveEntityNull = draftParams.SiblingEntity_IsActiveEntity === null
673
+
674
+ if (isActiveEntity === false && hasStreaming && !isBinaryDraftCompat) return Read.draftStream
675
+ if (isTargetDraft) return Read.ownDrafts
676
+ if (isActiveEntity === false && isSiblingIsActiveEntityNull) return Read.all
677
+ if (isActiveEntity === true && isSiblingIsActiveEntityNull && inProcessByUserEqNullString)
678
+ return Read.lockedByAnotherUser
679
+ if (isActiveEntity === true && isSiblingIsActiveEntityNull && inProcessByUserEqEmptyString)
680
+ return Read.unsavedChangesByAnotherUser
681
+ if (isActiveEntity === true && hasDraftEntity === false) return Read.unchanged
682
+ if (isActiveEntity === false) return Read.ownDrafts
683
+ return Read.onlyActives
684
+ }
685
+
686
+ const read = determineRead()
641
687
  const result = await read(run, query)
642
688
  return result
643
689
  }
644
690
 
645
691
  if (req.event === 'draftEdit') req.event = 'EDIT'
646
- if (req.event === 'draftPrepare' && draftParams.IsActiveEntity) req.reject({ code: 400, statusCode: 400 })
692
+ if (req.event === 'draftPrepare' && draftParams.IsActiveEntity)
693
+ cds.error({ status: 400, message: 'Action "draftPrepare" can only be called on the draft entity' })
694
+
695
+ // REVISIT: Can't use req.subject here:
696
+ // REVISIT: > Caching in req.subject will yield erroneous result
697
+ // REVISIT: > after replacing req.query by draft adapted query
698
+ let rootEntityName = (
699
+ query.SELECT?.from ||
700
+ query.INSERT?.into ||
701
+ query.UPSERT?.into ||
702
+ query.UPDATE?.entity ||
703
+ query.DELETE?.from
704
+ )?.ref?.[0]
705
+ if (typeof rootEntityName === 'object') rootEntityName = rootEntityName.id
706
+ const rootEntity = this.model.definitions[rootEntityName]
707
+
708
+ const isNewDraftViaActionEnabled = cds.env.fiori.draft_new_action ?? false
709
+ let newDraftAction = rootEntity['@Common.DraftRoot.NewAction']
710
+ if (typeof newDraftAction != 'string' || !newDraftAction.length) newDraftAction = false
711
+ else newDraftAction = newDraftAction.split('.').pop()
712
+ const shouldHandleNewDraftAction = isNewDraftViaActionEnabled && req.target === rootEntity
647
713
 
648
714
  // Create active instance of draft-enabled entity
649
- // Careful: New OData adapter only sets `NEW` for drafts... how to distinguish programmatic modifications?
715
+ // REVISIT: New OData adapter only sets `NEW` for drafts... how to distinguish programmatic modifications?
650
716
  if (
651
- (req.event === 'NEW' && req.data.IsActiveEntity === true) || // old OData adapter changes CREATE to NEW also for actives
717
+ (req.event === 'NEW' && shouldHandleNewDraftAction && req.data.IsActiveEntity !== false) ||
652
718
  (req.event === 'CREATE' && req.target.drafts && req.data?.IsActiveEntity !== false && !req.target.isDraft)
653
719
  ) {
654
- if (req.protocol === 'odata' && !cds.env.fiori.bypass_draft && !req.target['@odata.draft.bypass'])
720
+ if (
721
+ !isNewDraftViaActionEnabled &&
722
+ req.protocol === 'odata' &&
723
+ !cds.env.fiori.bypass_draft &&
724
+ !req.target['@odata.draft.bypass']
725
+ ) {
655
726
  return reject_bypassed_draft(req)
656
- const containsDraftRoot =
657
- this.model.definitions[query.INSERT.into?.ref?.[0]?.id || query.INSERT.into?.ref?.[0] || query.INSERT.into][
658
- '@Common.DraftRoot.ActivationAction'
659
- ]
727
+ }
660
728
 
661
- if (!containsDraftRoot) req.reject({ code: 403, statusCode: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
729
+ const queryUsesContainment = !!rootEntity['@Common.DraftRoot.ActivationAction']
730
+ if (!queryUsesContainment) cds.error({ status: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
662
731
 
663
- const isDirectAccess = typeof req.query.INSERT.into === 'string' || req.query.INSERT.into.ref?.length === 1
732
+ const isDirectAccess = req.query.INSERT.into.ref?.length === 1
664
733
  const data = Array.isArray(req.data) ? [...req.data] : Object.assign({}, req.data) // IsActiveEntity is not enumerable
665
- const draftsRootRef =
666
- typeof query.INSERT.into === 'string'
667
- ? [req.target.drafts.name]
668
- : _redirectRefToDrafts([query.INSERT.into.ref[0]], this.model)
734
+ const draftsRootRef = _redirectRefToDrafts([query.INSERT.into.ref[0]], this.model)
669
735
 
670
- let rootHasDraft
736
+ let draftRootEntityExists
671
737
 
672
- // children: check root entity has no draft
673
- if (!isDirectAccess) {
674
- rootHasDraft = await SELECT.one([1]).from({ ref: draftsRootRef })
675
- }
738
+ // Children: check root entity has no draft
739
+ if (!isDirectAccess) draftRootEntityExists = await SELECT.one([1]).from({ ref: draftsRootRef })
676
740
 
677
- // direct access and req.data contains keys: check if root has no draft with that keys
741
+ // Direct access and req.data contains keys: check if root has no draft with that keys
678
742
  if (isDirectAccess && _entityKeys(query._target).every(k => k in data)) {
679
743
  const keyData = _entityKeys(query._target).reduce((res, k) => {
680
744
  res[k] = req.data[k]
681
745
  return res
682
746
  }, {})
683
- rootHasDraft = await SELECT.one([1]).from({ ref: draftsRootRef }).where(keyData)
747
+ draftRootEntityExists = await SELECT.one([1]).from({ ref: draftsRootRef }).where(keyData)
684
748
  }
685
749
 
686
- if (rootHasDraft) req.reject({ code: 409, statusCode: 409, message: 'DRAFT_ALREADY_EXISTS' })
750
+ if (draftRootEntityExists) cds.error({ status: 409, message: 'DRAFT_ALREADY_EXISTS' })
687
751
 
688
752
  const cqn = INSERT.into(query.INSERT.into).entries(data)
689
753
  await run(cqn, { event: 'CREATE' })
@@ -694,64 +758,57 @@ const handle = async function (req) {
694
758
  return result
695
759
  }
696
760
 
697
- // It needs to be redirected to drafts
698
- if (req.event === 'NEW' || req.event === 'CANCEL' || req.event === 'draftPrepare') {
699
- if (!req.target.isDraft) req.target = req.target.drafts // COMPAT: also support these events for actives
761
+ // Prevent creating active entities via drafts
762
+ if (req.event === 'CREATE' && draftParams.IsActiveEntity === false && !req.target.isDraft) {
763
+ cds.error({ status: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
764
+ }
700
765
 
701
- if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && query.DELETE) {
702
- const prefixRef = query.DELETE.from.ref.map(dRef => {
703
- const pRef = { id: dRef.id, where: [...dRef.where] }
704
- pRef.where.push('and', 'IsActiveEntity', '=', false)
705
- return pRef
706
- })
707
- const messageTargetPrefix = cds.odata.urlify(
708
- { SELECT: { from: { ref: prefixRef } } },
709
- { kind: 'odata-v4', model: req.context.tx.model }
710
- ).path
711
-
712
- const draftData = await SELECT.one
713
- .from({ ref: _redirectRefToDrafts(query.DELETE.from.ref, this.model) }) // REVISIT: Avoid redundant redirect
714
- .columns('DraftAdministrativeData_DraftUUID', {
715
- ref: ['DraftAdministrativeData'],
716
- expand: [{ ref: ['DraftMessages'] }]
717
- })
766
+ // Handle 'newDraftAction' event if enabled
767
+ if (shouldHandleNewDraftAction && req.event === newDraftAction) {
768
+ if (!req.target.isDraft) req.target = req.target.drafts
718
769
 
719
- const draftAdminDataUUID = draftData?.DraftAdministrativeData_DraftUUID
720
- const persistedDraftMessages = draftData?.DraftAdministrativeData?.DraftMessages || []
770
+ // Rewrite 'newDraftAction' into regular 'NEW' event
771
+ query.SELECT.from.ref = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
772
+ const createNewQuery = INSERT.into(query.SELECT.from).entries(req.data)
721
773
 
722
- if (draftAdminDataUUID && persistedDraftMessages?.length) {
723
- let nextDraftMessages = persistedDraftMessages.filter(msg => !msg.prefix.startsWith(messageTargetPrefix))
724
- if (!req.target.actions?.draftActivate) nextDraftMessages = []
774
+ req.event = req._.event = 'NEW'
775
+ req.query = req._.query = createNewQuery
725
776
 
726
- await UPDATE('DRAFT.DraftAdministrativeData')
727
- .set({ DraftMessages: nextDraftMessages })
728
- .where({ DraftUUID: draftAdminDataUUID })
729
- }
730
- }
777
+ const innerReq = _newReq(req, createNewQuery, draftParams, { event: 'NEW' })
778
+ const createNewResult = await protoHandle.call(this, innerReq)
731
779
 
732
- if (query.INSERT) {
733
- if (typeof query.INSERT.into === 'string') query.INSERT.into = req.target.name
734
- else if (query.INSERT.into.ref) query.INSERT.into.ref = _redirectRefToDrafts(query.INSERT.into.ref, this.model)
735
- } else if (query.DELETE) {
736
- query.DELETE.from.ref = _redirectRefToDrafts(query.DELETE.from.ref, this.model)
737
- } else if (query.SELECT) {
738
- query.SELECT.from.ref = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
739
- }
780
+ req.data = createNewResult
781
+ req.res.status(201)
782
+
783
+ return _readAfterDraftAction.call(this, { req, payload: createNewResult, action: 'draftNew' })
784
+ }
785
+
786
+ // Handle draft-only events, that can only ever target entities in draft state
787
+ if (
788
+ (req.event === 'NEW' && (!shouldHandleNewDraftAction || req.data.IsActiveEntity === false)) ||
789
+ req.event === 'CANCEL' ||
790
+ req.event === 'draftPrepare'
791
+ ) {
792
+ if (!req.target.isDraft) req.target = req.target.drafts // COMPAT: also support these events for actives
793
+
794
+ if (query.INSERT) query.INSERT.into.ref = _redirectRefToDrafts(query.INSERT.into.ref, this.model)
795
+ else if (query.DELETE) query.DELETE.from.ref = _redirectRefToDrafts(query.DELETE.from.ref, this.model)
796
+ else if (query.SELECT) query.SELECT.from.ref = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
740
797
 
741
798
  const _req = _newReq(req, query, draftParams, { event: req.event })
742
799
 
743
800
  // Do not allow to create active instances via drafts
744
801
  if (req.event === 'NEW' && draftParams.IsActiveEntity === false && !_req.target.isDraft) {
745
- req.reject({ code: 403, statusCode: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
802
+ cds.error({ status: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
746
803
  }
747
804
 
748
- const result = await h.call(this, _req)
805
+ const result = await protoHandle.call(this, _req)
749
806
 
750
807
  req.data = result //> make keys available via req.data (as with normal crud)
751
808
  return result
752
809
  }
753
810
 
754
- // Delete active instance of draft-enabled entity
811
+ // Handle 'DELETE' event on entities in active state
755
812
  if (req.target.drafts && !req.target.isDraft && req.event === 'DELETE' && draftParams.IsActiveEntity !== false) {
756
813
  const draftsRef = _redirectRefToDrafts(query.DELETE.from.ref, this.model)
757
814
  const inProcessByUserCol = {
@@ -801,7 +858,7 @@ const handle = async function (req) {
801
858
  }
802
859
  }
803
860
  const keyVal = req.query.DELETE.from.ref[0].where?.[2]?.val
804
- if (keyVal === undefined) req.reject(400, 'Deletion not supported')
861
+ if (keyVal === undefined) cds.error({ status: 400, message: 'Deletion not supported' })
805
862
  // We must select actives and check for corresponding drafts (drafts themselve don't necessarily form a hierarchy)
806
863
  const recursiveQ = SELECT.from(req.target).columns(key)
807
864
  recursiveQ.SELECT.recurse = {
@@ -821,26 +878,26 @@ const handle = async function (req) {
821
878
  for (const draft of drafts) {
822
879
  const inProcessByUser = draft?.DraftAdministrativeData?.InProcessByUser
823
880
  if (!cds.context.user._is_privileged && inProcessByUser && inProcessByUser !== cds.context.user.id)
824
- req.reject({ code: 403, statusCode: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [inProcessByUser] })
825
- else req.reject({ code: 403, statusCode: 403, message: 'DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS' })
881
+ cds.error({ status: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [inProcessByUser] })
882
+ else cds.error({ status: 403, message: 'DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS' })
826
883
  }
827
884
  await run(query)
828
885
  return req.data
829
886
  }
830
887
 
888
+ // Handle 'draftActivate' event
831
889
  if (req.event === 'draftActivate') {
832
890
  LOG.debug('activate draft')
833
891
 
834
892
  if (req.query.SELECT.from.ref.length > 1 || draftParams.IsActiveEntity === true) {
835
- req.reject({
836
- code: 400,
837
- statusCode: 400,
893
+ cds.error({
894
+ status: 400,
838
895
  message: 'Action "draftActivate" can only be called on the root draft entity'
839
896
  })
840
897
  }
841
898
 
842
899
  if (req.target._etag && !req.headers['if-match'] && !req.headers['if-none-match']) {
843
- req.reject({ code: 428, statusCode: 428 })
900
+ cds.error(428)
844
901
  }
845
902
 
846
903
  const columns = expandStarStar(req.target.drafts, true)
@@ -857,9 +914,7 @@ const handle = async function (req) {
857
914
  ref: ['DraftAdministrativeData'],
858
915
  expand: [
859
916
  { ref: ['InProcessByUser'] },
860
- ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
861
- ? [{ ref: ['DraftMessages'] }]
862
- : [])
917
+ ...(IS_PERSISTED_DRAFT_MESSAGES_ENABLED ? [{ ref: ['DraftMessages'] }] : [])
863
918
  ]
864
919
  }
865
920
  ])
@@ -872,12 +927,11 @@ const handle = async function (req) {
872
927
  : req.headers['if-none-match']
873
928
  ? 'if-none-match'
874
929
  : undefined
875
- req.reject(_etagValidationType ? { code: 412, statusCode: 412 } : { code: 'DRAFT_NOT_EXISTING', statusCode: 404 })
930
+ cds.error(_etagValidationType ? { status: 412 } : { status: 404, message: 'DRAFT_NOT_EXISTING' })
876
931
  }
877
932
  if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
878
- req.reject({
879
- code: 403,
880
- statusCode: 403,
933
+ cds.error({
934
+ status: 403,
881
935
  message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
882
936
  args: [res.DraftAdministrativeData?.InProcessByUser]
883
937
  })
@@ -910,33 +964,32 @@ const handle = async function (req) {
910
964
 
911
965
  const _req = _newReq(req, upsertQuery, draftParams, upsertOptions)
912
966
 
913
- let result
914
- try {
915
- result = await h.call(this, _req)
916
- } catch (error) {
917
- if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && _req.errors)
918
- _req.on('failed', async () => {
919
- const nextDraftMessages = _compileUpdatedDraftMessages(
920
- // REVISIT: e._message hack for draft validation messages
921
- // Errors procesed during 'failed' will have undergone error._normalize at this point
922
- // > We need to revert the code - message swap _normalize includes
923
- // > This is required to ensure, no localized messages are persisted and redundant localization is avoided
924
- _req.errors.map(e => ({ message: e._message ?? e.message, target: e.target, args: e.args, i18n: e.i18n })),
925
- persistedDraftMessages,
926
- {},
927
- draftRef
928
- )
929
-
930
- await cds.tx(async () => {
931
- await UPDATE('DRAFT.DraftAdministrativeData')
932
- .set({ DraftMessages: nextDraftMessages })
933
- .where({ DraftUUID: DraftAdministrativeData_DraftUUID })
934
- })
967
+ if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED) {
968
+ _req.on('failed', async error => {
969
+ const errors = []
970
+ if (_req.errors) {
971
+ // REVISIT: e._message hack for draft validation messages
972
+ // Errors procesed during 'failed' will have undergone error._normalize at this point
973
+ // > We need to revert the code - message swap _normalize includes
974
+ // > This is required to ensure, no localized messages are persisted and redundant localization is avoided
975
+ errors.push(..._req.errors.map(e => ({ ...e, message: e._message ?? e.message })))
976
+ } else {
977
+ // NOTE: this branch is for on-commit errors, which don't end up in req.errors
978
+ // IMPORTANT: copy error objects to avoid side effects on original error
979
+ if (error.code) errors.push({ ...error })
980
+ if (error.details) errors.push(...error.details.map(e => ({ ...e })))
981
+ }
982
+ const nextDraftMessages = compileUpdatedDraftMessages(errors, persistedDraftMessages, {}, draftRef)
983
+ await cds.tx(async () => {
984
+ await UPDATE('DRAFT.DraftAdministrativeData')
985
+ .set({ DraftMessages: nextDraftMessages })
986
+ .where({ DraftUUID: DraftAdministrativeData_DraftUUID })
935
987
  })
936
-
937
- throw error
988
+ })
938
989
  }
939
990
 
991
+ const result = await protoHandle.call(this, _req)
992
+
940
993
  // REVISIT: Remove feature flag dependency
941
994
  if (cds.env.fiori.move_media_data_in_db) {
942
995
  // Move cds.LargeBinary data from draft to active
@@ -970,22 +1023,33 @@ const handle = async function (req) {
970
1023
  return Object.assign(result, { IsActiveEntity: true })
971
1024
  }
972
1025
 
973
- if (req.target.actions?.[req.event] && draftParams.IsActiveEntity === false) {
1026
+ // Handle regular custom actions on entities in draft state
1027
+ if (req.target.actions?.[req.event] && !(req.event in WELL_KNOWN_EVENTS) && draftParams.IsActiveEntity === false) {
974
1028
  if (query.SELECT?.from?.ref) query.SELECT.from.ref = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
1029
+
1030
+ // Check if the draft is locked by another user
975
1031
  const rootQuery = query.clone()
976
1032
  rootQuery.SELECT.columns = [{ ref: ['DraftAdministrativeData'], expand: [{ ref: ['InProcessByUser'] }] }]
977
1033
  rootQuery.SELECT.one = true
978
1034
  rootQuery.SELECT.from = { ref: [query.SELECT.from.ref[0]] }
979
1035
  const root = await cds.run(rootQuery)
980
- if (!root) req.reject({ code: 404, statusCode: 404 })
1036
+
1037
+ if (!root) cds.error(404)
981
1038
  if (root.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
982
- req.reject({ code: 403, statusCode: 403 })
1039
+ cds.error({
1040
+ status: 403,
1041
+ message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
1042
+ args: [root.DraftAdministrativeData.InProcessByUser]
1043
+ })
983
1044
  }
1045
+
984
1046
  const _req = _newReq(req, query, draftParams, { event: req.event })
985
- const result = await h.call(this, _req)
1047
+ const result = await protoHandle.call(this, _req)
1048
+
986
1049
  return result
987
1050
  }
988
1051
 
1052
+ // Handle 'UPDATE' events on entities in draft or active state
989
1053
  if (req.event === 'PATCH' || (req.event === 'UPDATE' && req.target.drafts)) {
990
1054
  // also delete `IsActiveEntity` for references
991
1055
  const _rmIsActiveEntity = (data, target) => {
@@ -1004,27 +1068,26 @@ const handle = async function (req) {
1004
1068
  }
1005
1069
  _rmIsActiveEntity(req.data, req.target)
1006
1070
 
1071
+ // REVISIT: This should allow `IsActiveEntity: false` to be passed in the body
1072
+ // REVISIT: > Enabling this would re-establish symmetry with CREATE
1007
1073
  if (draftParams.IsActiveEntity === false) {
1008
1074
  LOG.debug('patch draft')
1009
1075
 
1010
- if (req.target?.name.endsWith('DraftAdministrativeData')) req.reject({ code: 405, statusCode: 405 })
1076
+ if (req.target?.name.endsWith('DraftAdministrativeData')) cds.error(405)
1011
1077
 
1012
1078
  const draftsRef = _redirectRefToDrafts(query.UPDATE.entity.ref, this.model)
1013
1079
  const res = await SELECT.one.from({ ref: draftsRef }).columns('DraftAdministrativeData_DraftUUID', {
1014
1080
  ref: ['DraftAdministrativeData'],
1015
1081
  expand: [
1016
1082
  { ref: ['InProcessByUser'] },
1017
- ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
1018
- ? [{ ref: ['DraftMessages'] }]
1019
- : [])
1083
+ ...(IS_PERSISTED_DRAFT_MESSAGES_ENABLED ? [{ ref: ['DraftMessages'] }] : [])
1020
1084
  ]
1021
1085
  })
1022
1086
 
1023
- if (!res) req.reject({ code: 404, statusCode: 404 })
1087
+ if (!res) cds.error(404)
1024
1088
  if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
1025
- req.reject({
1026
- code: 403,
1027
- statusCode: 403,
1089
+ cds.error({
1090
+ status: 403,
1028
1091
  message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
1029
1092
  args: [res.DraftAdministrativeData?.InProcessByUser]
1030
1093
  })
@@ -1032,7 +1095,7 @@ const handle = async function (req) {
1032
1095
 
1033
1096
  const innerQuery = UPDATE({ ref: draftsRef }).data(req.data)
1034
1097
  const innerReq = _newReq(req, innerQuery, draftParams, {})
1035
- await h.call(this, innerReq).catch(error => {
1098
+ await protoHandle.call(this, innerReq).catch(error => {
1036
1099
  // > This prevents thrown req.errors from being added to 'sap-messages'
1037
1100
  // > Downgraded req.error will add to req.messages, regardless of whether the error is thrown
1038
1101
  // > Unless we prevent it here, the response will contain the error in both body and header
@@ -1047,16 +1110,13 @@ const handle = async function (req) {
1047
1110
  LastChangeDateTime: new Date()
1048
1111
  }
1049
1112
 
1050
- if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
1051
- nextDraftAdminData.DraftMessages = _compileUpdatedDraftMessages(
1052
- req.messages ?? [],
1113
+ if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED) {
1114
+ nextDraftAdminData.DraftMessages = compileUpdatedDraftMessages(
1115
+ req._validationErrors ?? [],
1053
1116
  res.DraftAdministrativeData?.DraftMessages ?? [],
1054
1117
  req.data ?? {},
1055
1118
  draftsRef
1056
1119
  )
1057
-
1058
- // Prevent validation errors from being sent in 'sap-messages' header
1059
- req.messages = req._set('_messages', new Responses())
1060
1120
  }
1061
1121
 
1062
1122
  await UPDATE('DRAFT.DraftAdministrativeData')
@@ -1069,32 +1129,34 @@ const handle = async function (req) {
1069
1129
 
1070
1130
  LOG.debug('patch active')
1071
1131
 
1072
- if (req.protocol === 'odata' && !cds.env.fiori.bypass_draft && !req.target['@odata.draft.bypass'])
1132
+ if (
1133
+ !isNewDraftViaActionEnabled &&
1134
+ req.protocol === 'odata' &&
1135
+ !cds.env.fiori.bypass_draft &&
1136
+ !req.target['@odata.draft.bypass']
1137
+ )
1073
1138
  return reject_bypassed_draft(req)
1074
1139
 
1075
1140
  const entityRef = query.UPDATE.entity.ref
1076
1141
 
1077
1142
  if (!this.model.definitions[entityRef[0].id || entityRef[0]]['@Common.DraftRoot.ActivationAction']) {
1078
- req.reject({ code: 403, statusCode: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
1143
+ // REVISIT: Should this really use 403?
1144
+ cds.error({ status: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
1079
1145
  }
1080
1146
 
1081
1147
  const draftsRef = _redirectRefToDrafts(entityRef, this.model)
1082
1148
  const draftsQuery = SELECT.one([1]).from({ ref: [draftsRef[0]] })
1083
1149
  if (query.UPDATE.where) draftsQuery.where(query.UPDATE.where)
1084
1150
  const hasDraft = !!(await draftsQuery)
1085
- if (hasDraft) req.reject({ code: 409, statusCode: 409, message: 'DRAFT_ALREADY_EXISTS' })
1151
+ if (hasDraft) cds.error({ status: 409, message: 'DRAFT_ALREADY_EXISTS' })
1086
1152
 
1087
1153
  await run(query)
1088
1154
  return req.data
1089
1155
  }
1090
1156
 
1091
- if (req.event === 'CREATE' && draftParams.IsActiveEntity === false && !req.target.isDraft) {
1092
- req.reject({ code: 403, statusCode: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
1093
- }
1094
-
1095
1157
  req.query = query
1096
1158
 
1097
- return h.call(this, req)
1159
+ return protoHandle.call(this, req)
1098
1160
  }
1099
1161
 
1100
1162
  // REVISIT: It's not optimal to first calculate the whole result array and only later
@@ -1176,7 +1238,7 @@ const Read = {
1176
1238
  // DraftAdministrativeData is only accessible via drafts
1177
1239
  if (_isCount(query)) return run(query)
1178
1240
  if (query._target.name.endsWith('.DraftAdministrativeData')) {
1179
- if (query.SELECT.from.ref?.length === 1) throw cds.error('INVALID_DRAFT_REQUEST', { statusCode: 400 }) // only via drafts
1241
+ if (query.SELECT.from.ref?.length === 1) cds.error({ status: 400, message: 'INVALID_DRAFT_REQUEST' }) // only via drafts
1180
1242
  return run(query._drafts)
1181
1243
  }
1182
1244
  if (!query._target._isDraftEnabled) return run(query)
@@ -1220,7 +1282,7 @@ const Read = {
1220
1282
  LOG.debug('List Editing Status: Unchanged')
1221
1283
 
1222
1284
  const draftsQuery = query._drafts
1223
- if (!draftsQuery) throw cds.error('INVALID_DRAFT_REQUEST', { statusCode: 400 }) // only via drafts
1285
+ if (!draftsQuery) cds.error({ status: 400, message: 'INVALID_DRAFT_REQUEST' }) // only via drafts
1224
1286
  draftsQuery.SELECT.count = undefined
1225
1287
  draftsQuery.SELECT.orderBy = undefined
1226
1288
  draftsQuery.SELECT.limit = null
@@ -1236,7 +1298,7 @@ const Read = {
1236
1298
  ownDrafts: async function (run, query) {
1237
1299
  LOG.debug('List Editing Status: Own Draft')
1238
1300
 
1239
- if (!query._drafts) throw cds.error('INVALID_DRAFT_REQUEST', { statusCode: 400 }) // only via drafts
1301
+ if (!query._drafts) cds.error({ status: 400, message: 'INVALID_DRAFT_REQUEST' }) // only via drafts
1240
1302
 
1241
1303
  // read active from draft
1242
1304
  if (!query._drafts._target?.name.endsWith('.drafts')) {
@@ -1259,13 +1321,16 @@ const Read = {
1259
1321
  selectedColumnNames.includes('*') ||
1260
1322
  Object.keys(query._drafts._target.keys).every(k => selectedColumnNames.includes(k))
1261
1323
 
1262
- if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && isAllKeysSelected) {
1324
+ let txModel
1325
+ if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED && isAllKeysSelected) {
1263
1326
  // Replace selection of 'DraftMessages' with the proper path expression
1264
1327
 
1265
1328
  query._drafts.SELECT.columns = [
1266
1329
  ...(query._drafts.SELECT.columns ?? ['*']).filter(c => c.ref?.[0] !== 'DraftMessages'),
1267
1330
  { ref: ['DraftAdministrativeData', 'DraftMessages'], as: 'DraftMessages' }
1268
1331
  ]
1332
+
1333
+ txModel = cds.context.tx.model
1269
1334
  }
1270
1335
 
1271
1336
  const draftsQuery = query._drafts.where(
@@ -1281,31 +1346,65 @@ const Read = {
1281
1346
  })
1282
1347
  _fillIsActiveEntity(row, false, query._drafts._target)
1283
1348
 
1284
- if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && row.DraftMessages?.length) {
1285
- // Reduce persisted draft messages to the set of those that are part of the queried entities tree
1286
-
1287
- const simplePath = query._drafts.SELECT.from.ref
1288
- .map(refElement => {
1289
- refElement = (refElement.id ?? refElement).split('.')
1290
- if (refElement.length === 1) return refElement[0]
1291
- if (refElement[refElement.length - 1] === 'drafts') return refElement[refElement.length - 2]
1292
- return refElement[refElement.length - 1]
1293
- })
1294
- .join('/')
1295
-
1296
- row.DraftMessages = row.DraftMessages.reduce((acc, msg) => {
1297
- const messageSimplePath = msg.prefix.replaceAll(/\([^(]*\)/g, '')
1298
- if (!messageSimplePath.startsWith(simplePath)) return acc
1349
+ if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED && row.DraftMessages?.length) {
1350
+ // Based on the query for own drafts, compile the ref of a query matching
1351
+ // the format produced by cds.odata.parse to compare against message prefixes
1352
+
1353
+ const queryRootEntity = txModel.definitions[draftsQuery.SELECT.from.ref[0].id || draftsQuery.SELECT.from.ref[0]]
1354
+ const queryPrefixRef = _extractPrefixRef(draftsQuery.SELECT.from.ref, queryRootEntity)
1355
+
1356
+ if (queryPrefixRef.length > 1) {
1357
+ let targetRef = queryPrefixRef[queryPrefixRef.length - 1]
1358
+ if (typeof targetRef === 'string') {
1359
+ // Construct key from model info and result data
1360
+
1361
+ queryPrefixRef[queryPrefixRef.length - 1] = targetRef = { id: targetRef, where: [] }
1362
+
1363
+ // This repliactes the strucutally equivalent logic from _compileUpdatedDraftMessages
1364
+ // > This is necessary to enable filtering for messages realted to a newly created entity
1365
+ // > Since readAfterRide will put key info into a filter rather than into the path
1366
+ for (const k in draftsQuery._target.actives.keys) {
1367
+ const key = draftsQuery._target.actives.keys[k]
1368
+ let v = row[key.name]
1369
+ if (v === undefined)
1370
+ if (key.name === 'IsActiveEntity') v = false
1371
+ else continue
1372
+ if (v instanceof Buffer) v = v.toString('base64') //> convert binary keys to base64
1373
+ if (targetRef.where.length > 0) targetRef.where.push('and')
1374
+ targetRef.where.push({ ref: [key.name] }, '=', { val: v })
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ // Reduce persisted draft messages to the set of those that are relevant for this result row:
1380
+ // Specification: https://sapui5.hana.ondemand.com/#/topic/fbe1cb5613cf4a40a841750bf813238e:~:text=Lifecycle%20Management%20for%20State%20Messages,-The%20lifecycle%20management
1381
+ const filteredDraftMessages = row.DraftMessages.reduce((acc, msg) => {
1382
+ const msgPrefixRef = cds.odata.parse(msg.prefix).SELECT.from.ref
1383
+ if (queryPrefixRef.length > msgPrefixRef.length) return acc
1384
+
1385
+ // TODO: This seems excessive ... how to compare more efficiently?
1386
+ const queryPrefixUrl = cds.odata.urlify(
1387
+ { SELECT: { from: { ref: queryPrefixRef } } },
1388
+ { kind: 'odata-v4', model: txModel }
1389
+ ).path
1390
+ const msgPrefixUrl = cds.odata.urlify(
1391
+ { SELECT: { from: { ref: msgPrefixRef } } },
1392
+ { kind: 'odata-v4', model: txModel }
1393
+ ).path
1394
+
1395
+ if (!msgPrefixUrl.startsWith(queryPrefixUrl)) return acc
1396
+
1299
1397
  msg.target = `/${msg.prefix}/${msg.target}`
1300
1398
  if (msg.additionalTargets?.length)
1301
1399
  msg.additionalTargets = msg.additionalTargets.map(additionalTarget => `/${msg.prefix}/${additionalTarget}`)
1400
+
1302
1401
  delete msg.prefix
1303
- delete msg.simplePathElements
1402
+
1304
1403
  acc.push(msg)
1305
1404
  return acc
1306
1405
  }, [])
1307
1406
 
1308
- row.DraftMessages = getLocalizedMessages(row.DraftMessages, cds.context.http.req)
1407
+ row.DraftMessages = getLocalizedMessages(filteredDraftMessages, cds.context.http.req)
1309
1408
  }
1310
1409
  })
1311
1410
 
@@ -1480,7 +1579,7 @@ const Read = {
1480
1579
 
1481
1580
  activesFromDrafts: async function (run, query, { isLocked = true }) {
1482
1581
  const draftsQuery = query._drafts
1483
- if (!draftsQuery) throw cds.error('INVALID_DRAFT_REQUEST', { statusCode: 400 }) // only via drafts
1582
+ if (!draftsQuery) cds.error({ status: 400, message: 'INVALID_DRAFT_REQUEST' }) // only via drafts
1484
1583
 
1485
1584
  const additionalCols = draftsQuery.SELECT.columns
1486
1585
  ? draftsQuery.SELECT.columns.filter(
@@ -1908,6 +2007,8 @@ function expandStarStar(target, draftActivate, recursion = new Map()) {
1908
2007
  }
1909
2008
 
1910
2009
  async function beforeNew(req) {
2010
+ if (!req.target.isDraft) return
2011
+
1911
2012
  function _cleanseData(data, target) {
1912
2013
  if (!data) return
1913
2014
  delete data.IsActiveEntity
@@ -1924,28 +2025,32 @@ async function beforeNew(req) {
1924
2025
 
1925
2026
  return data
1926
2027
  }
2028
+
1927
2029
  _cleanseData(req.data, req.target)
1928
2030
  }
1929
2031
  beforeNew._initial = true
1930
2032
 
1931
- async function onNew(req) {
2033
+ async function onNew(req, next) {
2034
+ if (!req.target.isDraft) return next?.()
2035
+
1932
2036
  LOG.debug('new draft')
1933
2037
 
1934
2038
  if (req.target.actives['@Capabilities.InsertRestrictions.Insertable'] === false || req.target.actives['@readonly'])
1935
- req.reject({ code: 405, statusCode: 405 })
2039
+ cds.error(405)
1936
2040
 
1937
- req.query ??= INSERT.into(req.subject).entries(req.data || {}) //> support simple srv.send('NEW',entity,...)
1938
- const isDirectAccess = typeof req.query.INSERT.into === 'string' || req.query.INSERT.into.ref?.length === 1
2041
+ //> support simple srv.send('NEW',entity,...)
2042
+ req.query ??= INSERT.into(req.subject).entries(req.data || {})
1939
2043
 
1940
2044
  // Only allowed for pseudo draft roots (entities with this action)
2045
+ const isDirectAccess = req.query.INSERT.into.ref?.length === 1
1941
2046
  if (isDirectAccess && !req.target.actives['@Common.DraftRoot.ActivationAction'])
1942
- req.reject({ code: 403, statusCode: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
2047
+ cds.error({ status: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
1943
2048
 
1944
2049
  _cleanUpOldDrafts(this, req.tenant)
1945
2050
 
1946
2051
  let DraftUUID
1947
2052
  let DraftMessages = []
1948
- let newDraftMessages = req.messages ?? req._messages ?? []
2053
+ let nextDraftMessages = []
1949
2054
  if (isDirectAccess) DraftUUID = cds.utils.uuid()
1950
2055
  else {
1951
2056
  const rootData = await SELECT.one(req.query.INSERT.into.ref[0].id)
@@ -1955,19 +2060,16 @@ async function onNew(req) {
1955
2060
  ref: ['DraftAdministrativeData'],
1956
2061
  expand: [
1957
2062
  { ref: ['InProcessByUser'] },
1958
- ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
1959
- ? [{ ref: ['DraftMessages'] }]
1960
- : [])
2063
+ ...(IS_PERSISTED_DRAFT_MESSAGES_ENABLED ? [{ ref: ['DraftMessages'] }] : [])
1961
2064
  ]
1962
2065
  }
1963
2066
  ])
1964
2067
  .where(req.query.INSERT.into.ref[0].where)
1965
2068
 
1966
- if (!rootData) req.reject({ code: 404, statusCode: 404 })
2069
+ if (!rootData) cds.error(404)
1967
2070
  if (!cds.context.user._is_privileged && rootData.DraftAdministrativeData?.InProcessByUser !== req.user.id)
1968
- req.reject({
1969
- code: 403,
1970
- statusCode: 403,
2071
+ cds.error({
2072
+ status: 403,
1971
2073
  message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
1972
2074
  args: [rootData.DraftAdministrativeData.InProcessByUser]
1973
2075
  })
@@ -1976,33 +2078,16 @@ async function onNew(req) {
1976
2078
  DraftMessages = rootData.DraftAdministrativeData?.DraftMessages || []
1977
2079
  }
1978
2080
 
1979
- const _setDraftColumns = (obj, target) => {
1980
- const newObj = Object.assign({}, obj, { DraftAdministrativeData_DraftUUID: DraftUUID, HasActiveEntity: false })
1981
- if (!target) return newObj
1982
-
1983
- // Also support deep insertions
1984
- for (const key in newObj) {
1985
- if (!target.elements[key]?.isComposition) continue
1986
- // do array trick
1987
- if (Array.isArray(newObj[key]))
1988
- newObj[key] = newObj[key].map(v => _setDraftColumns(v, target.elements[key]._target))
1989
- else if (typeof newObj[key] === 'object') {
1990
- newObj[key] = _setDraftColumns(newObj[key], target.elements[key]._target)
1991
- }
1992
- }
1993
-
1994
- return newObj
1995
- }
1996
-
1997
2081
  const timestamp = cds.context.timestamp.toISOString() // REVISIT: toISOString should be done on db layer
1998
- const draftData = _setDraftColumns(req.query.INSERT.entries[0], req.target)
2082
+ const draftData = _createNewDraftData(req.query.INSERT.entries[0], req.target, DraftUUID)
1999
2083
  const draftCQN = INSERT.into(req.subject).entries(draftData)
2000
2084
 
2001
- await this.run(draftCQN)
2085
+ const insertReq = _newReq(req, draftCQN, req.query[$draftParams], {})
2086
+ await this.dispatch(insertReq)
2002
2087
 
2003
- if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
2004
- newDraftMessages = _compileUpdatedDraftMessages(
2005
- newDraftMessages,
2088
+ if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED) {
2089
+ nextDraftMessages = compileUpdatedDraftMessages(
2090
+ req._validationErrors ?? [],
2006
2091
  DraftMessages,
2007
2092
  draftData,
2008
2093
  req.query.INSERT.into.ref
@@ -2019,18 +2104,14 @@ async function onNew(req) {
2019
2104
  DraftIsCreatedByMe: true, // Dummy values
2020
2105
  DraftIsProcessedByMe: true, // Dummy values
2021
2106
  InProcessByUser: req.user.id,
2022
- ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
2023
- ? { DraftMessages: newDraftMessages }
2024
- : {})
2107
+ ...(IS_PERSISTED_DRAFT_MESSAGES_ENABLED ? { DraftMessages: nextDraftMessages } : {})
2025
2108
  })
2026
2109
  : UPDATE('DRAFT.DraftAdministrativeData')
2027
2110
  .data({
2028
2111
  InProcessByUser: req.user.id,
2029
2112
  LastChangedByUser: req.user.id,
2030
2113
  LastChangeDateTime: timestamp,
2031
- ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
2032
- ? { DraftMessages: newDraftMessages }
2033
- : {})
2114
+ ...(IS_PERSISTED_DRAFT_MESSAGES_ENABLED ? { DraftMessages: nextDraftMessages } : {})
2034
2115
  })
2035
2116
  .where({ DraftUUID })
2036
2117
 
@@ -2041,17 +2122,18 @@ async function onNew(req) {
2041
2122
  return { ...draftData, IsActiveEntity: false }
2042
2123
  }
2043
2124
 
2044
- async function onEdit(req) {
2125
+ async function onEdit(req, next) {
2126
+ if (req.target.isDraft) return next?.()
2127
+
2045
2128
  LOG.debug('edit active')
2046
2129
 
2047
2130
  req.query ??= SELECT.from(req.target, req.data).where({ IsActiveEntity: true }) //> support simple srv.send('EDIT',entity,...)
2048
2131
 
2049
2132
  // REVISIT: can draftParams in the edit case ever be undefined or other than IsActiveEntity=true ?
2050
2133
  const draftParams = req.query[$draftParams] || { IsActiveEntity: true }
2051
- if (req.query.SELECT.from.ref.length > 1 || draftParams.IsActiveEntity !== true) {
2052
- req.reject({
2053
- code: 400,
2054
- statusCode: 400,
2134
+ if (req.query.SELECT.from.ref.length > 1 || draftParams.IsActiveEntity === false) {
2135
+ cds.error({
2136
+ status: 400,
2055
2137
  message: 'Action "draftEdit" can only be called on the root active entity'
2056
2138
  })
2057
2139
  }
@@ -2061,11 +2143,9 @@ async function onEdit(req) {
2061
2143
  req.target['@insertonly'] ||
2062
2144
  req.target['@readonly']
2063
2145
  ) {
2064
- req.reject({ code: 405, statusCode: 405 })
2146
+ cds.error({ status: 405 })
2065
2147
  }
2066
2148
 
2067
- if (draftParams.IsActiveEntity !== true) req.reject({ code: 400, statusCode: 400 })
2068
-
2069
2149
  _cleanUpOldDrafts(this, req.tenant)
2070
2150
 
2071
2151
  const DraftUUID = cds.utils.uuid()
@@ -2143,8 +2223,7 @@ async function onEdit(req) {
2143
2223
  } catch (error) {
2144
2224
  LOG._debug && LOG.debug('Failed to acquire database lock:', error)
2145
2225
  const draft = await existingDraft
2146
- if (draft) req.reject({ code: 409, statusCode: 409, message: 'DRAFT_ALREADY_EXISTS' })
2147
- req.reject({ code: 409, statusCode: 409, message: 'ENTITY_LOCKED' })
2226
+ cds.error({ status: 409, message: draft ? 'DRAFT_ALREADY_EXISTS' : 'ENTITY_LOCKED' })
2148
2227
  }
2149
2228
 
2150
2229
  const cqns = [
@@ -2175,13 +2254,13 @@ async function onEdit(req) {
2175
2254
  ])
2176
2255
  }
2177
2256
 
2178
- if (!res) req.reject({ code: 404, statusCode: 404 })
2257
+ if (!res) cds.error(404)
2179
2258
 
2180
2259
  const preserveChanges = req.data?.PreserveChanges
2181
2260
  const inProcessByUser = draft?.DraftAdministrativeData?.InProcessByUser
2182
2261
 
2183
2262
  if (draft) {
2184
- if (inProcessByUser || preserveChanges) req.reject({ code: 409, statusCode: 409, message: 'DRAFT_ALREADY_EXISTS' })
2263
+ if (inProcessByUser || preserveChanges) cds.error({ status: 409, message: 'DRAFT_ALREADY_EXISTS' })
2185
2264
  const keys = {}
2186
2265
  for (const key in req.target.drafts.keys) keys[key] = res[key]
2187
2266
  await _promiseAll([
@@ -2231,58 +2310,101 @@ async function onEdit(req) {
2231
2310
  }
2232
2311
  }
2233
2312
 
2234
- async function onCancel(req) {
2313
+ async function onCancel(req, next) {
2314
+ if (!req.target.isDraft) return next?.()
2235
2315
  LOG.debug('delete draft')
2236
2316
 
2237
2317
  req.query ??= DELETE(req.target, req.data) //> support simple srv.send('CANCEL',entity,...)
2238
- const draftParams = req.query[$draftParams] || { IsActiveEntity: false } // REVISIT: can draftParams in the cancel case ever be undefined or other than IsActiveEntity=false ?
2318
+ const draftParams = req.query[$draftParams] || { IsActiveEntity: false }
2239
2319
 
2240
- const draftQuery = SELECT.one
2241
- .from({ ref: req.query.DELETE.from.ref })
2242
- .columns([
2243
- 'DraftAdministrativeData_DraftUUID',
2244
- { ref: ['DraftAdministrativeData'], expand: [_inProcessByUserXpr(_lock.shiftedNow)] }
2245
- ])
2320
+ const expandedDraftAdminElements = [_inProcessByUserXpr(_lock.shiftedNow)]
2321
+ if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED) expandedDraftAdminElements.push({ ref: ['DraftMessages'] })
2322
+
2323
+ const draftQuery = SELECT.one.from({ ref: req.query.DELETE.from.ref }).columns([
2324
+ 'DraftAdministrativeData_DraftUUID',
2325
+ {
2326
+ ref: ['DraftAdministrativeData'],
2327
+ expand: expandedDraftAdminElements
2328
+ }
2329
+ ])
2246
2330
  if (req._etagValidationClause && draftParams.IsActiveEntity === false) draftQuery.where(req._etagValidationClause)
2247
- // do not add InProcessByUser restriction
2331
+
2248
2332
  const draft = await draftQuery
2249
- if (!draft) req.reject(req._etagValidationType ? 412 : 404)
2250
- if (draft) {
2251
- const processByUser = draft.DraftAdministrativeData?.InProcessByUser
2252
- if (!cds.context.user._is_privileged && processByUser && processByUser !== cds.context.user.id)
2253
- req.reject({ code: 403, statusCode: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [processByUser] })
2254
- }
2255
2333
 
2256
- const draftDeleteQuery = DELETE.from({ ref: req.query.DELETE.from.ref }) // REVISIT: Isn't that == req.query ?
2334
+ if (!draft) cds.error({ status: req._etagValidationType ? 412 : 404 })
2335
+
2336
+ const processByUser = draft.DraftAdministrativeData?.InProcessByUser
2337
+ if (!cds.context.user._is_privileged && processByUser && processByUser !== cds.context.user.id)
2338
+ cds.error({ status: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [processByUser] })
2339
+
2340
+ const deleteQuery = DELETE.from({ ref: req.query.DELETE.from.ref })
2341
+ const deleteReq = _newReq(req, deleteQuery, draftParams, {})
2342
+ const queries = [this.dispatch(deleteReq)]
2343
+
2344
+ if (req.target['@Common.DraftRoot.ActivationAction']) {
2345
+ // The root entity is being deleted and draft admin data can be deleted
2257
2346
 
2258
- const queries = !draft ? [] : [this.run(draftDeleteQuery, req.data)]
2259
- if (draft && req.target['@Common.DraftRoot.ActivationAction'])
2260
- // only for draft root
2261
- queries.push(
2262
- DELETE.from('DRAFT.DraftAdministrativeData').where({ DraftUUID: draft.DraftAdministrativeData_DraftUUID })
2263
- )
2264
- else
2265
2347
  queries.push(
2266
- UPDATE('DRAFT.DraftAdministrativeData')
2267
- .data({
2268
- InProcessByUser: cds.context.user.id,
2269
- LastChangedByUser: cds.context.user.id,
2270
- LastChangeDateTime: cds.context.timestamp.toISOString()
2271
- })
2272
- .where({ DraftUUID: draft.DraftAdministrativeData_DraftUUID })
2348
+ DELETE.from('DRAFT.DraftAdministrativeData').where({
2349
+ DraftUUID: draft.DraftAdministrativeData_DraftUUID
2350
+ })
2273
2351
  )
2352
+ } else {
2353
+ // A child entity is being deleted and draft admin data must be updated
2354
+
2355
+ const nextDraftAdminData = {
2356
+ InProcessByUser: cds.context.user.id,
2357
+ LastChangedByUser: cds.context.user.id,
2358
+ LastChangeDateTime: cds.context.timestamp.toISOString()
2359
+ }
2360
+
2361
+ if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED) {
2362
+ // Determine persisted draft messages that relate to the deleted child entity
2363
+
2364
+ const queryRootEntity = this.model.definitions[req.query.DELETE.from.ref[0].id || req.query.DELETE.from.ref[0]]
2365
+
2366
+ if (queryRootEntity['@Common.DraftRoot.ActivationAction']) {
2367
+ // The DELETE query uses containment & we can compile a target prefix
2368
+ // > This allows us to only remove those messages that target the delted child
2369
+
2370
+ const prefixRef = _extractPrefixRef(req.query.DELETE.from.ref, queryRootEntity)
2371
+ const messageTargetPrefix = cds.odata.urlify(
2372
+ { SELECT: { from: { ref: prefixRef } } },
2373
+ { kind: 'odata-v4', model: req.context.tx.model }
2374
+ ).path
2375
+
2376
+ // Remove those draft messages whose prefix matches the deleted child entity
2377
+ nextDraftAdminData.DraftMessages = (draft?.DraftAdministrativeData?.DraftMessages || []).filter(
2378
+ msg => !msg.prefix.startsWith(messageTargetPrefix)
2379
+ )
2380
+ } else {
2381
+ // The query does not use containment & we can not compile a target prefix
2382
+ // > Best ew can do is to clear all persisted messages
2383
+
2384
+ nextDraftAdminData.DraftMessages = []
2385
+ }
2386
+ }
2387
+
2388
+ const updateDraftAdminDataQuery = UPDATE('DRAFT.DraftAdministrativeData')
2389
+ .data(nextDraftAdminData)
2390
+ .where({ DraftUUID: draft.DraftAdministrativeData_DraftUUID })
2391
+
2392
+ queries.push(updateDraftAdminDataQuery)
2393
+ }
2394
+
2274
2395
  await _promiseAll(queries)
2396
+
2275
2397
  return req.data
2276
2398
  }
2277
2399
 
2278
- async function onPrepare(req) {
2400
+ async function onPrepare(req, next) {
2401
+ if (!req.target.isDraft) return next?.()
2279
2402
  LOG.debug('prepare draft')
2280
2403
 
2281
2404
  const draftParams = req.query[$draftParams]
2282
2405
  if (req.query.SELECT.from.ref.length > 1 || draftParams.IsActiveEntity !== false) {
2283
- req.reject({
2284
- code: 400,
2285
- statusCode: 400,
2406
+ cds.error({
2407
+ status: 400,
2286
2408
  message: 'Action "draftPrepare" can only be called on the root draft entity'
2287
2409
  })
2288
2410
  }
@@ -2295,11 +2417,10 @@ async function onPrepare(req) {
2295
2417
  .where(where)
2296
2418
  draftQuery[$draftParams] = draftParams
2297
2419
  const data = await draftQuery
2298
- if (!data) req.reject({ code: 404, statusCode: 404 })
2420
+ if (!data) cds.error(404)
2299
2421
  if (!cds.context.user._is_privileged && data.DraftAdministrativeData?.InProcessByUser !== req.user.id)
2300
- req.reject({
2301
- code: 403,
2302
- statusCode: 403,
2422
+ cds.error({
2423
+ status: 403,
2303
2424
  message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
2304
2425
  args: [data.DraftAdministrativeData?.InProcessByUser]
2305
2426
  })
@@ -2355,6 +2476,7 @@ module.exports = cds.service.impl(function () {
2355
2476
  // REVISIT: don't pollute services... -> do we really need this?
2356
2477
  Object.defineProperty(this, '_datasource', { value: cds.db })
2357
2478
 
2479
+ // Extend the AppService's API by draft specific functions
2358
2480
  this.new = function (draft, key) {
2359
2481
  return {
2360
2482
  then: (r, e) => this.send('NEW', draft, key).then(r, e),
@@ -2374,22 +2496,32 @@ module.exports = cds.service.impl(function () {
2374
2496
  return this.send('DISCARD', draft, key)
2375
2497
  }
2376
2498
 
2377
- this.handle = handle
2378
-
2379
- function _wrapped(handler, isActiveEntity) {
2380
- const fn = function handle_draft_requests(req, next) {
2381
- if (!req.target?.drafts || (isActiveEntity && req.target.isDraft) || (!isActiveEntity && !req.target.isDraft))
2382
- return next?.()
2383
- return handler.call(this, req, next)
2384
- }
2385
- if (handler._initial) fn._initial = true
2386
- return fn
2387
- }
2388
-
2389
- // Also runs those handlers if they're annotated with @odata.draft.enabled through extensibility
2390
- this.prepend(s => s.before('NEW', '*', _wrapped(beforeNew, false)))
2391
- this.on('NEW', '*', _wrapped(onNew, false))
2392
- this.on('EDIT', '*', _wrapped(onEdit, true))
2393
- this.on('CANCEL', '*', _wrapped(onCancel, false))
2394
- this.on('draftPrepare', '*', _wrapped(onPrepare, false))
2499
+ // Override the AppService's handle function
2500
+ this.handle = draftHandle
2501
+
2502
+ this.prepend(s =>
2503
+ s.before(
2504
+ 'NEW',
2505
+ '*',
2506
+ Object.assign(
2507
+ function (req) {
2508
+ return beforeNew.call(this, req)
2509
+ },
2510
+ { _initial: true }
2511
+ )
2512
+ )
2513
+ )
2514
+ this.on('NEW', '*', function (req, next) {
2515
+ return onNew.call(this, req, next)
2516
+ })
2517
+ this.on('EDIT', '*', function (req, next) {
2518
+ return onEdit.call(this, req, next)
2519
+ })
2520
+ this.on('CANCEL', '*', function (req, next) {
2521
+ return onCancel.call(this, req, next)
2522
+ })
2523
+ this.on('draftPrepare', '*', function (req, next) {
2524
+ return onPrepare.call(this, req, next)
2525
+ })
2395
2526
  })
2527
+ module.exports.compileUpdatedDraftMessages = compileUpdatedDraftMessages