@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
238 lines (189 loc) • 8.23 kB
JavaScript
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
)
})
})