@sap/cds 9.0.4 → 9.1.0

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.
@@ -10,12 +10,15 @@ const { handler: commonGenericSorting } = require('../common/generic/sorting')
10
10
  const { addEtagColumns } = require('../common/utils/etag')
11
11
  const { handleStreamProperties } = require('../common/utils/streamProp')
12
12
 
13
+ const { getLocalizedMessages } = require('../../odata/middleware/error')
14
+ const { Responses } = require('../../../lib/req/response')
13
15
  const location4 = require('../../http/location')
14
16
 
15
17
  const $original = Symbol('original')
16
18
  const $draftParams = Symbol('draftParams')
17
19
 
18
20
  const AGGREGATION_FUNCTIONS = ['sum', 'min', 'max', 'avg', 'average', 'count']
21
+ const MAX_RECURSION_DEPTH = cds.env.features.recursion_depth != null ? Number(cds.env.features.recursion_depth) : 4
19
22
 
20
23
  const _config_to_ms = (config, _default) => {
21
24
  const timeout = cds.env.fiori?.[config]
@@ -71,16 +74,6 @@ const DRAFT_ELEMENTS = new Set([
71
74
  const DRAFT_ELEMENTS_WITHOUT_HASACTIVE = new Set(DRAFT_ELEMENTS)
72
75
  DRAFT_ELEMENTS_WITHOUT_HASACTIVE.delete('HasActiveEntity')
73
76
  const REDUCED_DRAFT_ELEMENTS = new Set(['IsActiveEntity', 'HasDraftEntity', 'SiblingEntity'])
74
- const DRAFT_ADMIN_ELEMENTS = [
75
- 'DraftUUID',
76
- 'LastChangedByUser',
77
- 'LastChangeDateTime',
78
- 'CreatedByUser',
79
- 'CreationDateTime',
80
- 'InProcessByUser',
81
- 'DraftIsCreatedByMe',
82
- 'DraftIsProcessedByMe'
83
- ]
84
77
 
85
78
  const numericCollator = { numeric: true }
86
79
  const emptyObject = {}
@@ -279,6 +272,96 @@ const _removeEmptyStreams = async result => {
279
272
  }
280
273
  }
281
274
 
275
+ const getUpdateFromSelectQueries = (target, draftRef, activeRef, depth = 0) => {
276
+ const updateMediaDataQueries = []
277
+
278
+ // Collect relevant elements
279
+ const compositionElements = []
280
+ const mediaDataElements = []
281
+ const autogeneratedElements = []
282
+ const targetElements = Array.isArray(target.elements) ? target.elements : Object.values(target.elements)
283
+ for (const element of targetElements) {
284
+ if (element['@cds.on.update']) autogeneratedElements.push(element)
285
+ else if (element.isComposition) compositionElements.push(element)
286
+ else if (element._type === 'cds.LargeBinary') mediaDataElements.push(element)
287
+ }
288
+
289
+ // Recurse into compositions
290
+ const nextDepth = depth + 1
291
+ if (nextDepth <= MAX_RECURSION_DEPTH) {
292
+ for (const composition of compositionElements) {
293
+ const compositionTargetElement = cds.model.definitions[composition.target]
294
+
295
+ const nextDraftRef = [...draftRef, composition.name]
296
+ const nextActiveRef = [...activeRef, composition.name]
297
+
298
+ updateMediaDataQueries.push(
299
+ ...getUpdateFromSelectQueries(compositionTargetElement, nextDraftRef, nextActiveRef, nextDepth)
300
+ )
301
+ }
302
+ }
303
+
304
+ // Construct update queries for media data elements
305
+ if (mediaDataElements.length) {
306
+ const updateWith = {}
307
+ for (const element of autogeneratedElements) {
308
+ updateWith[element.name] = { ref: ['active', element.name] }
309
+ }
310
+
311
+ // Construct WHERE to match draft and active entities
312
+ const selectWhere = Object.values(target.keys).reduce((acc, key) => {
313
+ if (key.virtual || key.isAssociation) return acc
314
+ if (acc.length) acc.push('and')
315
+ acc.push({ ref: ['draft', key.name] })
316
+ acc.push('=')
317
+ acc.push({ ref: ['active', key.name] })
318
+ return acc
319
+ }, [])
320
+
321
+ for (const element of mediaDataElements) {
322
+ const activeElementRef = { ref: ['active', element.name] }
323
+ const draftElementRef = { ref: ['draft', element.name] }
324
+
325
+ // cds.ql.xpr`CASE WHEN (${draftElementRef} IS NULL OR length(${draftElementRef}) > 0) THEN ${draftElementRef} ELSE ${activeElementRef} END`
326
+ const columnExpression = {
327
+ xpr: [
328
+ 'case',
329
+ 'when',
330
+ {
331
+ xpr: [
332
+ draftElementRef,
333
+ '=',
334
+ { val: null },
335
+ 'or',
336
+ { func: 'length', args: [draftElementRef] },
337
+ '>',
338
+ { val: 0 }
339
+ ]
340
+ },
341
+ 'then',
342
+ draftElementRef,
343
+ 'else',
344
+ activeElementRef,
345
+ 'end'
346
+ ]
347
+ }
348
+ columnExpression.as = element.name
349
+
350
+ const qSelectNextMediaData = SELECT.columns([columnExpression])
351
+ .from({ ref: draftRef, as: 'draft' })
352
+ .where(selectWhere)
353
+
354
+ updateWith[element.name] = qSelectNextMediaData
355
+ }
356
+
357
+ // Construct & Collect the actual update query for this composition level
358
+ const updateMediaDataQuery = UPDATE.entity({ ref: activeRef, as: 'active' }).with(updateWith)
359
+ updateMediaDataQueries.push(updateMediaDataQuery)
360
+ }
361
+
362
+ return updateMediaDataQueries
363
+ }
364
+
282
365
  // REVISIT: Can be replaced with SQL WHEN statement (see commented code in expandStarStar) in the new HANA db layer - doesn't work with old db layer
283
366
  const _replaceStreams = result => {
284
367
  if (!result) return
@@ -299,6 +382,93 @@ const _replaceStreams = result => {
299
382
  }
300
383
  }
301
384
 
385
+ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestData, draftsRef) => {
386
+ const prefixRef = []
387
+ const simplePathElements = []
388
+ let targetEntity = cds.infer.ref([draftsRef[0]], cds.context.tx.model)
389
+
390
+ // Determine the path prefix required for a fully qualified validation message 'target'
391
+ for (let refIdx = 0; refIdx < draftsRef.length; refIdx++) {
392
+ let dRef = draftsRef[refIdx]
393
+ if (refIdx > 0) targetEntity = targetEntity.elements[draftsRef[refIdx].id || draftsRef[refIdx]]._target
394
+
395
+ const pRef = { where: [] }
396
+
397
+ const dRefId = dRef.id ?? dRef
398
+ if (dRefId === targetEntity.name) {
399
+ if (!targetEntity.isDraft) pRef.id = targetEntity.name.replace(`${targetEntity._service.name}.`, '')
400
+ else pRef.id = targetEntity.actives.name.replace(`${targetEntity.actives._service.name}.`, '')
401
+ } else pRef.id = dRefId
402
+ simplePathElements.push(pRef.id)
403
+
404
+ if (targetEntity.isDraft) targetEntity = targetEntity.actives
405
+
406
+ if (typeof dRef === 'string' || !dRef.where?.length) {
407
+ for (const k in targetEntity.keys) {
408
+ const key = targetEntity.keys[k]
409
+ let v = requestData[key.name]
410
+ if (v === undefined)
411
+ if (key.name === 'IsActiveEntity') v = false
412
+ else return null
413
+ if (pRef.where.length > 0) pRef.where.push('and')
414
+ pRef.where.push(key.name, '=', v)
415
+ }
416
+ } else {
417
+ pRef.where.push(...dRef.where, 'and', 'IsActiveEntity', '=', false)
418
+ }
419
+
420
+ prefixRef.push(pRef)
421
+ }
422
+
423
+ const nextMessages = []
424
+
425
+ // Collect messages that were created during the most recent validation run
426
+ const newMessagesByCodeAndTarget = newMessages.reduce((acc, message) => {
427
+ message.simplePathElements = [...simplePathElements]
428
+
429
+ const messagePrefixRef = [...prefixRef]
430
+ const messageTarget = message.target.startsWith('in/') ? message.target.slice(3) : message.target
431
+ const messageTargetRef = cds.odata.parse(messageTarget).SELECT.from.ref
432
+ message.target = messageTargetRef.pop()
433
+
434
+ for (const tRef of messageTargetRef) {
435
+ message.simplePathElements.push(tRef.id)
436
+ messagePrefixRef.push(tRef)
437
+ if ((tRef.where ??= []).some(w => w === 'IsActiveEntity' || w.ref?.[0] === 'IsActiveEntity')) continue
438
+ if (tRef.where.length > 0) tRef.where.push('and')
439
+ tRef.where.push('IsActiveEntity', '=', false)
440
+ }
441
+
442
+ message.prefix = cds.odata.urlify({ SELECT: { from: { ref: messagePrefixRef } } }).path
443
+ message.numericSeverity ??= 4
444
+
445
+ nextMessages.push(message)
446
+
447
+ return acc.set(`${message.message}:${message.prefix}:${message.target}`, message)
448
+ }, new Map())
449
+
450
+ const draftMessageTargetPrefix = cds.odata.urlify({ SELECT: { from: { ref: prefixRef } } }).path
451
+
452
+ // Merge new messages with persisted ones & eliminate outdated ones
453
+ for (const message of persistedMessages) {
454
+ if (newMessagesByCodeAndTarget.has(`${message.message}:${message.prefix}:${message.target}`)) continue
455
+ if (message.prefix === draftMessageTargetPrefix && requestData[message.target] !== undefined) continue
456
+ if (
457
+ message.simplePathElements.length > simplePathElements.length &&
458
+ requestData[message.simplePathElements[simplePathElements.length]] !== undefined
459
+ )
460
+ continue
461
+
462
+ nextMessages.push(message)
463
+ }
464
+
465
+ // REVISIT: Do this by default in validation?
466
+ for (const msg of nextMessages)
467
+ for (const i in msg.args) if (msg.args[i] instanceof RegExp) msg.args[i] = msg.args[i].toString()
468
+
469
+ return nextMessages
470
+ }
471
+
302
472
  // REVISIT: Can we do a regular handler function instead of monky patching?
303
473
  const h = cds.ApplicationService.prototype.handle
304
474
  const handle = async function (req) {
@@ -365,12 +535,23 @@ const handle = async function (req) {
365
535
  if (!_req._.event) _req._.event = req.event
366
536
  const cqnData = _req.query.UPDATE?.data || _req.query.INSERT?.entries?.[0]
367
537
  if (cqnData) _req.data = cqnData // must point to the same object
538
+ if (req.tx && !_req.tx) _req.tx = req.tx
539
+
540
+ // Ensure messages are added to the original request
368
541
  Object.defineProperty(_req, '_messages', {
369
542
  get: function () {
370
543
  return req._messages
371
544
  }
372
545
  })
373
- if (req.tx && !_req.tx) _req.tx = req.tx
546
+
547
+ if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
548
+ if (_req.target.isDraft && (_req.event === 'UPDATE' || _req.event === 'NEW')) {
549
+ // Degrade all errors to messages & prevent !!req.errors into req.reject() in dispatch
550
+ _req.error = (...args) => {
551
+ for (const err of args) _req._messages.add(4, err)
552
+ }
553
+ }
554
+ }
374
555
 
375
556
  return _req
376
557
  }
@@ -447,6 +628,7 @@ const handle = async function (req) {
447
628
  typeof query.INSERT.into === 'string'
448
629
  ? [req.target.drafts.name]
449
630
  : _redirectRefToDrafts([query.INSERT.into.ref[0]], this.model)
631
+
450
632
  let rootHasDraft
451
633
 
452
634
  // children: check root entity has no draft
@@ -478,6 +660,33 @@ const handle = async function (req) {
478
660
  if (req.event === 'NEW' || req.event === 'CANCEL' || req.event === 'draftPrepare') {
479
661
  if (!req.target.isDraft) req.target = req.target.drafts // COMPAT: also support these events for actives
480
662
 
663
+ if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && query.DELETE) {
664
+ const prefixRef = query.DELETE.from.ref.map(dRef => {
665
+ const pRef = { id: dRef.id, where: [...dRef.where] }
666
+ pRef.where.push('and', 'IsActiveEntity', '=', false)
667
+ return pRef
668
+ })
669
+ const messageTargetPrefix = cds.odata.urlify({ SELECT: { from: { ref: prefixRef } } }).path
670
+
671
+ const draftData = await SELECT.one
672
+ .from({ ref: _redirectRefToDrafts(query.DELETE.from.ref, this.model) }) // REVISIT: Avoid redundant redirect
673
+ .columns('DraftAdministrativeData_DraftUUID', {
674
+ ref: ['DraftAdministrativeData'],
675
+ expand: [{ ref: ['DraftMessages'] }]
676
+ })
677
+
678
+ const draftAdminDataUUID = draftData?.DraftAdministrativeData_DraftUUID
679
+ const persistedDraftMessages = draftData?.DraftAdministrativeData?.DraftMessages || []
680
+
681
+ if (draftAdminDataUUID && persistedDraftMessages?.length) {
682
+ const nextDraftMessages = persistedDraftMessages.filter(msg => !msg.prefix.startsWith(messageTargetPrefix))
683
+
684
+ await UPDATE('DRAFT.DraftAdministrativeData')
685
+ .set({ DraftMessages: nextDraftMessages })
686
+ .where({ DraftUUID: draftAdminDataUUID })
687
+ }
688
+ }
689
+
481
690
  if (query.INSERT) {
482
691
  if (typeof query.INSERT.into === 'string') query.INSERT.into = req.target.name
483
692
  else if (query.INSERT.into.ref) query.INSERT.into.ref = _redirectRefToDrafts(query.INSERT.into.ref, this.model)
@@ -490,15 +699,12 @@ const handle = async function (req) {
490
699
  const _req = _newReq(req, query, draftParams, { event: req.event })
491
700
 
492
701
  // Do not allow to create active instances via drafts
493
- if (
494
- (req.event === 'NEW' || req.event === 'CREATE') &&
495
- draftParams.IsActiveEntity === false &&
496
- !_req.target.isDraft
497
- ) {
702
+ if (req.event === 'NEW' && draftParams.IsActiveEntity === false && !_req.target.isDraft) {
498
703
  req.reject({ code: 403, statusCode: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
499
704
  }
500
705
 
501
706
  const result = await h.call(this, _req)
707
+
502
708
  req.data = result //> make keys available via req.data (as with normal crud)
503
709
  return result
504
710
  }
@@ -506,13 +712,13 @@ const handle = async function (req) {
506
712
  // Delete active instance of draft-enabled entity
507
713
  if (req.target.drafts && !req.target.isDraft && req.event === 'DELETE' && draftParams.IsActiveEntity !== false) {
508
714
  const draftsRef = _redirectRefToDrafts(query.DELETE.from.ref, this.model)
509
- const draftQuery = SELECT.one.from({ ref: draftsRef }).columns([
510
- { ref: ['DraftAdministrativeData_DraftUUID'] },
511
- {
512
- ref: ['DraftAdministrativeData'],
513
- expand: [_inProcessByUserXpr(_lock.shiftedNow)]
514
- }
515
- ])
715
+ const inProcessByUserCol = {
716
+ ref: ['DraftAdministrativeData'],
717
+ expand: [_inProcessByUserXpr(_lock.shiftedNow)]
718
+ }
719
+ const draftQuery = SELECT.one
720
+ .from({ ref: draftsRef })
721
+ .columns([{ ref: ['DraftAdministrativeData_DraftUUID'] }, inProcessByUserCol])
516
722
  if (query.DELETE.where) draftQuery.where(query.DELETE.where)
517
723
 
518
724
  // Deletion of active instance outside draft tree, no need to check for draft
@@ -523,11 +729,59 @@ const handle = async function (req) {
523
729
  }
524
730
 
525
731
  // Deletion of active instance inside draft tree, need to check that no draft exists
526
- const draft = await draftQuery
527
- const inProcessByUser = draft?.DraftAdministrativeData?.InProcessByUser
528
- if (!cds.context.user._is_privileged && inProcessByUser && inProcessByUser !== cds.context.user.id)
529
- req.reject({ code: 403, statusCode: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [inProcessByUser] })
530
- if (draft) req.reject({ code: 403, statusCode: 403, message: 'DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS' })
732
+ const drafts = []
733
+ const draftsRes = await draftQuery
734
+ if (draftsRes) drafts.push(draftsRes)
735
+
736
+ // For hierarchies, check that no sub node exists.
737
+ if (target?.elements?.LimitedDescendantCount) {
738
+ let key
739
+ for (const _key in req.target.keys) {
740
+ if (_key === 'IsActiveEntity') continue
741
+ key = _key // only single key supported
742
+ }
743
+ // We must only do this for recursive _composition_ children.
744
+ // For recursive _association_ children, app developers must deal with dangling pointers themselves.
745
+ const _recursiveComposition = target => {
746
+ for (const _comp in target.compositions) {
747
+ if (target.compositions[_comp]['@odata.draft.ignore']) {
748
+ return target.compositions[_comp]
749
+ }
750
+ }
751
+ }
752
+ const recursiveComposition = _recursiveComposition(req.target)
753
+ if (recursiveComposition) {
754
+ let uplinkName
755
+ for (const key in req.target) {
756
+ if (key.match(/@Aggregation\.RecursiveHierarchy\s*#.*\.ParentNavigationProperty/)) {
757
+ uplinkName = req.target[key]['=']
758
+ break
759
+ }
760
+ }
761
+ const keyVal = req.query.DELETE.from.ref[0].where?.[2]?.val
762
+ if (keyVal === undefined) req.reject(400, 'Deletion not supported')
763
+ // We must select actives and check for corresponding drafts (drafts themselve don't necessarily form a hierarchy)
764
+ const recursiveQ = SELECT.from(req.target).columns(key)
765
+ recursiveQ.SELECT.recurse = {
766
+ ref: [uplinkName],
767
+ where: [{ func: 'DistanceTo', args: [{ val: keyVal }, { val: null }] }]
768
+ }
769
+ const recursives = await recursiveQ
770
+ if (recursives.length) {
771
+ const recursiveDrafts = await SELECT.from(req.target.drafts)
772
+ .columns(inProcessByUserCol)
773
+ .where(Read.whereIn(req.target, recursives))
774
+ drafts.push(...recursiveDrafts)
775
+ }
776
+ }
777
+ }
778
+
779
+ for (const draft of drafts) {
780
+ const inProcessByUser = draft?.DraftAdministrativeData?.InProcessByUser
781
+ if (!cds.context.user._is_privileged && inProcessByUser && inProcessByUser !== cds.context.user.id)
782
+ req.reject({ code: 403, statusCode: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [inProcessByUser] })
783
+ else req.reject({ code: 403, statusCode: 403, message: 'DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS' })
784
+ }
531
785
  await run(query)
532
786
  return req.data
533
787
  }
@@ -535,8 +789,6 @@ const handle = async function (req) {
535
789
  if (req.event === 'draftActivate') {
536
790
  LOG.debug('activate draft')
537
791
 
538
- // It would be great if we'd have a SELECT ** to deeply expand the entity (along compositions), that should
539
- // be implemented in expand implementation.
540
792
  if (req.query.SELECT.from.ref.length > 1 || draftParams.IsActiveEntity === true) {
541
793
  req.reject({
542
794
  code: 400,
@@ -549,21 +801,29 @@ const handle = async function (req) {
549
801
  req.reject({ code: 428, statusCode: 428 })
550
802
  }
551
803
 
552
- const cols = expandStarStar(req.target.drafts, true)
804
+ const columns = expandStarStar(req.target.drafts, true)
553
805
 
554
- // Use `run` (since also etags might need to be checked)
555
- // REVISIT: Find a better approach (`etag` as part of CQN?)
556
806
  const draftRef = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
557
807
  const draftQuery = SELECT.one
558
808
  .from({ ref: draftRef })
559
- .columns(cols)
809
+ .columns(columns)
560
810
  .columns([
561
- 'HasActiveEntity',
562
- 'DraftAdministrativeData_DraftUUID',
563
- { ref: ['DraftAdministrativeData'], expand: [{ ref: ['InProcessByUser'] }] }
811
+ // Will automatically select 'IsActiveEntity' as key column
812
+ { ref: ['HasActiveEntity'] },
813
+ { ref: ['DraftAdministrativeData_DraftUUID'] },
814
+ {
815
+ ref: ['DraftAdministrativeData'],
816
+ expand: [
817
+ { ref: ['InProcessByUser'] },
818
+ ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
819
+ ? [{ ref: ['DraftMessages'] }]
820
+ : [])
821
+ ]
822
+ }
564
823
  ])
565
824
  .where(query.SELECT.where)
566
825
  const res = await run(draftQuery)
826
+
567
827
  if (!res) {
568
828
  const _etagValidationType = req.headers['if-match']
569
829
  ? 'if-match'
@@ -581,23 +841,41 @@ const handle = async function (req) {
581
841
  })
582
842
  }
583
843
 
844
+ // Remove draft artefacts from persistedDraft entry
584
845
  const DraftAdministrativeData_DraftUUID = res.DraftAdministrativeData_DraftUUID
585
846
  delete res.DraftAdministrativeData_DraftUUID
586
847
  delete res.DraftAdministrativeData
587
848
  const HasActiveEntity = res.HasActiveEntity
588
849
  delete res.HasActiveEntity
589
850
 
590
- if (_hasStreaming(draftQuery.SELECT.columns, draftQuery._target, true) && !cds.env.features.binary_draft_compat)
851
+ if (
852
+ _hasStreaming(draftQuery.SELECT.columns, draftQuery._target, true) &&
853
+ !cds.env.features.binary_draft_compat &&
854
+ !cds.env.fiori.move_media_data_in_db
855
+ ) {
591
856
  await _removeEmptyStreams(res)
857
+ }
592
858
 
593
859
  // First run the handlers as they might need access to DraftAdministrativeData or the draft entities
594
860
  const activesRef = _redirectRefToActives(query.SELECT.from.ref, this.model)
595
- const result = await run(
596
- HasActiveEntity
597
- ? UPDATE({ ref: activesRef }).data(res).where(query.SELECT.where)
598
- : INSERT.into({ ref: activesRef }).entries(res),
599
- { headers: Object.assign({}, req.headers, { 'if-match': '*' }) }
600
- )
861
+
862
+ // Upsert draft into active, not considering media data columns
863
+ const upsertQuery = HasActiveEntity
864
+ ? UPDATE({ ref: activesRef }).data(res).where(query.SELECT.where)
865
+ : INSERT.into({ ref: activesRef }).entries(res)
866
+ const upsertOptions = { headers: Object.assign({}, req.headers, { 'if-match': '*' }) }
867
+
868
+ const _req = _newReq(req, upsertQuery, draftParams, upsertOptions)
869
+ const result = await h.call(this, _req)
870
+
871
+ // REVISIT: Remove feature flag dependency
872
+ if (cds.env.fiori.move_media_data_in_db) {
873
+ // Move cds.LargeBinary data from draft to active
874
+ const updateMediaDataQueries = getUpdateFromSelectQueries(req.target, draftRef, activesRef)
875
+ await _promiseAll(updateMediaDataQueries)
876
+ }
877
+
878
+ // Delete draft artefacts
601
879
  await _promiseAll([
602
880
  DELETE.from({ ref: draftRef }).where(query.SELECT.where),
603
881
  DELETE.from('DRAFT.DraftAdministrativeData').where({ DraftUUID: DraftAdministrativeData_DraftUUID })
@@ -656,15 +934,23 @@ const handle = async function (req) {
656
934
  }
657
935
  }
658
936
  _rmIsActiveEntity(req.data, req.target)
937
+
659
938
  if (draftParams.IsActiveEntity === false) {
660
939
  LOG.debug('patch draft')
661
940
 
662
941
  if (req.target?.name.endsWith('DraftAdministrativeData')) req.reject({ code: 405, statusCode: 405 })
942
+
663
943
  const draftsRef = _redirectRefToDrafts(query.UPDATE.entity.ref, this.model)
664
944
  const res = await SELECT.one.from({ ref: draftsRef }).columns('DraftAdministrativeData_DraftUUID', {
665
945
  ref: ['DraftAdministrativeData'],
666
- expand: [{ ref: ['InProcessByUser'] }]
946
+ expand: [
947
+ { ref: ['InProcessByUser'] },
948
+ ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
949
+ ? [{ ref: ['DraftMessages'] }]
950
+ : [])
951
+ ]
667
952
  })
953
+
668
954
  if (!res) req.reject({ code: 404, statusCode: 404 })
669
955
  if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
670
956
  req.reject({
@@ -674,15 +960,31 @@ const handle = async function (req) {
674
960
  args: [res.DraftAdministrativeData?.InProcessByUser]
675
961
  })
676
962
  }
963
+
964
+ await run(UPDATE({ ref: draftsRef }).data(req.data))
965
+
966
+ const nextDraftAdminData = {
967
+ InProcessByUser: req.user.id,
968
+ LastChangedByUser: req.user.id,
969
+ LastChangeDateTime: new Date()
970
+ }
971
+
972
+ if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
973
+ nextDraftAdminData.DraftMessages = _compileUpdatedDraftMessages(
974
+ req.messages ?? [],
975
+ res.DraftAdministrativeData?.DraftMessages ?? [],
976
+ req.data ?? {},
977
+ draftsRef
978
+ )
979
+
980
+ // Prevent validation errors from being sent in 'sap-messages' header
981
+ req.messages = req._set('_messages', new Responses())
982
+ }
983
+
677
984
  await UPDATE('DRAFT.DraftAdministrativeData')
678
- .data({
679
- InProcessByUser: req.user.id,
680
- LastChangedByUser: req.user.id,
681
- LastChangeDateTime: new Date()
682
- })
985
+ .data(nextDraftAdminData)
683
986
  .where({ DraftUUID: res.DraftAdministrativeData_DraftUUID })
684
987
 
685
- await run(UPDATE({ ref: draftsRef }).data(req.data))
686
988
  req.data.IsActiveEntity = false
687
989
  return req.data
688
990
  }
@@ -877,6 +1179,16 @@ const Read = {
877
1179
  }
878
1180
  return result
879
1181
  }
1182
+
1183
+ if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
1184
+ // Replace selection of 'DraftMessages' with the proper path expression
1185
+
1186
+ query._drafts.SELECT.columns = [
1187
+ ...(query._drafts.SELECT.columns ?? ['*']).filter(c => c.ref?.[0] !== 'DraftMessages'),
1188
+ { ref: ['DraftAdministrativeData', 'DraftMessages'], as: 'DraftMessages' }
1189
+ ]
1190
+ }
1191
+
880
1192
  const draftsQuery = query._drafts.where(
881
1193
  { ref: ['DraftAdministrativeData', 'InProcessByUser'] },
882
1194
  '=',
@@ -889,7 +1201,31 @@ const Read = {
889
1201
  HasDraftEntity: false
890
1202
  })
891
1203
  _fillIsActiveEntity(row, false, query._drafts._target)
1204
+
1205
+ if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && row.DraftMessages?.length) {
1206
+ const simplifiedMessagePrefix = query._drafts.SELECT.from.ref
1207
+ .map(refElement => {
1208
+ refElement = (refElement.id ?? refElement).split('.')
1209
+ if (refElement.length === 1) return refElement[0]
1210
+ if (refElement[refElement.length - 1] === 'drafts') return refElement[refElement.length - 2]
1211
+ return refElement[refElement.length - 1]
1212
+ })
1213
+ .join('/')
1214
+
1215
+ row.DraftMessages = row.DraftMessages.reduce((acc, msg) => {
1216
+ if (!msg.simplePathElements.join('/').startsWith(simplifiedMessagePrefix)) return acc
1217
+ msg.target = `/${msg.prefix}/${msg.target}`
1218
+ delete msg.prefix
1219
+ delete msg.simplePathElements
1220
+ acc.push(msg)
1221
+ return acc
1222
+ }, [])
1223
+
1224
+ // TODO: Messsages use i18n to map codes to textual error messages -> Replace!
1225
+ row.DraftMessages = getLocalizedMessages(row.DraftMessages, cds.context.http.req)
1226
+ }
892
1227
  })
1228
+
893
1229
  return _requested(drafts, query)
894
1230
  },
895
1231
 
@@ -1229,6 +1565,7 @@ function _cleanseCols(columns, elements, target) {
1229
1565
  })
1230
1566
  }
1231
1567
 
1568
+ let DRAFT_ADMIN_ELEMENTS
1232
1569
  /**
1233
1570
  * Creates a clone of the query, cleanses and collects all draft parameters into DRAFT_PARAMS.
1234
1571
  */
@@ -1355,6 +1692,11 @@ function _cleansed(query, model) {
1355
1692
  }
1356
1693
 
1357
1694
  function _tweakAdminCols(columns) {
1695
+ if (!DRAFT_ADMIN_ELEMENTS) {
1696
+ DRAFT_ADMIN_ELEMENTS = []
1697
+ for (const key in cds.model.definitions['DRAFT.DraftAdministrativeData'].elements) DRAFT_ADMIN_ELEMENTS.push(key)
1698
+ }
1699
+
1358
1700
  if (!columns || columns.some(c => c === '*')) columns = DRAFT_ADMIN_ELEMENTS.map(k => ({ ref: [k] }))
1359
1701
  return columns.map(col => {
1360
1702
  const name = col.ref?.[0]
@@ -1444,59 +1786,40 @@ function _cleansed(query, model) {
1444
1786
  }
1445
1787
  }
1446
1788
 
1447
- // This function is better defined on DB layer
1789
+ // REVISIT: This function is better defined on DB layer
1448
1790
  function expandStarStar(target, draftActivate, recursion = new Map()) {
1449
- const MAX_RECURSION_DEPTH = (cds.env.features.recursion_depth && Number(cds.env.features.recursion_depth)) || 4
1450
1791
  const columns = []
1792
+
1451
1793
  for (const el in target.elements) {
1452
1794
  const element = target.elements[el]
1453
1795
 
1454
- // no need to read calculated elements
1796
+ // Skip calculated elements
1455
1797
  if (draftActivate && element.value) continue
1456
1798
 
1457
- // REVISIT: This does not work with old HANA db layer.
1458
- // Use it after removing old db layer.
1459
- /*
1460
- if (element._type === 'cds.LargeBinary') {
1461
- if (!draftActivate) {
1462
- columns.push({
1463
- xpr: [
1464
- 'case',
1465
- 'when',
1466
- { ref: [el] },
1467
- 'IS',
1468
- 'NULL',
1469
- 'then',
1470
- { val: null },
1471
- 'else',
1472
- { val: '' },
1473
- 'end'
1474
- ],
1475
- as: el,
1476
- // cast: 'cds.LargeBinary' <-- This should be fixed for new HANA db layer
1477
- })
1478
- continue
1479
- }
1480
- }*/
1481
-
1482
- const skip_managed = draftActivate && (element['@cds.on.insert'] || element['@cds.on.update'])
1483
- if (!skip_managed && !element.isAssociation && !DRAFT_ELEMENTS.has(el) && !element['@odata.draft.skip'])
1484
- columns.push({ ref: [el] })
1485
-
1486
- if (!element.isComposition || element._target['@odata.draft.enabled'] === false) continue // happens for texts if not @fiori.draft.enabled
1487
- const _key = target.name + ':' + el
1488
- let cache = recursion.get(_key)
1489
- if (!cache) {
1490
- cache = 1
1491
- recursion.set(_key, cache)
1492
- } else {
1493
- cache++
1494
- recursion.set(_key, cache)
1495
- }
1496
- if (cache >= MAX_RECURSION_DEPTH) return
1497
- const expand = expandStarStar(element._target, draftActivate, recursion)
1498
- if (expand) columns.push({ ref: [el], expand })
1799
+ // Make sure column is releveant & add it to output
1800
+ const isDraftInfo = DRAFT_ELEMENTS.has(el)
1801
+ const isGenerated = draftActivate && !!(element['@cds.on.insert'] || element['@cds.on.update'])
1802
+ const isLargeBinary = cds.env.fiori.move_media_data_in_db && draftActivate && element._type === 'cds.LargeBinary'
1803
+ const isSkippedType = element.isAssociation || isLargeBinary
1804
+ if (!isDraftInfo && !isGenerated && !isSkippedType && !element['@odata.draft.skip']) columns.push({ ref: [el] })
1805
+
1806
+ // Skip children where draft is explicitly disabled
1807
+ if (!element.isComposition || element._target['@odata.draft.enabled'] === false || element['@odata.draft.ignore'])
1808
+ continue // happens for texts if not @fiori.draft.enabled
1809
+
1810
+ // Make sure recursion depth is not exceeded
1811
+ const cacheKey = target.name + ':' + el
1812
+ let cache = recursion.get(cacheKey) ?? 0
1813
+ recursion.set(cacheKey, ++cache)
1814
+ if (cache >= MAX_RECURSION_DEPTH) continue
1815
+
1816
+ // Recurse
1817
+ const expandedColumns = expandStarStar(element._target, draftActivate, recursion)
1818
+
1819
+ // Unpack recursion result
1820
+ if (expandedColumns.length) columns.push({ ref: [el], expand: expandedColumns })
1499
1821
  }
1822
+
1500
1823
  return columns
1501
1824
  }
1502
1825
 
@@ -1521,7 +1844,6 @@ async function beforeNew(req) {
1521
1844
  }
1522
1845
  _cleanseData(req.data, req.target)
1523
1846
  }
1524
-
1525
1847
  beforeNew._initial = true
1526
1848
 
1527
1849
  async function onNew(req) {
@@ -1529,8 +1851,10 @@ async function onNew(req) {
1529
1851
 
1530
1852
  if (req.target.actives['@Capabilities.InsertRestrictions.Insertable'] === false || req.target.actives['@readonly'])
1531
1853
  req.reject({ code: 405, statusCode: 405 })
1854
+
1532
1855
  req.query ??= INSERT.into(req.subject).entries(req.data || {}) //> support simple srv.send('NEW',entity,...)
1533
1856
  const isDirectAccess = typeof req.query.INSERT.into === 'string' || req.query.INSERT.into.ref?.length === 1
1857
+
1534
1858
  // Only allowed for pseudo draft roots (entities with this action)
1535
1859
  if (isDirectAccess && !req.target.actives['@Common.DraftRoot.ActivationAction'])
1536
1860
  req.reject({ code: 403, statusCode: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
@@ -1538,14 +1862,25 @@ async function onNew(req) {
1538
1862
  _cleanUpOldDrafts(this, req.tenant)
1539
1863
 
1540
1864
  let DraftUUID
1865
+ let DraftMessages = []
1866
+ let newDraftMessages = req.messages ?? req._messages ?? []
1541
1867
  if (isDirectAccess) DraftUUID = cds.utils.uuid()
1542
1868
  else {
1543
1869
  const rootData = await SELECT.one(req.query.INSERT.into.ref[0].id)
1544
1870
  .columns([
1545
1871
  { ref: ['DraftAdministrativeData_DraftUUID'] },
1546
- { ref: ['DraftAdministrativeData'], expand: [{ ref: ['InProcessByUser'] }] }
1872
+ {
1873
+ ref: ['DraftAdministrativeData'],
1874
+ expand: [
1875
+ { ref: ['InProcessByUser'] },
1876
+ ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
1877
+ ? [{ ref: ['DraftMessages'] }]
1878
+ : [])
1879
+ ]
1880
+ }
1547
1881
  ])
1548
1882
  .where(req.query.INSERT.into.ref[0].where)
1883
+
1549
1884
  if (!rootData) req.reject({ code: 404, statusCode: 404 })
1550
1885
  if (!cds.context.user._is_privileged && rootData.DraftAdministrativeData?.InProcessByUser !== req.user.id)
1551
1886
  req.reject({
@@ -1554,27 +1889,10 @@ async function onNew(req) {
1554
1889
  message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
1555
1890
  args: [rootData.DraftAdministrativeData.InProcessByUser]
1556
1891
  })
1892
+
1557
1893
  DraftUUID = rootData.DraftAdministrativeData_DraftUUID
1894
+ DraftMessages = rootData.DraftAdministrativeData?.DraftMessages || []
1558
1895
  }
1559
- const timestamp = cds.context.timestamp.toISOString() // REVISIT: toISOString should be done on db layer
1560
- const adminDataCQN = isDirectAccess
1561
- ? INSERT.into('DRAFT.DraftAdministrativeData').entries({
1562
- DraftUUID,
1563
- CreationDateTime: timestamp,
1564
- CreatedByUser: req.user.id,
1565
- LastChangeDateTime: timestamp,
1566
- LastChangedByUser: req.user.id,
1567
- DraftIsCreatedByMe: true, // Dummy values
1568
- DraftIsProcessedByMe: true, // Dummy values
1569
- InProcessByUser: req.user.id
1570
- })
1571
- : UPDATE('DRAFT.DraftAdministrativeData')
1572
- .data({
1573
- InProcessByUser: req.user.id,
1574
- LastChangedByUser: req.user.id,
1575
- LastChangeDateTime: timestamp
1576
- })
1577
- .where({ DraftUUID })
1578
1896
 
1579
1897
  const _setDraftColumns = (obj, target) => {
1580
1898
  const newObj = Object.assign({}, obj, { DraftAdministrativeData_DraftUUID: DraftUUID, HasActiveEntity: false })
@@ -1594,15 +1912,49 @@ async function onNew(req) {
1594
1912
  return newObj
1595
1913
  }
1596
1914
 
1915
+ const timestamp = cds.context.timestamp.toISOString() // REVISIT: toISOString should be done on db layer
1597
1916
  const draftData = _setDraftColumns(req.query.INSERT.entries[0], req.target)
1598
-
1599
1917
  const draftCQN = INSERT.into(req.subject).entries(draftData)
1600
1918
 
1601
- await _promiseAll([adminDataCQN, this.run(draftCQN)])
1919
+ await this.run(draftCQN)
1602
1920
 
1603
- // flag to trigger read after write in legacy odata adapter
1604
- if (req.constructor.name in { ODataRequest: 1 }) req._.readAfterWrite = true
1605
- if (req.protocol?.match(/odata/)) req._.readAfterWrite = true //> REVISIT for noah
1921
+ if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
1922
+ newDraftMessages = _compileUpdatedDraftMessages(
1923
+ newDraftMessages,
1924
+ DraftMessages,
1925
+ draftData,
1926
+ req.query.INSERT.into.ref
1927
+ )
1928
+ }
1929
+
1930
+ const adminDataCQN = isDirectAccess
1931
+ ? INSERT.into('DRAFT.DraftAdministrativeData').entries({
1932
+ DraftUUID,
1933
+ CreationDateTime: timestamp,
1934
+ CreatedByUser: req.user.id,
1935
+ LastChangeDateTime: timestamp,
1936
+ LastChangedByUser: req.user.id,
1937
+ DraftIsCreatedByMe: true, // Dummy values
1938
+ DraftIsProcessedByMe: true, // Dummy values
1939
+ InProcessByUser: req.user.id,
1940
+ ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
1941
+ ? { DraftMessages: newDraftMessages }
1942
+ : {})
1943
+ })
1944
+ : UPDATE('DRAFT.DraftAdministrativeData')
1945
+ .data({
1946
+ InProcessByUser: req.user.id,
1947
+ LastChangedByUser: req.user.id,
1948
+ LastChangeDateTime: timestamp,
1949
+ ...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
1950
+ ? { DraftMessages: newDraftMessages }
1951
+ : {})
1952
+ })
1953
+ .where({ DraftUUID })
1954
+
1955
+ await adminDataCQN
1956
+
1957
+ if (req.protocol?.match(/odata/)) req._.readAfterWrite = true
1606
1958
 
1607
1959
  return { ...draftData, IsActiveEntity: false }
1608
1960
  }
@@ -1612,8 +1964,8 @@ async function onEdit(req) {
1612
1964
 
1613
1965
  req.query ??= SELECT.from(req.target, req.data).where({ IsActiveEntity: true }) //> support simple srv.send('EDIT',entity,...)
1614
1966
 
1615
- // use symbol for _draftParams
1616
- const draftParams = req.query[$draftParams] || { IsActiveEntity: true } // REVISIT: can draftParams in the edit caser ever be undefined or other than IsActiveEntity=true ?
1967
+ // REVISIT: can draftParams in the edit case ever be undefined or other than IsActiveEntity=true ?
1968
+ const draftParams = req.query[$draftParams] || { IsActiveEntity: true }
1617
1969
  if (req.query.SELECT.from.ref.length > 1 || draftParams.IsActiveEntity !== true) {
1618
1970
  req.reject({
1619
1971
  code: 400,
@@ -1650,6 +2002,7 @@ async function onEdit(req) {
1650
2002
  }
1651
2003
  }
1652
2004
  }
2005
+
1653
2006
  _addDraftColumns(req.target, cols)
1654
2007
 
1655
2008
  const draftsRef = _redirectRefToDrafts(req.query.SELECT.from.ref, this.model)
@@ -1698,6 +2051,7 @@ async function onEdit(req) {
1698
2051
  })
1699
2052
  return w
1700
2053
  })()
2054
+
1701
2055
  const activeLockCQN = SELECT.from(lockTarget, [1]).where(lockWhere).forUpdate({ wait: 0 })
1702
2056
  activeLockCQN.SELECT.localized = false
1703
2057
  activeLockCQN[$draftParams] = draftParams
@@ -1775,16 +2129,11 @@ async function onEdit(req) {
1775
2129
  // change to db run
1776
2130
  await INSERT.into(req.target.drafts).entries(res)
1777
2131
 
1778
- // REVISIT: we need to use okra API here because it must be set in the batched request
1779
- // status code must be set in handler to allow overriding for FE V2
1780
- // REVISIT: needs reworking for new adapter, especially re $batch
1781
- if (req._?.odataRes) {
1782
- req._?.odataRes?.setStatusCode(201, { overwrite: true })
1783
- } else if (req.res) {
2132
+ if (req.res) {
2133
+ // status code must be set in handler to allow overriding for FE V2
2134
+ // REVISIT: needs reworking for new adapter, especially re $batch
1784
2135
  req.res.status(201)
1785
- }
1786
2136
 
1787
- if (req.res) {
1788
2137
  const read_result = await _readAfterDraftAction.bind(this)({
1789
2138
  req,
1790
2139
  payload: res,
@@ -1804,7 +2153,6 @@ async function onCancel(req) {
1804
2153
  LOG.debug('delete draft')
1805
2154
 
1806
2155
  req.query ??= DELETE(req.target, req.data) //> support simple srv.send('CANCEL',entity,...)
1807
- const activeRef = _redirectRefToActives(req.query.DELETE.from.ref, this.model)
1808
2156
  const draftParams = req.query[$draftParams] || { IsActiveEntity: false } // REVISIT: can draftParams in the cancel case ever be undefined or other than IsActiveEntity=false ?
1809
2157
 
1810
2158
  const draftQuery = SELECT.one
@@ -1816,16 +2164,16 @@ async function onCancel(req) {
1816
2164
  if (req._etagValidationClause && draftParams.IsActiveEntity === false) draftQuery.where(req._etagValidationClause)
1817
2165
  // do not add InProcessByUser restriction
1818
2166
  const draft = await draftQuery
1819
- if (draftParams.IsActiveEntity === false && !draft) req.reject(req._etagValidationType ? 412 : 404)
2167
+ if (!draft) req.reject(req._etagValidationType ? 412 : 404)
1820
2168
  if (draft) {
1821
2169
  const processByUser = draft.DraftAdministrativeData?.InProcessByUser
1822
2170
  if (!cds.context.user._is_privileged && processByUser && processByUser !== cds.context.user.id)
1823
2171
  req.reject({ code: 403, statusCode: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [processByUser] })
1824
2172
  }
2173
+
1825
2174
  const draftDeleteQuery = DELETE.from({ ref: req.query.DELETE.from.ref }) // REVISIT: Isn't that == req.query ?
1826
- const queries = !draft
1827
- ? []
1828
- : [draftParams.IsActiveEntity === false ? this.run(draftDeleteQuery, req.data) : draftDeleteQuery]
2175
+
2176
+ const queries = !draft ? [] : [this.run(draftDeleteQuery, req.data)]
1829
2177
  if (draft && req.target['@Common.DraftRoot.ActivationAction'])
1830
2178
  // only for draft root
1831
2179
  queries.push(
@@ -1841,8 +2189,6 @@ async function onCancel(req) {
1841
2189
  })
1842
2190
  .where({ DraftUUID: draft.DraftAdministrativeData_DraftUUID })
1843
2191
  )
1844
- if (draftParams.IsActiveEntity !== false && !req.target.isDraft)
1845
- queries.push(this.run(DELETE.from({ ref: activeRef })))
1846
2192
  await _promiseAll(queries)
1847
2193
  return req.data
1848
2194
  }
@@ -1862,7 +2208,7 @@ async function onPrepare(req) {
1862
2208
 
1863
2209
  const draftQuery = SELECT.one
1864
2210
  .from(req.target, d => {
1865
- d`.*`, d.DraftAdministrativeData(a => a.InProcessByUser)
2211
+ ;(d`.*`, d.DraftAdministrativeData(a => a.InProcessByUser))
1866
2212
  })
1867
2213
  .where(where)
1868
2214
  draftQuery[$draftParams] = draftParams
@@ -1959,8 +2305,8 @@ module.exports = cds.service.impl(function () {
1959
2305
  }
1960
2306
 
1961
2307
  // Also runs those handlers if they're annotated with @odata.draft.enabled through extensibility
1962
- this.on('NEW', '*', _wrapped(onNew, false))
1963
2308
  this.prepend(s => s.before('NEW', '*', _wrapped(beforeNew, false)))
2309
+ this.on('NEW', '*', _wrapped(onNew, false))
1964
2310
  this.on('EDIT', '*', _wrapped(onEdit, true))
1965
2311
  this.on('CANCEL', '*', _wrapped(onCancel, false))
1966
2312
  this.on('draftPrepare', '*', _wrapped(onPrepare, false))