UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

1,320 lines (1,113 loc) 94.9 kB
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