UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

1,363 lines (1,182 loc) 72.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 location4 = require('../../http/location') const $original = Symbol('original') const $draftParams = Symbol('draftParams') const AGGREGATION_FUNCTIONS = ['sum', 'min', 'max', 'avg', 'average', 'count'] const _config_to_ms = (config, _default) => { const timeout = cds.env.fiori?.[config] let timeout_ms if (timeout === true) { timeout_ms = cds.utils._unit.time2ms(_default) } else if (typeof timeout === 'string') { timeout_ms = cds.utils._unit.time2ms(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 DRAFT_ADMIN_ELEMENTS = [ 'DraftUUID', 'LastChangedByUser', 'LastChangeDateTime', 'CreatedByUser', 'CreationDateTime', 'InProcessByUser', 'DraftIsCreatedByMe', 'DraftIsProcessedByMe' ] 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) } } } } } // 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) } } } } // 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 Object.defineProperty(_req, '_messages', { get: function () { return req._messages } }) if (req.tx && !_req.tx) _req.tx = req.tx 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 (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' || req.event === 'CREATE') && 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 draftQuery = SELECT.one.from({ ref: draftsRef }).columns([ { ref: ['DraftAdministrativeData_DraftUUID'] }, { ref: ['DraftAdministrativeData'], expand: [_inProcessByUserXpr(_lock.shiftedNow)] } ]) 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 draft = await draftQuery 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] }) if (draft) 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') // It would be great if we'd have a SELECT ** to deeply expand the entity (along compositions), that should // be implemented in expand implementation. 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 cols = expandStarStar(req.target.drafts, true) // Use `run` (since also etags might need to be checked) // REVISIT: Find a better approach (`etag` as part of CQN?) const draftRef = _redirectRefToDrafts(query.SELECT.from.ref, this.model) const draftQuery = SELECT.one .from({ ref: draftRef }) .columns(cols) .columns([ 'HasActiveEntity', 'DraftAdministrativeData_DraftUUID', { ref: ['DraftAdministrativeData'], expand: [{ ref: ['InProcessByUser'] }] } ]) .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] }) } const DraftAdministrativeData_DraftUUID = res.DraftAdministrativeData_DraftUUID 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) 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) const result = await run( HasActiveEntity ? UPDATE({ ref: activesRef }).data(res).where(query.SELECT.where) : INSERT.into({ ref: activesRef }).entries(res), { headers: Object.assign({}, req.headers, { 'if-match': '*' }) } ) 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'] }] }) 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 UPDATE('DRAFT.DraftAdministrativeData') .data({ InProcessByUser: req.user.id, LastChangedByUser: req.user.id, LastChangeDateTime: new Date() }) .where({ DraftUUID: res.DraftAdministrativeData_DraftUUID }) await run(UPDATE({ ref: draftsRef }).data(req.data)) 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 } 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) }) 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)) } removeColumns(query._drafts.SELECT.columns, orderByDraftColumns) removeColumns(query.SELECT.columns, orderByColumns) const ownNewDrafts = [] const ownEditDrafts = [] for (const draft of ownDrafts) { if (draft.HasActiveEntity) ownEditDrafts.push(draft) else ownNewDrafts.push(draft) } const $count = ownDrafts.length + (isCount ? actives[0]?.$count : (actives.$count ?? 0)) if (isCount) return { $count } Read.merge(query._target, ownDrafts, [], row => { Object.assign(row, { HasDraftEntity: false }) _fillIsActiveEntity(row, false, query._drafts._target) }) const otherEditDrafts = await Read.complementaryDrafts(query, actives) Read.merge(query._target, actives, otherEditDrafts, (row, other) => { if (other) { Object.assign(row, { HasDraftEntity: true, HasActiveEntity: false, DraftAdministrativeData_DraftUUID: other.DraftAdministrativeData_DraftUUID, DraftAdministrativeData: other.DraftAdministrativeData }) } else { Object.assign(row, { HasDraftEntity: false, HasActiveEntity: false, DraftAdministrativeData_DraftUUID: null, DraftAdministrativeData: null }) } _fillIsActiveEntity(row, true, query._target) }) const resultSet = actives.length > 0 && ownDrafts.length === 0 ? actives : ownDrafts.length > 0 && actives.length === 0 ? ownDrafts : [...ownDrafts, ...actives] // runtime sort required if (orderByExpr && ownDrafts.length > 0 && actives.length > 0) { const locale = cds.context.locale?.replaceAll('_', '-') const collatorMap = new Map() const elementNamesToSort = orderByExpr.map(orderByExp => orderByExp.ref.join('_')) for (const elementName of elementNamesToSort) { const element = queryElements[elementName] ?? query._target.elements[elementName] // The latter is needed for CDS orderBy statements if (!element) continue let collatorOptions switch (element.type) { case 'cds.Integer': case 'cds.UInt8': case 'cds.Int16': case 'cds.Int32': case 'cds.Integer64': case 'cds.Int64': case 'cds.Decimal': case 'cds.DecimalFloat': case 'cds.Double': collatorOptions = numericCollator break default: collatorOptions = emptyObject } const collator = Intl.Collator(locale, collatorOptions) collatorMap.set(elementName, collator) } const getSortFn = (index = 0) => (entityA, entityB) => { const orderBy = orderByExpr[index] const elementName = elementNamesToSort[index] const collator = collatorMap.get(elementName) const diff = collator.compare(entityA[elementName], entityB[elementName]) if (diff === 0 && index + 1 < orderByExpr.length) return getSortFn(index + 1)(entityA, entityB) if (orderBy.sort === 'desc') return diff * -1 return diff } resultSet.sort(getSortFn()) } let virtualOffset = offset - draftLength virtualOffset = virtualOffset > 0 ? draftLength : draftLength + virtualOffset const pageResultSet = _filterResultSet(resultSet, limit, virtualOffset) if (query.SELECT.count) pageResultSet.$count = ownDrafts.$count ?? 0 + $count return _requested(pageResultSet, query) }, activesFromDrafts: async function (run, query, { isLocked = true }) { const draftsQuery = query._drafts if (!draftsQuery) throw cds.error('INVALID_DRAFT_REQUEST', { statusCode: 400 }) // only via drafts const additionalCols = draftsQuery.SELECT.columns ? draftsQuery.SELECT.columns.filter( c => c.ref && ['DraftAdministrativeData', 'DraftAdministrativeData_DraftUUID'].includes(c.ref[0]) ) : [{ ref: ['DraftAdministrativeData_DraftUUID'] }] draftsQuery.SELECT.columns = _entityKeys(query._target) .map(k => ({ ref: [k] })) .concat(additionalCols) draftsQuery.where({ HasActiveEntity: true, 'DraftAdministrativeData.InProcessByUser': { '!=': cds.context.user.id }, 'DraftAdministrativeData.LastChangeDateTime': { [isLocked ? '>' : '<']: _lock.shiftedNow } }) const drafts = await draftsQuery const actives = drafts.length ? await run(query.where(Read.whereIn(query._target, drafts))) : Object.assign([], { $count: 0 }) Read.merge(query._target, actives, drafts, (row, other) => { if (other) Object.assign(row, other, { HasDraftEntity: true, HasActiveEntity: false }) else Object.assign({ HasDraftEntity: false, HasActiveEntity: false }) _fillIsActiveEntity(row, true, query._target) }) return _requested(actives, query) }, unsavedChangesByAnotherUser: async function (run, query) { LOG.debug('List Editing Status: Unsaved Changes by Another User') return Read.activesFromDrafts(run, query, { isLocked: false }) }, lockedByAnotherUser: async function (run, query) { LOG.debug('List Editing Status: Locked by Another User') return Read.activesFromDrafts(run, query, { isLocked: true }) }, whereNotIn: (target, data) => Read.whereIn(target, data, true), whereIn: (target, data, not = false) => { const keys = _entityKeys(target) const dataArray = data ? (Array.isArray(data) ? data : [data]) : [] if (not && !dataArray.length) return [] if (keys.length === 1) { // For single keys, make it nicer (without unnecessary lists) const key = keys[0] const left = { ref: [key] } const op = not ? ['not', 'in'] : ['in'] const right = { list: dataArray.map(r => ({ val: r[key] })) } return [left, ...op, right] } else { const left = { list: keys.map(k => ({ ref: [k] })) } const op = not ? ['not', 'in'] : ['in'] const right = { list: dataArray.map(r => ({ list: keys.map(k => ({ val: r[k] })) })) } return [left, ...op, right] } }, complementaryDrafts: (query, _actives) => { const actives = Array.isArray(_actives) ? _actives : [_actives] if (!actives.length) return [] const drafts = cds.ql.clone(query._drafts) drafts.SELECT.where = Read.whereIn(query._target, actives) const newColumns = _entityKeys(query._target).map(k => ({ ref: [k] })) if ( !drafts.SELECT.columns || drafts.SELECT.columns.some(c => c === '*' || c.ref?.[0] === 'DraftAdministrativeData_DraftUUID') ) newColumns.push({ ref: ['DraftAdministrativeData_DraftUUID'] }) const draftAdmin = drafts.SELECT.columns?.find(c => c.ref?.[0] === 'DraftAdministrativeData') if (draftAdmin) newColumns.push(draftAdmin) drafts.SELECT.columns = newColumns drafts.SELECT.count = undefined drafts.SELECT.search = undefined drafts.SELECT.one = undefined drafts.SELECT.recurse = undefined return drafts }, draftStream: async (run, query) => { // read from draft const result = await Read.ownDrafts(run, query) if (!Array.isArray(result)) { for (let key in result) { if (result[key] instanceof Readable) { result[key] = result[key].constructor.name === 'HanaLobStream' ? await _readDraftStreamHanaClient(result[key], query, key) : _readDraftStream(result[key], query, key) } } } return result }, _makeArray: data => (Array.isArray(data) ? data : data ? [data] : []), _index: (target, data) => { // Indexes the data for fast key access const dataArray = Read._makeArray(data) if (!dataArray.length) return const hash = row => _entityKeys(target) .map(k => row[k]) .reduce((res, curr) => res + '|$|' + curr, '') const hashMap = new Map() for (const row of dataArray) hashMap.set(hash(row), row) return { hashMap, hash } }, // Calls `cb` for each entry of data with a potential counterpart in otherData merge: (target, data, otherData, cb) => { const dataArray = Read._makeArray(data) if (!dataArray.length) return const index = Read._index(target, otherData) for (const row of dataArray) { const other = index?.hashMap.get(index.hash(row)) cb(row, other) } }, // Deletes entries of data with a counterpart in otherData delete: (target, data, otherData) => { if (!Array.isArray(data) || !data.length) return const index = Read._index(target, otherData) let i = data.length while (i--) { if (index?.hashMap.get(index.hash(data[i]))) data.splice(i, 1) } } } function _cleanseParams(params, target) { if (!target?.drafts) return if (Array.isArray(params)) { for (const param of params) _cleanseParams(param, target) return } if (typeof params === 'object') { for (const key in params) { if (key === 'IsActiveEntity') { const value = params[key] delete params[key] Object.defineProperty(params, key, { value, enumerable: false, writeable: true, configurable: true }) } } } } function _cleanseCols(columns, elements, target) { // TODO: sometimes target is undefined if (!target || typeof columns?.filter !== 'function') return columns const filtered = target?.drafts ? columns.filter(c => !elements.has(c.ref?.[0])) : columns return filtered.map(c => { if (c.expand && c.ref) { return { ...c, expand: _cleanseCols(c.expand, elements, target.elements[c.ref[0]]?._target) } } return c }) } /** * Creates a clone of the query, cleanses and collects all draft parameters into DRAFT_PARAMS. */ function _cleansed(query, model) { const draftParams = {} //> used to collect draft filter criteria const q = _cleanseQuery(query, draftParams, model) if (query.SELECT) { const getDrafts = () => { // could just clone `query` but the latter is ruined by database layer const draftsQuery = _cleanseQuery(query, {}, model) // set the target to null to ensure cds.infer(...) correctly infer the // target after query modifications draftsQuery._target = null let draftSelect = draftsQuery.SELECT let querySelect = query.SELECT // in the $apply scenario, only the most inner nested SELECT data structure must be cleansed while (draftSelect.from.SELECT) { draftSelect = draftSelect.from.SELECT querySelect = querySelect.from.SELECT } if (!draftSelect.from.ref) return // invalid draft request const [root, ...tail] = draftSelect.from.ref const draft = model.definitions[root.id || root].drafts if (!draft) return draftSelect.from = { ref: [root.id ? { ...root, id: draft.name } : draft.name, ...tail] } cds.infer.target(draftsQuery) // draftsQuery._target = draftsQuery._target?.drafts || draftsQuery._target if (querySelect.columns && query._target.drafts) { if (draftsQuery._target.isDraft) draftSelect.columns = _cleanseCols(querySelect.columns, REDUCED_DRAFT_ELEMENTS, draft) else draftSelect.columns = _cleanseCols(querySelect.columns, DRAFT_ELEMENTS, draft) } if (querySelect.where && query._target.drafts) { if (draftsQuery._target.isDraft) draftSelect.where = _cleanseWhere(querySelect.where, {}, DRAFT_ELEMENTS_WITHOUT_HASACTIVE) else draftSelect.where = _cleanseWhere(querySelect.where, {}, DRAFT_ELEMENTS) } if (querySelect.orderBy && query._target.drafts) { if (draftsQuery._target.isDraft) draftSelect.orderBy = _cleanseWhere(querySelect.orderBy, {}, REDUCED_DRAFT_ELEMENTS) else draftSelect.orderBy = _cleanseWhere(querySelect.orderBy, {}, DRAFT_ELEMENTS) } if (draftsQuery._target.name.endsWith('.DraftAdministrativeData')) { draftSelect.columns = _tweakAdminCols(draftSelect.columns) } else if (draftsQuery._target?.name.endsWith('.drafts')) { draftSelect.columns = _tweakAdminExpand(draftSelect.columns) } draftsQuery[$draftParams] = draftParams Object.defineProperty(q, '_drafts', { value: draftsQuery }) return draftsQuery } Object.defineProperty(q, '_drafts', { configurable: true, get() { return getDrafts() } }) } q[$draftParams] = draftParams q[$original] = query return q function _cleanseQuery(query, draftParams, model) { const target = query._target const q = cds.ql.clone(query) if (q.SELECT?.from.SELECT) q.SELECT.from = _cleanseQuery(q.SELECT?.from, draftParams, model) const ref = q.SELECT?.from.SELECT?.from.ref || q.SELECT?.from.ref || q.UPDATE?.entity.ref || q.INSERT?.into.ref || q.DELETE?.from.ref const cqn = q.SELECT || q.UPDATE || q.INSERT || q.DELETE if (ref) { let entity const cleansedRef = ref.map(r => { entity = (entity && entity.elements[r.id || r]._target) || model.definitions[r.id || r] if (!entity?.drafts) return r return r.where ? { ...r, where: _cleanseWhere(r.where, draftParams, DRAFT_ELEMENTS) } : r }) if (q.SELECT) q.SELECT.from = q.SELECT.from.SELECT ? q.SELECT.from : { ...q.SELECT.from, ref: cleansedRef } else if (q.DELETE) q.DELETE.from = { ...q.DELETE.from, ref: cleansedRef } else if (q.UPDATE) q.UPDATE.entity = { ...q.UPDATE.entity, ref: cleansedRef } else if (q.INSERT) q.INSERT.into = { ...q.INSERT.into, ref: cleansedRef } // This only works for simple cases of `SiblingEntity`, e.g. `root(ID=1,IsActiveEntity=false)/SiblingEntity` // , check if there are more complicated use cases const siblingIdx = cleansedRef.findIndex(r => r === 'SiblingEntity') if (siblingIdx !== -1) { cleansedRef.splice(siblingIdx, 1) draftParams.IsActiveEntity = !draftParams.IsActiveEntity } } if (target.drafts && cqn.where) cqn.where = _cleanseWhere(cqn.where, draftParams, DRAFT_ELEMENTS) if (target.drafts && cqn.orderBy) cqn.orderBy = _cleanseCols(cqn.orderBy, DRAFT_ELEMENTS, target) // allowed to reuse if (cqn.columns) cqn.columns = _cleanseCols(cqn.columns, DRAFT_ELEMENTS, target) return q } function _tweakAdminExpand(columns) { if (!columns) return columns return columns.map(col => { if (col.ref?.[0] === 'DraftAdministrativeData') { return { ...col, expand: _tweakAdminCols(col.expand) } } return col }) } function _tweakAdminCols(columns) { if (!columns || columns.some(c => c === '*')) columns = DRAFT_ADMIN_ELEMENTS.map(k => ({ ref: [k] })) return columns.map(col => { const name = col.ref?.[0] if (!name) return col switch (name) { case 'DraftAdministrative