@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
1,320 lines (1,113 loc) • 94.9 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, prepareError } = require('../../../lib/req/response')
const location4 = require('../../http/location')
const $original = Symbol('original')
const $draftParams = Symbol('draftParams')
const { WELL_KNOWN_EVENTS } = require('../../../lib/req/event')
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 IS_PERSISTED_DRAFT_MESSAGES_ENABLED =
!!cds.model?.definitions?.['DRAFT.DraftAdministrativeData']?.elements?.DraftMessages
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)
cds.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 = () => {
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.'
cds.error({ status: 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 _extractPrefixRef = (draftsRef, targetEntity, requestData = null) => {
const prefixRef = []
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) {
pRef.id = targetEntity.isDraft ? targetEntity.actives.name : targetEntity.name
} else pRef.id = dRefId
if (targetEntity.isDraft) targetEntity = targetEntity.actives
if ((typeof dRef === 'string' || !dRef.where?.length) && requestData) {
// 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 (v instanceof Buffer) v = v.toString('base64') //> convert binary keys to base64
if (pRef.where.length > 0) pRef.where.push('and')
pRef.where.push({ ref: [key.name] }, '=', { val: v })
}
prefixRef.push(pRef)
continue
}
if (dRef.where?.length) {
// Use the existing where clause from draftRef
pRef.where.push(...dRef.where)
if (!pRef.where.some(w => w === 'IsActiveEntity' || w.ref?.[0] === 'IsActiveEntity'))
pRef.where.push('and', { ref: ['IsActiveEntity'] }, '=', { val: false })
prefixRef.push(pRef)
continue
}
prefixRef.push(pRef.id)
}
return prefixRef
}
const compileUpdatedDraftMessages = (newMessages, persistedMessages, requestData, draftsRef) => {
const txModel = cds.context.tx.model
let targetEntity = txModel.definitions[draftsRef[0].id || draftsRef[0]]
// The 'target' of validation errors needs to specify the full path from the draft root
// > Since success of establishment of containment can't be guaranteed, we clean up messages
if (!targetEntity['@Common.DraftRoot.ActivationAction']) return []
// Determine the path prefix required for a fully qualified validation message 'target'
const prefixRef = _extractPrefixRef(draftsRef, targetEntity, requestData)
if (!prefixRef) return null
const draftMessageTargetPrefix = cds.odata.urlify(
{ SELECT: { from: { ref: prefixRef } } },
{ kind: 'odata-v4', model: txModel }
).path
const nextMessages = []
// Collect messages that were created during the most recent validation run
const newMessagesByMessageKeyAndTarget = newMessages.reduce((acc, message) => {
if (!message.target) return acc //> silently ignore messages without target
message.numericSeverity ??= message['@Common.numericSeverity'] ?? 4
if (message['@Common.additionalTargets']) message.additionalTargets ??= message['@Common.additionalTargets']
message.additionalTargets = message.additionalTargets?.map(t => (t.startsWith('in/') ? t.slice(3) : t))
delete message['@Common.additionalTargets']
// Remove prefix 'in/' in favor of fully qualified path to the target
const messageTarget = message.target.startsWith('in/') ? message.target.slice(3) : message.target
if (message.code) delete message.code // > Expect _only_ message to be set and contain the 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 } } },
{ kind: 'odata-v4', model: txModel }
).path
nextMessages.push(message)
return acc.set(`${message.message}:${message.prefix}:${message.target}`, message)
}, new Map())
// 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 (newMessagesByMessageKeyAndTarget.has(`${message.message}:${message.prefix}:${message.target}`)) continue
const hasNewMessageForAdditionalTarget = message.additionalTargets?.some(target =>
newMessagesByMessageKeyAndTarget.has(`${message.message}:${message.prefix}:${target}`)
)
if (hasNewMessageForAdditionalTarget) continue
// Drop persisted draft messages where the value of the target field changed
if (message.prefix === draftMessageTargetPrefix) {
if (requestData[message.target] !== undefined) continue
if (message.additionalTargets?.some(target => requestData[target] !== undefined)) continue
}
// Drop persisted draft messages, whose target's use navigations, where the value of the target field changed
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
}
const _createNewDraftData = (obj, target, draftUUID) => {
const newDraftData = Object.assign({}, obj, {
DraftAdministrativeData_DraftUUID: draftUUID,
HasActiveEntity: false
})
if (!target) return newDraftData
for (const key in newDraftData) {
if (!target.elements[key]?.isComposition) continue
// Recurse into payload entries for nested associated entities
if (Array.isArray(newDraftData[key]))
newDraftData[key] = newDraftData[key].map(v => _createNewDraftData(v, target.elements[key]._target, draftUUID))
else if (typeof newDraftData[key] === 'object')
newDraftData[key] = _createNewDraftData(newDraftData[key], target.elements[key]._target, draftUUID)
}
return newDraftData
}
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
// To create a `READ` event based on a modifying request, we need to delete req.data
if (event === 'READ' && req.event !== 'READ') delete _req.data
if (headers) {
_req.headers = Object.create(req.headers)
Object.assign(_req.headers, headers)
}
// Set relevant attributes of the inner request based on the outer
_req.target = cds.infer.target(query)
_req.query = query
_req.params = req.params
_req._ = req._
const cqnData = _req.query.UPDATE?.data || _req.query.INSERT?.entries?.[0]
if (cqnData) _req.data = cqnData // must point to the same object
if (req.protocol) _req.protocol = req.protocol
if (!_req._.event) _req._.event = req.event
if (req.tx && !_req.tx) _req.tx = req.tx
// Determine the proper event for the inner request
if (event) _req.event = event
else if (query.SELECT) _req.event = 'READ'
else if (query.INSERT) _req.event = 'CREATE'
else if (query.UPDATE) _req.event = 'UPDATE'
else if (query.DELETE) _req.event = 'DELETE'
else _req.event = req.event
// Divert messages and validation errors to outer request
/* prettier-ignore */ Object.defineProperty(_req, '_messages', { get() { return req._messages } })
/* prettier-ignore */ Object.defineProperty(_req, '_validationErrors', { get() { return req._validationErrors } })
const shouldCollectValidationErrorsSeparately =
_req.target.isDraft && IS_PERSISTED_DRAFT_MESSAGES_ENABLED && req.protocol && ['UPDATE', 'NEW'].includes(_req.event)
if (shouldCollectValidationErrorsSeparately) {
// Add a separate collection for validation errors, to the outer request
req._validationErrors ??= new Responses()
// Override .error to collect validation errors separately, for the inner request
// > Validation errors being classified by having a 'target'
_req.error = (...args) => {
const err = prepareError(4, ...args)
if (err.target) _req._validationErrors.push(err)
else _req._errors.add(null, ...args)
return err
}
}
return _req
}
// REVISIT: Can we do a regular handler function instead of monky patching?
const protoHandle = cds.ApplicationService.prototype.handle
const draftHandle = 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 protoHandle.call(this, req)
if ($draftParams in req.query) return protoHandle.call(this, req)
/* prettier-ignore */ 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 protoHandle.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 run = (query, options = {}) => {
const _req = _newReq(req, query, draftParams, options)
return protoHandle.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 protoHandle.call(this, req)
}
// apply paging and sorting on original query for protocol adapters relying on it
commonGenericPaging(req)
commonGenericSorting(req)
const determineRead = () => {
// 'draftParams' as filled in '_cleanseQuery'
const isActiveEntity = draftParams.IsActiveEntity
const hasDraftEntity = draftParams.HasDraftEntity
const hasStreaming = _hasStreaming(query.SELECT.columns, query._target)
const isBinaryDraftCompat = cds.env.features.binary_draft_compat
const isTargetDraft = req.query._target.name.endsWith('.drafts')
const inProcessByUserEqNullString = ['not ', 'not null'].includes(
draftParams.DraftAdministrativeData_InProcessByUser
)
const inProcessByUserEqEmptyString = draftParams.DraftAdministrativeData_InProcessByUser === ''
const isSiblingIsActiveEntityNull = draftParams.SiblingEntity_IsActiveEntity === null
if (isActiveEntity === false && hasStreaming && !isBinaryDraftCompat) return Read.draftStream
if (isTargetDraft) return Read.ownDrafts
if (isActiveEntity === false && isSiblingIsActiveEntityNull) return Read.all
if (isActiveEntity === true && isSiblingIsActiveEntityNull && inProcessByUserEqNullString)
return Read.lockedByAnotherUser
if (isActiveEntity === true && isSiblingIsActiveEntityNull && inProcessByUserEqEmptyString)
return Read.unsavedChangesByAnotherUser
if (isActiveEntity === true && hasDraftEntity === false) return Read.unchanged
if (isActiveEntity === false) return Read.ownDrafts
return Read.onlyActives
}
const read = determineRead()
const result = await read(run, query)
return result
}
if (req.event === 'draftEdit') req.event = 'EDIT'
if (req.event === 'draftPrepare' && draftParams.IsActiveEntity)
cds.error({ status: 400, message: 'Action "draftPrepare" can only be called on the draft entity' })
// REVISIT: Can't use req.subject here:
// REVISIT: > Caching in req.subject will yield erroneous result
// REVISIT: > after replacing req.query by draft adapted query
let rootEntityName = (
query.SELECT?.from ||
query.INSERT?.into ||
query.UPSERT?.into ||
query.UPDATE?.entity ||
query.DELETE?.from
)?.ref?.[0]
if (typeof rootEntityName === 'object') rootEntityName = rootEntityName.id
const rootEntity = this.model.definitions[rootEntityName]
const isNewDraftViaActionEnabled = cds.env.fiori.direct_crud ?? false
let newDraftAction = rootEntity['@Common.DraftRoot.NewAction']
if (typeof newDraftAction != 'string' || !newDraftAction.length) newDraftAction = false
else newDraftAction = newDraftAction.split('.').pop()
const shouldHandleNewDraftAction = isNewDraftViaActionEnabled && req.target === rootEntity
// Create active instance of draft-enabled entity
// REVISIT: New OData adapter only sets `NEW` for drafts... how to distinguish programmatic modifications?
if (
(req.event === 'NEW' && shouldHandleNewDraftAction && req.data.IsActiveEntity !== false) ||
(req.event === 'CREATE' && req.target.drafts && req.data?.IsActiveEntity !== false && !req.target.isDraft)
) {
if (
!isNewDraftViaActionEnabled &&
req.protocol === 'odata' &&
!cds.env.fiori.bypass_draft &&
!req.target['@odata.draft.bypass']
) {
return reject_bypassed_draft(req)
}
const queryUsesContainment = !!rootEntity['@Common.DraftRoot.ActivationAction']
if (!queryUsesContainment) cds.error({ status: 403, message: 'DRAFT_MODIFICATION_ONLY_VIA_ROOT' })
const isDirectAccess = 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 = _redirectRefToDrafts([query.INSERT.into.ref[0]], this.model)
let draftRootEntityExists
// Children: check root entity has no draft
if (!isDirectAccess) draftRootEntityExists = 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
}, {})
draftRootEntityExists = await SELECT.one([1]).from({ ref: draftsRootRef }).where(keyData)
}
if (draftRootEntityExists) cds.error({ status: 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
}
// Prevent creating active entities via drafts
if (req.event === 'CREATE' && draftParams.IsActiveEntity === false && !req.target.isDraft) {
cds.error({ status: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
}
// Handle 'newDraftAction' event if enabled
if (shouldHandleNewDraftAction && req.event === newDraftAction) {
if (!req.target.isDraft) req.target = req.target.drafts
// Rewrite 'newDraftAction' into regular 'NEW' event
query.SELECT.from.ref = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
const createNewQuery = INSERT.into(query.SELECT.from).entries(req.data)
req.event = req._.event = 'NEW'
req.query = req._.query = createNewQuery
const innerReq = _newReq(req, createNewQuery, draftParams, { event: 'NEW' })
const createNewResult = await protoHandle.call(this, innerReq)
req.data = createNewResult
req.res.status(201)
return _readAfterDraftAction.call(this, { req, payload: createNewResult, action: 'draftNew' })
}
// Handle draft-only events, that can only ever target entities in draft state
if (
(req.event === 'NEW' && (!shouldHandleNewDraftAction || req.data.IsActiveEntity === false)) ||
req.event === 'CANCEL' ||
req.event === 'draftPrepare'
) {
if (!req.target.isDraft) req.target = req.target.drafts // COMPAT: also support these events for actives
if (query.INSERT) 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) {
cds.error({ status: 403, message: 'ACTIVE_MODIFICATION_VIA_DRAFT' })
}
const result = await protoHandle.call(this, _req)
req.data = result //> make keys available via req.data (as with normal crud)
return result
}
// Handle 'DELETE' event on entities in active state
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) cds.error({ status: 400, message: '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)
cds.error({ status: 403, message: 'DRAFT_LOCKED_BY_ANOTHER_USER', args: [inProcessByUser] })
else cds.error({ status: 403, message: 'DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS' })
}
await run(query)
return req.data
}
// Handle 'draftActivate' event
if (req.event === 'draftActivate') {
LOG.debug('activate draft')
if (req.query.SELECT.from.ref.length > 1 || draftParams.IsActiveEntity === true) {
cds.error({
status: 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']) {
cds.error(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'] },
...(IS_PERSISTED_DRAFT_MESSAGES_ENABLED ? [{ 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
cds.error(_etagValidationType ? { status: 412 } : { status: 404, message: 'DRAFT_NOT_EXISTING' })
}
if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
cds.error({
status: 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)
if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED) {
_req.on('failed', async error => {
const errors = []
if (_req.errors) {
// REVISIT: e._message hack for draft validation messages
// Errors procesed during 'failed' will have undergone error._normalize at this point
// > We need to revert the code - message swap _normalize includes
// > This is required to ensure, no localized messages are persisted and redundant localization is avoided
errors.push(..._req.errors.map(e => ({ ...e, message: e._message ?? e.message })))
} else {
// NOTE: this branch is for on-commit errors, which don't end up in req.errors
// IMPORTANT: copy error objects to avoid side effects on original error
if (error.code) errors.push({ ...error })
if (error.details) errors.push(...error.details.map(e => ({ ...e })))
}
const nextDraftMessages = compileUpdatedDraftMessages(errors, persistedDraftMessages, _req.data || {}, draftRef)
await cds.tx(async () => {
await UPDATE('DRAFT.DraftAdministrativeData')
.set({ DraftMessages: nextDraftMessages })
.where({ DraftUUID: DraftAdministrativeData_DraftUUID })
})
})
}
const result = await protoHandle.call(this, _req)
// 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 })
}
// Handle regular custom actions on entities in draft state
if (req.target.actions?.[req.event] && !(req.event in WELL_KNOWN_EVENTS) && draftParams.IsActiveEntity === false) {
if (query.SELECT?.from?.ref) query.SELECT.from.ref = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
// Check if the draft is locked by another user
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) cds.error(404)
if (root.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
cds.error({
status: 403,
message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
args: [root.DraftAdministrativeData.InProcessByUser]
})
}
const _req = _newReq(req, query, draftParams, { event: req.event })
const result = await protoHandle.call(this, _req)
return result
}
// Handle 'UPDATE' events on entities in draft or active state
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)
// REVISIT: This should allow `IsActiveEntity: false` to be passed in the body
// REVISIT: > Enabling this would re-establish symmetry with CREATE
if (draftParams.IsActiveEntity === false) {
LOG.debug('patch draft')
if (req.target?.name.endsWith('DraftAdministrativeData')) cds.error(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'] },
...(IS_PERSISTED_DRAFT_MESSAGES_ENABLED ? [{ ref: ['DraftMessages'] }] : [])
]
})
if (!res) cds.error(404)
if (!cds.context.user._is_privileged && res.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id) {
cds.error({
status: 403,
message: 'DRAFT_LOCKED_BY_ANOTHER_USER',
args: [res.DraftAdministrativeData?.InProcessByUser]
})
}
const innerQuery = UPDATE({ ref: draftsRef }).data(req.data)
const innerReq = _newReq(req, innerQuery, draftParams, {})
await protoHandle.call(this, innerReq).catch(error => {
// > This prevents thrown req.errors from being added to 'sap-messages'
// > Downgraded req.error will add to req.messages, regardless of whether the error is thrown
// > Unless we prevent it here, the response will contain the error in both body and header
// > Downgrading is only required for PATCH, to prevent a rollback in case validation fails
if (req.messages?.length) req.messages = req.messages.filter(m => m !== error)
throw req.error(error)
})
const nextDraftAdminData = {
InProcessByUser: req.user.id,
LastChangedByUser: req.user.id,
LastChangeDateTime: new Date()
}
if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED) {
nextDraftAdminData.DraftMessages = compileUpdatedDraftMessages(
req._validationErrors ?? [],
res.DraftAdministrativeData?.DraftMessages ?? [],
req.data ?? {},
draftsRef
)
}
await UPDATE('DRAFT.DraftAdministrativeData')
.data(nextDraftAdminData)
.where({ DraftUUID: res.DraftAdministrativeData_DraftUUID })
req.data.IsActiveEntity = false
return req.data
}
LOG.debug('patch active')
if (
!isNewDraftViaActionEnabled &&
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']) {
// REVISIT: Should this really use 403?
cds.error({ status: 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) cds.error({ status: 409, message: 'DRAFT_ALREADY_EXISTS' })
await run(query)
return req.data
}
req.query = query
return protoHandle.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) cds.error({ status: 400, message: 'INVALID_DRAFT_REQUEST' }) // 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 {
drafts = await Read.complementaryDrafts(query, actives)
}
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) cds.error({ status: 400, message: 'INVALID_DRAFT_REQUEST' }) // 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) cds.error({ status: 400, message: 'INVALID_DRAFT_REQUEST' }) // 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
}
const selectedColumnNames = query._drafts.SELECT.columns?.map(c => c.ref?.[0] || c) || ['*']
const isAllKeysSelec