UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

238 lines (189 loc) 8.23 kB
const cds = require('@sap/cds') const getTemplate = require('../utils/template') const templatePathSerializer = require('../utils/templateProcessorPathSerializer') const $has_asserts = Symbol.for('has_asserts') const { compileUpdatedDraftMessages } = require('../../fiori/lean-draft') const _serialize = obj => JSON.stringify( Object.keys(obj) .sort() .reduce((a, k) => ((a[k] = obj[k]), a), {}) ) const _bufferReviver = (key, value) => { if (value && typeof value === 'object' && value.type === 'Buffer' && Array.isArray(value.data)) { return Buffer.from(value.data) } return value } const _hasAssert = entity => { if ($has_asserts in entity) return entity[$has_asserts] entity[$has_asserts] = false for (const each in entity.elements) { const element = entity.elements[each] if (element['@assert']) { entity[$has_asserts] = true break } } if (entity[$has_asserts]) return entity[$has_asserts] for (const c in entity.compositions) { const nested_asserts = _hasAssert(entity.compositions[c]._target) if (nested_asserts) { entity[$has_asserts] = true break } } return entity[$has_asserts] } module.exports = cds.service.impl(async function () { this.after(['INSERT', 'UPSERT', 'UPDATE'], async (res, req) => { if (!req.target) return if (!($has_asserts in req.target)) _hasAssert(req.target) if (!cds.context?.tx || !req.target[$has_asserts]) return const IS_DRAFT_ENTITY = req.target.isDraft if (req.event === 'CREATE' && IS_DRAFT_ENTITY) return let touched if (cds.env.features.assert_touched_only !== false && IS_DRAFT_ENTITY && req.event === 'UPDATE') touched = Object.keys(res).filter(k => !(k in req.target.keys)) const template = getTemplate('assert', this, req.target, { pick: element => element['@assert'], ignore: element => element.isAssociation && !element.isComposition }) if (!cds.context.tx.changes) { cds.context.tx.changes = {} req.before('commit', async function () { const { changes } = cds.context.tx const errors = [] for (const [entityName, serializedChanges] of Object.entries(changes)) { if (!serializedChanges.size) continue const deserializedChanges = Array.from(serializedChanges).map(([k, v]) => [JSON.parse(k, _bufferReviver), v]) const entity = cds.model.definitions[entityName] const IS_DRAFT_ENTITY = entity.isDraft // Cache assert query on entity if (!Object.hasOwn(entity, 'assert')) { const asserts = [] for (const element of Object.values(entity.elements)) { // if (element._foreignKey4) continue if (element.isAssociation && !element.isComposition) continue const assert = element['@assert'] if (!assert) continue // replace $self with $main const xpr = JSON.parse(JSON.stringify(assert.xpr).replace(/\$self/g, '$main')) asserts.push({ xpr, as: '@assert:' + element.name }) } entity.assert = cds.ql.SELECT([...Object.keys(entity.keys), ...asserts]).from(entity) } const query = cds.ql.clone(entity.assert) // Select only rows with changes const keyNames = Object.keys(entity.keys).filter( k => !entity.keys[k].virtual && !entity.keys[k].isAssociation ) const keyMap = Object.fromEntries(keyNames.map(k => [k, true])) query.where([ { list: keyNames.map(k => ({ ref: [k] })) }, 'in', { list: deserializedChanges.map(([keyKV]) => ({ list: keyNames.map(k => ({ val: keyKV[k] })) })) } ]) const results = await query for (const row of results) { const keyColumns = Object.fromEntries(Object.entries(row).filter(([k]) => k in keyMap)) const { touched, req, pathSegmentsInfo } = serializedChanges.get(_serialize(keyColumns)) const failedColumns = Object.entries(row) .filter(([k, v]) => v !== null && !(k in keyMap)) .map(([k, v]) => [k.replace(/^@assert:/, ''), v]) if (failedColumns.length === 0) continue const failedAsserts = failedColumns.map(([element, message]) => { const error = { status: 400, code: 'ASSERT', target: element, numericSeverity: 4, '@Common.numericSeverity': 4 } // if error function was used in @assert expression -> use its output try { // Depending on DB, function result may be JavaScript Object or JSON String const parsed = typeof message === 'string' ? JSON.parse(message) : message Object.assign(error, parsed) if (Array.isArray(error.targets)) { const target = error.targets.at(0) const additionalTargets = error.targets.slice(1) if (target) error.target = target if (additionalTargets.length) error.additionalTargets = additionalTargets } delete error.targets } catch { error.message = message } return error }) if (IS_DRAFT_ENTITY) { const draft = await SELECT.one .from({ ref: [req.subject.ref[0]] }) .columns('DraftAdministrativeData_DraftUUID', 'DraftAdministrativeData.DraftMessages') const persistedMessages = draft.DraftAdministrativeData_DraftMessages || [] // keep all messages that have targets that were touched in this change const newMessages = touched ? failedAsserts.filter(a => { const targets = [a.target].concat(a.additionalTargets || []) return touched.some(t => targets.includes(t)) }) : failedAsserts const nextDraftMessages = compileUpdatedDraftMessages( newMessages, persistedMessages, req.data, req.subject.ref ) await UPDATE('DRAFT.DraftAdministrativeData') .set({ DraftMessages: nextDraftMessages }) .where({ DraftUUID: draft.DraftAdministrativeData_DraftUUID }) } else { const isDraftAction = req._.event?.startsWith('draft') const prefix = templatePathSerializer('', pathSegmentsInfo) failedAsserts.forEach(err => { err.target = (isDraftAction ? 'in/' : '') + prefix + err.target }) errors.push(...failedAsserts) } } } if (errors.length) { if (errors.length === 1) throw errors[0] const err = new cds.error('MULTIPLE_ERRORS', { details: errors }) delete err.stack throw err } }) } const templateProcessOptions = { pathSegmentsInfo: [], includeKeyValues: true } if (req._.event?.startsWith('draft')) { const IsActiveEntity = req.data.IsActiveEntity || false templateProcessOptions.draftKeys = { IsActiveEntity } } // Collect entity keys and their values of changed rows template.process( req.data, elementInfo => { const { row, target, pathSegmentsInfo } = elementInfo const targetName = target.name cds.context.tx.changes[targetName] ??= new Map() const keys = {} for (const key in target.keys) { if (key === 'IsActiveEntity') continue if (!(key in row)) continue keys[key] = row[key] } if (!Object.keys(keys).length) return const serialized = _serialize(keys) const changes = cds.context.tx.changes[targetName] if (changes.has(serialized)) return changes.set(serialized, { touched, req, pathSegmentsInfo: [...pathSegmentsInfo] }) }, templateProcessOptions ) }) })