@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.
- package/CHANGELOG.md +71 -1
- package/_i18n/messages_en_US_saptrc.properties +1 -1
- package/common.cds +5 -2
- package/lib/compile/cds-compile.js +1 -0
- package/lib/compile/for/assert.js +64 -0
- package/lib/compile/for/flows.js +194 -58
- package/lib/compile/for/lean_drafts.js +75 -7
- package/lib/compile/parse.js +1 -1
- package/lib/compile/to/csn.js +6 -2
- package/lib/compile/to/edm.js +1 -1
- package/lib/compile/to/yaml.js +8 -1
- package/lib/dbs/cds-deploy.js +2 -2
- package/lib/env/cds-env.js +14 -4
- package/lib/env/defaults.js +6 -1
- package/lib/i18n/localize.js +1 -1
- package/lib/index.js +7 -7
- package/lib/req/event.js +4 -0
- package/lib/req/validate.js +3 -0
- package/lib/srv/cds.Service.js +2 -1
- package/lib/srv/middlewares/auth/ias-auth.js +5 -7
- package/lib/srv/middlewares/auth/index.js +1 -1
- package/lib/srv/protocols/index.js +7 -6
- package/lib/srv/srv-handlers.js +7 -0
- package/libx/_runtime/common/Service.js +5 -1
- package/libx/_runtime/common/constants/events.js +1 -0
- package/libx/_runtime/common/generic/assert.js +220 -0
- package/libx/_runtime/common/generic/flows.js +168 -108
- package/libx/_runtime/common/utils/cqn.js +0 -24
- package/libx/_runtime/common/utils/normalizeTimestamp.js +2 -2
- package/libx/_runtime/common/utils/resolveView.js +8 -2
- package/libx/_runtime/common/utils/templateProcessor.js +10 -1
- package/libx/_runtime/common/utils/templateProcessorPathSerializer.js +21 -9
- package/libx/_runtime/fiori/lean-draft.js +511 -379
- package/libx/_runtime/messaging/enterprise-messaging-utils/registerEndpoints.js +39 -35
- package/libx/_runtime/messaging/enterprise-messaging.js +2 -2
- package/libx/_runtime/remote/Service.js +4 -5
- package/libx/_runtime/ucl/Service.js +111 -15
- package/libx/common/utils/streaming.js +1 -1
- package/libx/odata/middleware/batch.js +8 -6
- package/libx/odata/middleware/create.js +2 -2
- package/libx/odata/middleware/delete.js +2 -2
- package/libx/odata/middleware/metadata.js +18 -11
- package/libx/odata/middleware/read.js +2 -2
- package/libx/odata/middleware/service-document.js +1 -1
- package/libx/odata/middleware/update.js +1 -1
- package/libx/odata/parse/afterburner.js +24 -25
- package/libx/odata/parse/cqn2odata.js +2 -6
- package/libx/odata/parse/grammar.peggy +90 -12
- package/libx/odata/parse/parser.js +1 -1
- package/libx/odata/utils/index.js +2 -2
- package/libx/odata/utils/readAfterWrite.js +2 -0
- package/libx/queue/TaskRunner.js +26 -1
- package/libx/queue/index.js +11 -1
- package/package.json +1 -1
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
509
|
-
const
|
|
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
|
|
519
|
-
|
|
520
|
-
/* prettier-ignore */
|
|
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
|
|
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
|
|
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
|
|
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
|
|
617
|
-
draftParams
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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)
|
|
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
|
-
//
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
736
|
+
let draftRootEntityExists
|
|
671
737
|
|
|
672
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
747
|
+
draftRootEntityExists = await SELECT.one([1]).from({ ref: draftsRootRef }).where(keyData)
|
|
684
748
|
}
|
|
685
749
|
|
|
686
|
-
if (
|
|
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
|
-
//
|
|
698
|
-
if (req.event === '
|
|
699
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
720
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
if (!req.target.actions?.draftActivate) nextDraftMessages = []
|
|
774
|
+
req.event = req._.event = 'NEW'
|
|
775
|
+
req.query = req._.query = createNewQuery
|
|
725
776
|
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
802
|
+
cds.error({ status: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
|
|
746
803
|
}
|
|
747
804
|
|
|
748
|
-
const result = await
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
825
|
-
else
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
879
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
await
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1036
|
+
|
|
1037
|
+
if (!root) cds.error(404)
|
|
981
1038
|
if (root.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
|
|
982
|
-
|
|
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
|
|
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'))
|
|
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
|
-
...(
|
|
1018
|
-
? [{ ref: ['DraftMessages'] }]
|
|
1019
|
-
: [])
|
|
1083
|
+
...(IS_PERSISTED_DRAFT_MESSAGES_ENABLED ? [{ ref: ['DraftMessages'] }] : [])
|
|
1020
1084
|
]
|
|
1021
1085
|
})
|
|
1022
1086
|
|
|
1023
|
-
if (!res)
|
|
1087
|
+
if (!res) cds.error(404)
|
|
1024
1088
|
if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
|
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 (
|
|
1051
|
-
nextDraftAdminData.DraftMessages =
|
|
1052
|
-
req.
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
1285
|
-
//
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
-
|
|
1402
|
+
|
|
1304
1403
|
acc.push(msg)
|
|
1305
1404
|
return acc
|
|
1306
1405
|
}, [])
|
|
1307
1406
|
|
|
1308
|
-
row.DraftMessages = getLocalizedMessages(
|
|
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)
|
|
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
|
-
|
|
2039
|
+
cds.error(405)
|
|
1936
2040
|
|
|
1937
|
-
|
|
1938
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...(
|
|
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)
|
|
2069
|
+
if (!rootData) cds.error(404)
|
|
1967
2070
|
if (!cds.context.user._is_privileged && rootData.DraftAdministrativeData?.InProcessByUser !== req.user.id)
|
|
1968
|
-
|
|
1969
|
-
|
|
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 =
|
|
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
|
-
|
|
2085
|
+
const insertReq = _newReq(req, draftCQN, req.query[$draftParams], {})
|
|
2086
|
+
await this.dispatch(insertReq)
|
|
2002
2087
|
|
|
2003
|
-
if (
|
|
2004
|
-
|
|
2005
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
|
2052
|
-
|
|
2053
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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 }
|
|
2318
|
+
const draftParams = req.query[$draftParams] || { IsActiveEntity: false }
|
|
2239
2319
|
|
|
2240
|
-
const
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2267
|
-
.
|
|
2268
|
-
|
|
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
|
-
|
|
2284
|
-
|
|
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)
|
|
2420
|
+
if (!data) cds.error(404)
|
|
2299
2421
|
if (!cds.context.user._is_privileged && data.DraftAdministrativeData?.InProcessByUser !== req.user.id)
|
|
2300
|
-
|
|
2301
|
-
|
|
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
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
this.on('
|
|
2393
|
-
|
|
2394
|
-
|
|
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
|