UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

1,339 lines (1,137 loc) 87.2 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 } = 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