@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
1,339 lines (1,137 loc) • 87.2 kB
JavaScript
const cds = require('../cds')
const LOG = cds.log('fiori|drafts')
const { Object_keys } = cds.utils
const { Readable, PassThrough } = require('stream')
const { getPageSize, commonGenericPaging } = require('../common/generic/paging')
const { handler: commonGenericSorting } = require('../common/generic/sorting')
const { addEtagColumns } = require('../common/utils/etag')
const { handleStreamProperties } = require('../common/utils/streamProp')
const { getLocalizedMessages } = require('../../odata/middleware/error')
const { Responses } = require('../../../lib/req/response')
const location4 = require('../../http/location')
const $original = Symbol('original')
const $draftParams = Symbol('draftParams')
const AGGREGATION_FUNCTIONS = ['sum', 'min', 'max', 'avg', 'average', 'count']
const MAX_RECURSION_DEPTH = cds.env.features.recursion_depth != null ? Number(cds.env.features.recursion_depth) : 4
const _config_to_ms = (config, _default) => {
const timeout = cds.env.fiori?.[config]
let timeout_ms
if (timeout === true) {
timeout_ms = cds.utils.ms4(_default)
} else if (typeof timeout === 'string') {
timeout_ms = cds.utils.ms4(timeout)
if (!timeout_ms)
throw new Error(`
${timeout} is an invalid value for \`cds.fiori.${config}\`.
Please provide a value in format /^([0-9]+)(w|d|h|hrs|min)$/.
`)
} else {
timeout_ms = timeout
}
return timeout_ms
}
const DEL_TIMEOUT = {
get value() {
const timeout_ms = _config_to_ms('draft_deletion_timeout', '30d')
Object.defineProperty(DEL_TIMEOUT, 'value', { value: timeout_ms })
return timeout_ms
}
}
const LOCK_TIMEOUT = {
get value() {
let timeout_ms = _config_to_ms('draft_lock_timeout', '15min')
Object.defineProperty(LOCK_TIMEOUT, 'value', { value: timeout_ms })
return timeout_ms
}
}
const reject_bypassed_draft = req => {
const message =
!cds.profiles?.includes('production') &&
'`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.'
return req.reject({ code: 501, statusCode: 501, message })
}
const DRAFT_ELEMENTS = new Set([
'IsActiveEntity',
'HasDraftEntity',
'HasActiveEntity',
'DraftAdministrativeData',
'DraftAdministrativeData_DraftUUID',
'SiblingEntity'
])
const DRAFT_ELEMENTS_WITHOUT_HASACTIVE = new Set(DRAFT_ELEMENTS)
DRAFT_ELEMENTS_WITHOUT_HASACTIVE.delete('HasActiveEntity')
const REDUCED_DRAFT_ELEMENTS = new Set(['IsActiveEntity', 'HasDraftEntity', 'SiblingEntity'])
const numericCollator = { numeric: true }
const emptyObject = {}
const _isKeyValue = (i, keys, where) => {
if (!where[i].ref || !keys.includes(where[i].ref[0])) {
return false
}
return where[i + 1] === '=' && 'val' in where[i + 2]
}
const _getKeyData = (keys, where) => {
if (!where) {
return {}
}
const data = {}
let i = 0
while (where[i]) {
if (_isKeyValue(i, keys, where)) {
data[where[i].ref[0]] = where[i + 2].val
i = i + 3
} else {
i++
}
}
return data
}
const _fillIsActiveEntity = (row, IsActiveEntity, target) => {
if (target.drafts) row.IsActiveEntity = IsActiveEntity
for (const key in target.associations) {
const prop = row[key]
if (!prop) continue
const el = target.elements[key]
const childIsActiveEntity = el._target.isDraft ? IsActiveEntity : true
const propArray = Array.isArray(prop) ? prop : [prop]
propArray.forEach(r => _fillIsActiveEntity(r, childIsActiveEntity, el._target))
}
}
const _filterResultSet = (resultSet, limit, offset) => {
const pageResultSet = []
for (let i = 0; i < resultSet.length; i++) {
if (i < offset) continue
pageResultSet.push(resultSet[i])
if (pageResultSet.length === limit) break
}
return pageResultSet
}
// It's important to wait for the completion of all promises, otherwise a rollback might happen too soon
const _promiseAll = async array => {
const results = await Promise.allSettled(array)
const firstRejected = results.find(response => response.status === 'rejected')
if (firstRejected) throw firstRejected.reason
return results.map(result => result.value)
}
const _isCount = query => query.SELECT.columns?.length === 1 && query.SELECT.columns[0].func === 'count'
const _entityKeys = entity =>
Object_keys(entity.keys).filter(key => key !== 'IsActiveEntity' && !entity.keys[key].isAssociation)
const _inProcessByUserXpr = lockShiftedNow => ({
xpr: [
'case',
'when',
{ ref: ['LastChangeDateTime'] },
'<',
{ val: lockShiftedNow },
'then',
{ val: '' },
'else',
{ ref: ['InProcessByUser'] },
'end'
],
as: 'InProcessByUser',
cast: { type: 'cds.String' }
})
const _lock = {
get shiftedNow() {
return new Date(Math.max(0, Date.now() - LOCK_TIMEOUT.value)).toISOString()
}
}
const _redirectRefToDrafts = (ref, model) => {
const [root, ...tail] = ref
const target = model.definitions[root.id || root]
const draft = target.drafts || target
return [root.id ? { ...root, id: draft.name } : draft.name, ...tail]
}
const _redirectRefToActives = (ref, model) => {
const [root, ...tail] = ref
const target = model.definitions[root.id || root]
const active = target.actives || target
return [root.id ? { ...root, id: active.name } : active.name, ...tail]
}
const lastCheckMap = new Map()
const _cleanUpOldDrafts = (service, tenant) => {
if (!DEL_TIMEOUT.value) return
const expiryDate = new Date(Date.now() - DEL_TIMEOUT.value).toISOString()
const interval = DEL_TIMEOUT.value / 2
const lastCheck = lastCheckMap.get(tenant)
if (lastCheck && Date.now() - lastCheck < Number(interval)) return
cds.spawn({ tenant, user: cds.User.privileged }, async () => {
const expiredDrafts = await SELECT.from('DRAFT.DraftAdministrativeData', ['DraftUUID']).where(
`LastChangeDateTime <`,
expiryDate
)
if (!expiredDrafts.length) return
const expiredDraftsIds = expiredDrafts.map(el => el.DraftUUID)
const promises = []
const draftRoots = []
for (const name in service.model.definitions) {
const target = service.model.definitions[name]
if (target.drafts && target['@Common.DraftRoot.ActivationAction']) {
draftRoots.push(target.drafts)
}
}
const draftRootIds = await Promise.all(
draftRoots.map(draftRoot =>
SELECT.from(draftRoot, _entityKeys(draftRoot)).where(`DraftAdministrativeData_DraftUUID IN`, expiredDraftsIds)
)
)
for (let i = 0; i < draftRoots.length; i++) {
const ids = draftRootIds[i]
if (!ids.length) continue
const srv = await cds.connect.to(draftRoots[i]._service.name).catch(() => {})
if (!srv) continue // srv might not be loaded
for (const idObj of ids) {
promises.push(srv.send({ event: 'CANCEL', query: DELETE.from(draftRoots[i], idObj), data: idObj }))
}
}
await Promise.allSettled(promises)
})
lastCheckMap.set(tenant, Date.now())
}
const _hasStreaming = (cols, target, deep) => {
return cols?.some(col => {
const name = col.as || col.ref?.at(-1)
if (!target.elements[name]) return
return (
target.elements[name]._type === 'cds.LargeBinary' ||
(deep && col.expand && _hasStreaming(col.expand, target.elements[name]._target, deep))
)
})
}
const _waitForReadable = readable => {
return new Promise((resolve, reject) => {
readable.once('readable', resolve)
readable.once('error', reject)
})
}
const _removeEmptyStreams = async result => {
if (!result) return
const res = Array.isArray(result) ? result : [result]
for (let r of res) {
for (let key in r) {
const el = r[key]
if (el instanceof Readable) {
// In case hana-client Readable may not be ready
if (cds.db?.constructor?.name === 'HANAService') await _waitForReadable(el)
const chunk0 = el.read()
if (chunk0 === null) delete r[key]
else el.unshift(chunk0)
} else if (typeof el === 'object') {
const res = Array.isArray(el) ? el : [el]
for (let r of res) {
await _removeEmptyStreams(r)
}
}
}
}
}
const getUpdateFromSelectQueries = (target, draftRef, activeRef, depth = 0) => {
const updateMediaDataQueries = []
// Collect relevant elements
const compositionElements = []
const mediaDataElements = []
const autogeneratedElements = []
const targetElements = Array.isArray(target.elements) ? target.elements : Object.values(target.elements)
for (const element of targetElements) {
if (element['@cds.on.update']) autogeneratedElements.push(element)
else if (element.isComposition) compositionElements.push(element)
else if (element._type === 'cds.LargeBinary') mediaDataElements.push(element)
}
// Recurse into compositions
const nextDepth = depth + 1
if (nextDepth <= MAX_RECURSION_DEPTH) {
for (const composition of compositionElements) {
const compositionTargetElement = cds.model.definitions[composition.target]
const nextDraftRef = [...draftRef, composition.name]
const nextActiveRef = [...activeRef, composition.name]
updateMediaDataQueries.push(
...getUpdateFromSelectQueries(compositionTargetElement, nextDraftRef, nextActiveRef, nextDepth)
)
}
}
// Construct update queries for media data elements
if (mediaDataElements.length) {
const updateWith = {}
for (const element of autogeneratedElements) {
updateWith[element.name] = { ref: ['active', element.name] }
}
// Construct WHERE to match draft and active entities
const selectWhere = Object.values(target.keys).reduce((acc, key) => {
if (key.virtual || key.isAssociation) return acc
if (acc.length) acc.push('and')
acc.push({ ref: ['draft', key.name] })
acc.push('=')
acc.push({ ref: ['active', key.name] })
return acc
}, [])
for (const element of mediaDataElements) {
const activeElementRef = { ref: ['active', element.name] }
const draftElementRef = { ref: ['draft', element.name] }
// cds.ql.xpr`CASE WHEN (${draftElementRef} IS NULL OR length(${draftElementRef}) > 0) THEN ${draftElementRef} ELSE ${activeElementRef} END`
const columnExpression = {
xpr: [
'case',
'when',
{
xpr: [
draftElementRef,
'=',
{ val: null },
'or',
{ func: 'length', args: [draftElementRef] },
'>',
{ val: 0 }
]
},
'then',
draftElementRef,
'else',
activeElementRef,
'end'
]
}
columnExpression.as = element.name
const qSelectNextMediaData = SELECT.columns([columnExpression])
.from({ ref: draftRef, as: 'draft' })
.where(selectWhere)
updateWith[element.name] = qSelectNextMediaData
}
// Construct & Collect the actual update query for this composition level
const updateMediaDataQuery = UPDATE.entity({ ref: activeRef, as: 'active' }).with(updateWith)
updateMediaDataQueries.push(updateMediaDataQuery)
}
return updateMediaDataQueries
}
// 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
const _replaceStreams = result => {
if (!result) return
const res = Array.isArray(result) ? result : [result]
for (let r of res) {
for (let key in r) {
const el = r[key]
if (el instanceof Readable) {
const stream = new Readable()
stream.push(null)
r[key] = stream
} else if (typeof el === 'object') {
const res = Array.isArray(el) ? el : [el]
res.forEach(_replaceStreams)
}
}
}
}
const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestData, draftsRef) => {
const prefixRef = []
// Determine the path prefix required for a fully qualified validation message 'target'
let targetEntity = cds.context.tx.model.definitions[draftsRef[0].id || draftsRef[0]]
for (let refIdx = 0; refIdx < draftsRef.length; refIdx++) {
const dRef = draftsRef[refIdx],
dRefId = dRef.id ?? dRef
// Determine entity, referenced by the processesd segment of 'draftsRef'
if (refIdx > 0) targetEntity = targetEntity.elements[dRefId]._target
// Construct 'prefixRef' segment
const pRef = { where: [] }
if (dRefId === targetEntity.name) {
if (!targetEntity.isDraft) pRef.id = targetEntity.name.replace(`${targetEntity._service.name}.`, '')
else pRef.id = targetEntity.actives.name.replace(`${targetEntity.actives._service.name}.`, '')
} else pRef.id = dRefId
if (targetEntity.isDraft) targetEntity = targetEntity.actives
if (typeof dRef === 'string' || !dRef.where?.length) {
// In case of CREATE: The WHERE for the created entity must be constructed for use in 'prefixRef'
for (const k in targetEntity.keys) {
const key = targetEntity.keys[k]
let v = requestData[key.name]
if (v === undefined)
if (key.name === 'IsActiveEntity') v = false
else return null
if (pRef.where.length > 0) pRef.where.push('and')
pRef.where.push(key.name, '=', v)
}
} else {
// 'dRef.where' regards the draft and will never contain 'IsActiveEntity=false'
pRef.where.push(...dRef.where, 'and', 'IsActiveEntity', '=', false)
}
prefixRef.push(pRef)
}
const nextMessages = []
// Collect messages that were created during the most recent validation run
const newMessagesByCodeAndTarget = newMessages.reduce((acc, message) => {
message.numericSeverity ??= 4
// Handle validation messages produced during draftActivate, that went through error normalization already
// > We must not store pre-localized data in DraftAdministrativeData.DraftMessages
const messageTarget = message.target.startsWith('in/') ? message.target.slice(3) : message.target
if (message.code) {
message.message = message.code
delete message.code
}
// Process the message target produced by validation
// > The message target contains the relative path of the erroneous entity
// > For a message specific 'prefixRef', this info must be added to the entity 'prefixRef'
const messagePrefixRef = [...prefixRef]
const messageTargetRef = cds.odata.parse(messageTarget).SELECT.from.ref
message.target = messageTargetRef.pop()
for (const tRef of messageTargetRef) {
messagePrefixRef.push(tRef)
if ((tRef.where ??= []).some(w => w === 'IsActiveEntity' || w.ref?.[0] === 'IsActiveEntity')) continue
if (tRef.where.length > 0) tRef.where.push('and', 'IsActiveEntity', '=', false)
}
message.prefix = cds.odata.urlify({ SELECT: { from: { ref: messagePrefixRef } } }).path
nextMessages.push(message)
return acc.set(`${message.message}:${message.prefix}:${message.target}`, message)
}, new Map())
const draftMessageTargetPrefix = cds.odata.urlify({ SELECT: { from: { ref: prefixRef } } }).path
// Merge new messages with persisted ones & eliminate outdated ones
const simplePathElements = prefixRef.map(pRef => pRef.id)
for (const message of persistedMessages) {
// Drop persisted draft messages that are replaced by new ones
if (newMessagesByCodeAndTarget.has(`${message.message}:${message.prefix}:${message.target}`)) continue
// Drop persisted draft messages where the value of the target field changed without a new error
if (message.prefix === draftMessageTargetPrefix && requestData[message.target] !== undefined) continue
// Drop persisted draft messages, whose target's use navigations, where the value of the target field changed without a new error
const messageSimplePathElements = message.prefix.replaceAll(/\([^(]*\)/g, '').split('/')
if (messageSimplePathElements.length > simplePathElements.length)
if (requestData[messageSimplePathElements[simplePathElements.length]] !== undefined) continue
nextMessages.push(message)
}
// REVISIT: Do this by default in validation?
for (const msg of nextMessages)
for (const i in msg.args) if (msg.args[i] instanceof RegExp) msg.args[i] = msg.args[i].toString()
return nextMessages
}
// REVISIT: Can we do a regular handler function instead of monky patching?
const h = cds.ApplicationService.prototype.handle
const handle = async function (req) {
if (req.event === 'DISCARD') req.event = 'CANCEL'
else if (req.event === 'SAVE') {
req.event = 'draftActivate'
req.query ??= SELECT.from(req.target, req.data) //> support simple srv.send('SAVE',entity,...)
}
// Fast exit for non-draft requests
// REVISIT: should also start with else, but then this test fails: cds/tests/_runtime/odata/__tests__/integration/draft-custom-handlers.test.js
if (!req.query) return h.call(this, req)
else if ($draftParams in req.query) return h.call(this, req)
/* prettier-ignore */ else if (!(
// Note: we skip UPSERTs as these might have an additional INSERT
'SELECT' in req.query ||
'INSERT' in req.query ||
'UPDATE' in req.query ||
'DELETE' in req.query
)) return h.call(this, req)
// TODO: also skip quickly if no draft-enabled entities are involved ?!?
// TODO: also skip quickly if no isActiveEntity is part of the query ?!?
// TODO: also skip quickly for CREATE request not from Fiori clients ???
// rewrite event if necessary
if (req.protocol && req.target.drafts && req.event in { CREATE: 1, DELETE: 1 }) {
if (req.event === 'CREATE' && req.data.IsActiveEntity !== true) req.event = 'NEW'
if (req.event === 'DELETE' && req.data.IsActiveEntity === false) req.event = 'CANCEL'
}
const query = _cleansed(req.query, this.model)
_cleanseParams(req.params, req.target)
if (req.data) _cleanseParams(req.data, req.target)
const draftParams = query[$draftParams]
const _newReq = (req, query, draftParams, { event, headers }) => {
// REVISIT: This is a bit hacky -> better way?
query._target = undefined
query[$draftParams] = draftParams
// REVISIT: This is extremely bad. We should be able to just create a copy without such hacks.
const _req = cds.Request.for(req._) // REVISIT: this causes req._.data of WRITE reqs copied to READ reqs
if (headers) {
_req.headers = Object.create(req.headers)
Object.assign(_req.headers, headers)
}
// If we create a `READ` event based on a modifying request, we delete data
if (event === 'READ' && req.event !== 'READ') delete _req.data // which we fix here -> but this is an ugly workaround
_req.target = cds.infer.target(query)
_req.query = query
_req.event =
event ||
(query.SELECT && 'READ') ||
(query.INSERT && 'CREATE') ||
(query.UPDATE && 'UPDATE') ||
(query.DELETE && 'DELETE') ||
req.event
_req.params = req.params
if (req.protocol) _req.protocol = req.protocol
_req._ = req._
if (!_req._.event) _req._.event = req.event
const cqnData = _req.query.UPDATE?.data || _req.query.INSERT?.entries?.[0]
if (cqnData) _req.data = cqnData // must point to the same object
if (req.tx && !_req.tx) _req.tx = req.tx
// Ensure messages are added to the original request
Object.defineProperty(_req, '_messages', {
get: function () {
return req._messages
}
})
if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
if (_req.target.isDraft && (_req.event === 'UPDATE' || _req.event === 'NEW')) {
// Degrade all errors to messages & prevent !!req.errors into req.reject() in dispatch
_req.error = (...args) => {
for (const err of args) _req._messages.add(4, err)
}
}
}
return _req
}
const run = (query, options = {}) => {
const _req = _newReq(req, query, draftParams, options)
return h.call(this, _req)
}
if (req.event === 'READ') {
if (
!Object.keys(draftParams).length &&
!req.query._target.name?.endsWith('DraftAdministrativeData') &&
!req.query._target.drafts
) {
req.query = query
return h.call(this, req)
}
// apply paging and sorting on original query for protocol adapters relying on it
commonGenericPaging(req)
commonGenericSorting(req)
const read =
draftParams.IsActiveEntity === false &&
_hasStreaming(query.SELECT.columns, query._target) &&
!cds.env.features.binary_draft_compat
? Read.draftStream
: req.query._target.name.endsWith('.drafts')
? Read.ownDrafts
: draftParams.IsActiveEntity === false && draftParams.SiblingEntity_IsActiveEntity === null
? Read.all
: draftParams.IsActiveEntity === true &&
draftParams.SiblingEntity_IsActiveEntity === null &&
(draftParams.DraftAdministrativeData_InProcessByUser === 'not null' ||
draftParams.DraftAdministrativeData_InProcessByUser === 'not ')
? Read.lockedByAnotherUser
: draftParams.IsActiveEntity === true &&
draftParams.SiblingEntity_IsActiveEntity === null &&
draftParams.DraftAdministrativeData_InProcessByUser === ''
? Read.unsavedChangesByAnotherUser
: draftParams.IsActiveEntity === true && draftParams.HasDraftEntity === false
? Read.unchanged
: draftParams.IsActiveEntity === true
? Read.onlyActives
: draftParams.IsActiveEntity === false
? Read.ownDrafts
: Read.onlyActives
const result = await read(run, query)
return result
}
if (req.event === 'draftEdit') req.event = 'EDIT'
if (req.event === 'draftPrepare' && draftParams.IsActiveEntity) req.reject({ code: 400, statusCode: 400 })
// Create active instance of draft-enabled entity
// Careful: New OData adapter only sets `NEW` for drafts... how to distinguish programmatic modifications?
if (
(req.event === 'NEW' && req.data.IsActiveEntity === true) || // old OData adapter changes CREATE to NEW also for actives
(req.event === 'CREATE' && req.target.drafts && req.data?.IsActiveEntity !== false && !req.target.isDraft)
) {
if (req.protocol === 'odata' && !cds.env.fiori.bypass_draft && !req.target['@odata.draft.bypass'])
return reject_bypassed_draft(req)
const containsDraftRoot =
this.model.definitions[query.INSERT.into?.ref?.[0]?.id || query.INSERT.into?.ref?.[0] || query.INSERT.into][
'@Common.DraftRoot.ActivationAction'
]
if (!containsDraftRoot) req.reject({ code: 403, statusCode: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
const isDirectAccess = typeof req.query.INSERT.into === 'string' || req.query.INSERT.into.ref?.length === 1
const data = Array.isArray(req.data) ? [...req.data] : Object.assign({}, req.data) // IsActiveEntity is not enumerable
const draftsRootRef =
typeof query.INSERT.into === 'string'
? [req.target.drafts.name]
: _redirectRefToDrafts([query.INSERT.into.ref[0]], this.model)
let rootHasDraft
// children: check root entity has no draft
if (!isDirectAccess) {
rootHasDraft = await SELECT.one([1]).from({ ref: draftsRootRef })
}
// direct access and req.data contains keys: check if root has no draft with that keys
if (isDirectAccess && _entityKeys(query._target).every(k => k in data)) {
const keyData = _entityKeys(query._target).reduce((res, k) => {
res[k] = req.data[k]
return res
}, {})
rootHasDraft = await SELECT.one([1]).from({ ref: draftsRootRef }).where(keyData)
}
if (rootHasDraft) req.reject({ code: 409, statusCode: 409, message: 'DRAFT_ALREADY_EXISTS' })
const cqn = INSERT.into(query.INSERT.into).entries(data)
await run(cqn, { event: 'CREATE' })
const result = Array.isArray(data)
? data.map(d => ({ ...d, IsActiveEntity: true }))
: { ...data, IsActiveEntity: true }
req.data = result //> make keys available via req.data (as with normal crud)
return result
}
// It needs to be redirected to drafts
if (req.event === 'NEW' || req.event === 'CANCEL' || req.event === 'draftPrepare') {
if (!req.target.isDraft) req.target = req.target.drafts // COMPAT: also support these events for actives
if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && query.DELETE) {
const prefixRef = query.DELETE.from.ref.map(dRef => {
const pRef = { id: dRef.id, where: [...dRef.where] }
pRef.where.push('and', 'IsActiveEntity', '=', false)
return pRef
})
const messageTargetPrefix = cds.odata.urlify({ SELECT: { from: { ref: prefixRef } } }).path
const draftData = await SELECT.one
.from({ ref: _redirectRefToDrafts(query.DELETE.from.ref, this.model) }) // REVISIT: Avoid redundant redirect
.columns('DraftAdministrativeData_DraftUUID', {
ref: ['DraftAdministrativeData'],
expand: [{ ref: ['DraftMessages'] }]
})
const draftAdminDataUUID = draftData?.DraftAdministrativeData_DraftUUID
const persistedDraftMessages = draftData?.DraftAdministrativeData?.DraftMessages || []
if (draftAdminDataUUID && persistedDraftMessages?.length) {
const nextDraftMessages = persistedDraftMessages.filter(msg => !msg.prefix.startsWith(messageTargetPrefix))
await UPDATE('DRAFT.DraftAdministrativeData')
.set({ DraftMessages: nextDraftMessages })
.where({ DraftUUID: draftAdminDataUUID })
}
}
if (query.INSERT) {
if (typeof query.INSERT.into === 'string') query.INSERT.into = req.target.name
else if (query.INSERT.into.ref) query.INSERT.into.ref = _redirectRefToDrafts(query.INSERT.into.ref, this.model)
} else if (query.DELETE) {
query.DELETE.from.ref = _redirectRefToDrafts(query.DELETE.from.ref, this.model)
} else if (query.SELECT) {
query.SELECT.from.ref = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
}
const _req = _newReq(req, query, draftParams, { event: req.event })
// Do not allow to create active instances via drafts
if (req.event === 'NEW' && draftParams.IsActiveEntity === false && !_req.target.isDraft) {
req.reject({ code: 403, statusCode: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
}
const result = await h.call(this, _req)
req.data = result //> make keys available via req.data (as with normal crud)
return result
}
// Delete active instance of draft-enabled entity
if (req.target.drafts && !req.target.isDraft && req.event === 'DELETE' && draftParams.IsActiveEntity !== false) {
const draftsRef = _redirectRefToDrafts(query.DELETE.from.ref, this.model)
const inProcessByUserCol = {
ref: ['DraftAdministrativeData'],
expand: [_inProcessByUserXpr(_lock.shiftedNow)]
}
const draftQuery = SELECT.one
.from({ ref: draftsRef })
.columns([{ ref: ['DraftAdministrativeData_DraftUUID'] }, inProcessByUserCol])
if (query.DELETE.where) draftQuery.where(query.DELETE.where)
// Deletion of active instance outside draft tree, no need to check for draft
const target = cds.infer.target(draftQuery) // FIXME: this should not be neccessary, does it?
if (!target?.isDraft) {
await run(query)
return req.data
}
// Deletion of active instance inside draft tree, need to check that no draft exists
const drafts = []
const draftsRes = await draftQuery
if (draftsRes) drafts.push(draftsRes)
// For hierarchies, check that no sub node exists.
if (target?.elements?.LimitedDescendantCount) {
let key
for (const _key in req.target.keys) {
if (_key === 'IsActiveEntity') continue
key = _key // only single key supported
}
// We must only do this for recursive _composition_ children.
// For recursive _association_ children, app developers must deal with dangling pointers themselves.
const _recursiveComposition = target => {
for (const _comp in target.compositions) {
if (target.compositions[_comp]['@odata.draft.ignore']) {
return target.compositions[_comp]
}
}
}
const recursiveComposition = _recursiveComposition(req.target)
if (recursiveComposition) {
let uplinkName
for (const key in req.target) {
if (key.match(/@Aggregation\.RecursiveHierarchy\s*#.*\.ParentNavigationProperty/)) {
uplinkName = req.target[key]['=']
break
}
}
const keyVal = req.query.DELETE.from.ref[0].where?.[2]?.val
if (keyVal === undefined) req.reject(400, 'Deletion not supported')
// We must select actives and check for corresponding drafts (drafts themselve don't necessarily form a hierarchy)
const recursiveQ = SELECT.from(req.target).columns(key)
recursiveQ.SELECT.recurse = {
ref: [uplinkName],
where: [{ func: 'DistanceTo', args: [{ val: keyVal }, { val: null }] }]
}
const recursives = await recursiveQ
if (recursives.length) {
const recursiveDrafts = await SELECT.from(req.target.drafts)
.columns(inProcessByUserCol)
.where(Read.whereIn(req.target, recursives))
drafts.push(...recursiveDrafts)
}
}
}
for (const draft of drafts) {
const inProcessByUser = draft?.DraftAdministrativeData?.InProcessByUser
if (!cds.context.user._is_privileged && inProcessByUser && inProcessByUser !== cds.context.user.id)
req.reject({ code: 403, statusCode: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [inProcessByUser] })
else req.reject({ code: 403, statusCode: 403, message: 'DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS' })
}
await run(query)
return req.data
}
if (req.event === 'draftActivate') {
LOG.debug('activate draft')
if (req.query.SELECT.from.ref.length > 1 || draftParams.IsActiveEntity === true) {
req.reject({
code: 400,
statusCode: 400,
message: 'Action "draftActivate" can only be called on the root draft entity'
})
}
if (req.target._etag && !req.headers['if-match'] && !req.headers['if-none-match']) {
req.reject({ code: 428, statusCode: 428 })
}
const columns = expandStarStar(req.target.drafts, true)
const draftRef = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
const draftQuery = SELECT.one
.from({ ref: draftRef })
.columns(columns)
.columns([
// Will automatically select 'IsActiveEntity' as key column
{ ref: ['HasActiveEntity'] },
{ ref: ['DraftAdministrativeData_DraftUUID'] },
{
ref: ['DraftAdministrativeData'],
expand: [
{ ref: ['InProcessByUser'] },
...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
? [{ ref: ['DraftMessages'] }]
: [])
]
}
])
.where(query.SELECT.where)
const res = await run(draftQuery)
if (!res) {
const _etagValidationType = req.headers['if-match']
? 'if-match'
: req.headers['if-none-match']
? 'if-none-match'
: undefined
req.reject(_etagValidationType ? { code: 412, statusCode: 412 } : { code: 'DRAFT_NOT_EXISTING', statusCode: 404 })
}
if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
req.reject({
code: 403,
statusCode: 403,
message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
args: [res.DraftAdministrativeData?.InProcessByUser]
})
}
// Remove draft artefacts from persistedDraft entry
const DraftAdministrativeData_DraftUUID = res.DraftAdministrativeData_DraftUUID
const persistedDraftMessages = res.DraftAdministrativeData?.DraftMessages || []
delete res.DraftAdministrativeData_DraftUUID
delete res.DraftAdministrativeData
const HasActiveEntity = res.HasActiveEntity
delete res.HasActiveEntity
if (
_hasStreaming(draftQuery.SELECT.columns, draftQuery._target, true) &&
!cds.env.features.binary_draft_compat &&
!cds.env.fiori.move_media_data_in_db
) {
await _removeEmptyStreams(res)
}
// First run the handlers as they might need access to DraftAdministrativeData or the draft entities
const activesRef = _redirectRefToActives(query.SELECT.from.ref, this.model)
// Upsert draft into active, not considering media data columns
const upsertQuery = HasActiveEntity
? UPDATE({ ref: activesRef }).data(res).where(query.SELECT.where)
: INSERT.into({ ref: activesRef }).entries(res)
const upsertOptions = { headers: Object.assign({}, req.headers, { 'if-match': '*' }) }
const _req = _newReq(req, upsertQuery, draftParams, upsertOptions)
let result
try {
result = await h.call(this, _req)
} catch (error) {
if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && _req.errors)
_req.on('failed', async () => {
const nextDraftMessages = _compileUpdatedDraftMessages(
_req.errors.map(e => ({ ...e })),
persistedDraftMessages,
{},
draftRef
)
await cds.tx(async () => {
await UPDATE('DRAFT.DraftAdministrativeData')
.set({ DraftMessages: nextDraftMessages })
.where({ DraftUUID: DraftAdministrativeData_DraftUUID })
})
})
throw error
}
// REVISIT: Remove feature flag dependency
if (cds.env.fiori.move_media_data_in_db) {
// Move cds.LargeBinary data from draft to active
const updateMediaDataQueries = getUpdateFromSelectQueries(req.target, draftRef, activesRef)
await _promiseAll(updateMediaDataQueries)
}
// Delete draft artefacts
await _promiseAll([
DELETE.from({ ref: draftRef }).where(query.SELECT.where),
DELETE.from('DRAFT.DraftAdministrativeData').where({ DraftUUID: DraftAdministrativeData_DraftUUID })
])
if (req.res) {
// status code must be set in handler to allow overriding for FE V2
// REVISIT: needs reworking for new adapter, especially re $batch
if (!HasActiveEntity) req.res.status(201)
const read_result = await _readAfterDraftAction.bind(this)({
req,
payload: res,
action: 'draftActivate'
})
req.res.set('location', '../' + location4(req.target, this, read_result || { ...res, IsActiveEntity: true }))
if (read_result == null) req.res.status(204)
return read_result
}
return Object.assign(result, { IsActiveEntity: true })
}
if (req.target.actions?.[req.event] && draftParams.IsActiveEntity === false) {
if (query.SELECT?.from?.ref) query.SELECT.from.ref = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
const rootQuery = query.clone()
rootQuery.SELECT.columns = [{ ref: ['DraftAdministrativeData'], expand: [{ ref: ['InProcessByUser'] }] }]
rootQuery.SELECT.one = true
rootQuery.SELECT.from = { ref: [query.SELECT.from.ref[0]] }
const root = await cds.run(rootQuery)
if (!root) req.reject({ code: 404, statusCode: 404 })
if (root.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
req.reject({ code: 403, statusCode: 403 })
}
const _req = _newReq(req, query, draftParams, { event: req.event })
const result = await h.call(this, _req)
return result
}
if (req.event === 'PATCH' || (req.event === 'UPDATE' && req.target.drafts)) {
// also delete `IsActiveEntity` for references
const _rmIsActiveEntity = (data, target) => {
delete data.IsActiveEntity
for (const assoc in target.associations) {
const val = data[assoc]
if (val && typeof val === 'object') {
const _target = req.target.associations[assoc]._target
if (Array.isArray(val)) {
val.forEach(v => _rmIsActiveEntity(v, _target))
} else {
_rmIsActiveEntity(val, _target)
}
}
}
}
_rmIsActiveEntity(req.data, req.target)
if (draftParams.IsActiveEntity === false) {
LOG.debug('patch draft')
if (req.target?.name.endsWith('DraftAdministrativeData')) req.reject({ code: 405, statusCode: 405 })
const draftsRef = _redirectRefToDrafts(query.UPDATE.entity.ref, this.model)
const res = await SELECT.one.from({ ref: draftsRef }).columns('DraftAdministrativeData_DraftUUID', {
ref: ['DraftAdministrativeData'],
expand: [
{ ref: ['InProcessByUser'] },
...(cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages
? [{ ref: ['DraftMessages'] }]
: [])
]
})
if (!res) req.reject({ code: 404, statusCode: 404 })
if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
req.reject({
code: 403,
statusCode: 403,
message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
args: [res.DraftAdministrativeData?.InProcessByUser]
})
}
await run(UPDATE({ ref: draftsRef }).data(req.data))
const nextDraftAdminData = {
InProcessByUser: req.user.id,
LastChangedByUser: req.user.id,
LastChangeDateTime: new Date()
}
if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
nextDraftAdminData.DraftMessages = _compileUpdatedDraftMessages(
req.messages ?? [],
res.DraftAdministrativeData?.DraftMessages ?? [],
req.data ?? {},
draftsRef
)
// Prevent validation errors from being sent in 'sap-messages' header
req.messages = req._set('_messages', new Responses())
}
await UPDATE('DRAFT.DraftAdministrativeData')
.data(nextDraftAdminData)
.where({ DraftUUID: res.DraftAdministrativeData_DraftUUID })
req.data.IsActiveEntity = false
return req.data
}
LOG.debug('patch active')
if (req.protocol === 'odata' && !cds.env.fiori.bypass_draft && !req.target['@odata.draft.bypass'])
return reject_bypassed_draft(req)
const entityRef = query.UPDATE.entity.ref
if (!this.model.definitions[entityRef[0].id || entityRef[0]]['@Common.DraftRoot.ActivationAction']) {
req.reject({ code: 403, statusCode: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
}
const draftsRef = _redirectRefToDrafts(entityRef, this.model)
const draftsQuery = SELECT.one([1]).from({ ref: [draftsRef[0]] })
if (query.UPDATE.where) draftsQuery.where(query.UPDATE.where)
const hasDraft = !!(await draftsQuery)
if (hasDraft) req.reject({ code: 409, statusCode: 409, message: 'DRAFT_ALREADY_EXISTS' })
await run(query)
return req.data
}
if (req.event === 'CREATE' && draftParams.IsActiveEntity === false && !req.target.isDraft) {
req.reject({ code: 403, statusCode: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
}
req.query = query
return h.call(this, req)
}
// REVISIT: It's not optimal to first calculate the whole result array and only later
// delete unrequested properties. However, as a first step, we do it that way,
// especially since the current db driver always adds those fields.
// Once we switch to the new driver, we'll adapt it.
const _requested = (result, query) => {
const originalQuery = query[$original]
if (!result || !originalQuery) return result
const all = ['HasActiveEntity', 'HasDraftEntity']
const ignoredCols = new Set(all.concat('DraftAdministrativeData'))
const _isODataV2 = cds.context?.http?.req?.headers?.['x-cds-odata-version'] === 'v2'
if (!_isODataV2) ignoredCols.add('DraftAdministrativeData_DraftUUID')
for (const col of originalQuery.SELECT.columns || ['*']) {
const name = col.as || col.ref?.[0] || col
if (all.includes(name) || name === 'DraftAdministrativeData' || name === 'DraftAdministrativeData_DraftUUID')
ignoredCols.delete(name)
if (name === '*') all.forEach(c => ignoredCols.delete(c))
}
if (!ignoredCols.size) return result
const resArray = Array.isArray(result) ? result : [result]
for (const row of resArray) {
for (const ignoredCol of ignoredCols) delete row[ignoredCol]
}
return result
}
const _readDraftStream = (draftStream, activeCQN, property) =>
Readable.from(
(async function* () {
let isActive = true
this._stream = draftStream
for await (const chunk of draftStream) {
isActive = false
yield chunk
}
if (isActive) {
const active = (await activeCQN)?.[property]
if (active) {
for await (const chunk of active) {
yield chunk
}
}
}
})()
)
// REVISIT: HanaLobStream of @sap/hana-client cannot read chunks with "for await" - hangs
const _readDraftStreamHanaClient = async (draftStream, activeCQN, property) =>
Readable.from(
(async function* () {
let isActive = true
const pth = new PassThrough()
draftStream.pipe(pth)
for await (const chunk of pth) {
isActive = false
yield chunk
}
if (isActive) {
const active = (await activeCQN)?.[property]
if (active) {
const pth = new PassThrough()
active.pipe(pth)
for await (const chunk of pth) {
yield chunk
}
}
}
})()
)
const Read = {
onlyActives: async function (run, query, { ignoreDrafts } = {}) {
LOG.debug('List Editing Status: Only Active')
// DraftAdministrativeData is only accessible via drafts
if (_isCount(query)) return run(query)
if (query._target.name.endsWith('.DraftAdministrativeData')) {
if (query.SELECT.from.ref?.length === 1) throw cds.error('INVALID_DRAFT_REQUEST', { statusCode: 400 }) // only via drafts
return run(query._drafts)
}
if (!query._target._isDraftEnabled) return run(query)
if (
!query.SELECT.groupBy &&
query.SELECT.columns &&
!query.SELECT.columns.some(c => c === '*') &&
!query.SELECT.columns.some(c => c.func && AGGREGATION_FUNCTIONS.includes(c.func))
) {
const keys = _entityKeys(query._target)
for (const key of keys) {
if (!query.SELECT.columns.some(c => c.ref?.[0] === key)) query.SELECT.columns.push({ ref: [key] })
}
}
const actives = await run(query)
if (!actives || (Array.isArray(actives) && !actives.length) || !query._target.drafts) return actives
let drafts
if (ignoreDrafts) drafts = []
else {
try {
drafts = await Read.complementaryDrafts(query, actives)
} catch {
drafts = []
}
}
Read.merge(query._target, actives, drafts, (row, other) => {
if (other) {
if ('DraftAdministrativeData' in other) row.DraftAdministrativeData = other.DraftAdministrativeData
if ('DraftAdministrativeData_DraftUUID' in other)
row.DraftAdministrativeData_DraftUUID = other.DraftAdministrativeData_DraftUUID
Object.assign(row, { HasActiveEntity: false, HasDraftEntity: true })
} else
Object.assign(row, {
HasActiveEntity: false,
HasDraftEntity: false,
DraftAdministrativeData: null,
DraftAdministrativeData_DraftUUID: null
})
_fillIsActiveEntity(row, true, query._target)
})
return _requested(actives, query)
},
unchanged: async function (run, query) {
LOG.debug('List Editing Status: Unchanged')
const draftsQuery = query._drafts
if (!draftsQuery) throw cds.error('INVALID_DRAFT_REQUEST', { statusCode: 400 }) // only via drafts
draftsQuery.SELECT.count = undefined
draftsQuery.SELECT.orderBy = undefined
draftsQuery.SELECT.limit = null
draftsQuery.SELECT.columns = _entityKeys(query._target).map(k => ({ ref: [k] }))
const drafts = await draftsQuery.where({ HasActiveEntity: true })
const res = await Read.onlyActives(run, query.where(Read.whereNotIn(query._target, drafts)), {
ignoreDrafts: true
})
return _requested(res, query)
},
ownDrafts: async function (run, query) {
LOG.debug('List Editing Status: Own Draft')
if (!query._drafts) throw cds.error('INVALID_DRAFT_REQUEST', { statusCode: 400 }) // only via drafts
// read active from draft
if (!query._drafts._target?.name.endsWith('.drafts')) {
const result = await run(query._drafts)
// active entity is draft enabled, draft columns have to be removed
if (query._drafts._target?.drafts) {
Read.merge(query._drafts._target, result, [], row => {
delete row.IsActiveEntity
delete row.HasDraftEntity
delete row.HasActiveEntity
delete row.DraftAdministrativeData_DraftUUID
})
}
return result
}
if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
// Replace selection of 'DraftMessages' with the proper path expression
query._drafts.SELECT.columns = [
...(query._drafts.SELECT.columns ?? ['*']).filter(c => c.ref?.[0] !== 'DraftMessages'),
{ ref: ['DraftAdministrativeData', 'DraftMessages'], as: 'DraftMessages' }
]
}
const draftsQuery = query._drafts.where(
{ ref: ['DraftAdministrativeData', 'InProcessByUser'] },
'=',
cds.context.user.id
)
const drafts = await run(draftsQuery)
Read.merge(query._target, drafts, [], row => {
Object.assign(row, {
HasDraftEntity: false
})
_fillIsActiveEntity(row, false, query._drafts._target)
if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && row.DraftMessages?.length) {
// Reduce persisted draft messages to the set of those that are part of the queried entities tree
const simplePath = query._drafts.SELECT.from.ref
.map(refElement => {
refElement = (refElement.id ?? refElement).split('.')
if (refElement.length === 1) return refElement[0]
if (refElement[refElement.length - 1] === 'drafts') return refElement[refElement.length - 2]
return refElement[refElement.length - 1]
})
.join('/')
row.DraftMessages = row.DraftMessages.reduce((acc, msg) => {
const messageSimplePath = msg.prefix.replaceAll(/\([^(]*\)/g, '')
if (!messageSimplePath.startsWith(simplePath)) return acc
msg.target = `/${msg.prefix}/${msg.target}`
delete msg.prefix
delete msg.simplePathElements
acc.push(msg)
return acc
}, [])
row.DraftMessages = getLocalizedMessages(row.DraftMessages, cds.context.http.req)
}
})
return _requested(drafts, query)
},
all: async function (run, query) {
LOG.debug('List Editing Status: All')
if (!query._drafts) return []
query._drafts.SELECT.count = false
query._drafts.SELECT.limit = null // we need all entries for the keys to properly select actives (count)
const isCount = _isCount(query._drafts)
if (isCount) {
query._drafts.SELECT.columns = _entityKeys(query._target).map(k => ({ ref: [k] }))
}
if (!query._drafts.SELECT.columns) query._drafts.SELECT.columns = ['*']
if (!query._drafts.SELECT.columns.some(c => c.ref?.[0] === 'HasActiveEntity')) {
query._drafts.SELECT.columns.push({ ref: ['HasActiveEntity'] })
}
const orderByExpr = query.SELECT.orderBy
const getOrderByColumns = columns => {
const selectAll = columns === undefined || columns.includes('*')
const queryColumns = !selectAll && columns && columns.map(column => column.as || column?.ref?.[0]).filter(c => c)
const newColumns = []
for (const column of orderByExpr) {
if (selectAll || !queryColumns.includes(column.ref.join('_'))) {
if (column.ref.length === 1 && selectAll) continue
const columnClone = { ...column }
delete columnClone.sort
columnClone.as = columnClone.ref.join('_')
newColumns.push(columnClone)
}
}
return newColumns
}
let orderByDraftColumns
if (orderByExpr) {
orderByDraftColumns = getOrderByColumns(query._drafts.SELECT.columns)
if (orderByDraftColumns.length) query._drafts.SELECT.columns.push(...orderByDraftColumns)
}
const ownDrafts = await run(
query._drafts.where({ ref: ['DraftAdministrativeData', 'InProcessByUser'] }, '=', cds.context.user.id)
)
const draftLength = ownDrafts.length
const limit = query.SELECT.limit?.rows?.val ?? getPageSize(query._target).max
const offset = query.SELECT.limit?.offset?.val ?? 0
query.SELECT.limit = {
rows: { val: limit + draftLength }, // virtual limit
offset: { val: Math.max(0, offset - draftLength) } // virtual offset
}
let orderByColumns
if (orderByExpr) {
orderByColumns = getOrderByColumns(query.SELECT.columns)
if (orderByColumns.length) {
query.SELECT.columns = query.SELECT.columns ?? ['*']
query.SELECT.columns.push(...orderByColumns)
}
}
const queryElements = query.elements
const actives = await run(query.where(Read.whereNotIn(query._target, ownDrafts)))
const removeColumns = (columns, toRemoveCols) => {
if (!toRemoveCols) return
for (const c of toRemoveCols) columns.forEach((column, index) => c.as === column.as && columns.splice(index, 1))
}
removeCo