@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
226 lines (178 loc) • 7.96 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(function () {
this.after(['INSERT', 'UPSERT', 'UPDATE'], async function (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(req.data).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 () => {
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 = this.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, message, target: element }
// If error function was used in @assert expression -> use its output
// Note: depending on DB, function result may be JavaScript object or JSON string
if (typeof message === 'string') {
if (message[0] === '{')
message = JSON.parse(message) //> it's an error function result
else return error //> it's a plain message string
}
let { targets = [], ...details } = message
if (targets.length) details.target = targets.shift()
if (targets.length) details.additionalTargets = targets
return Object.assign(error, details)
})
if (IS_DRAFT_ENTITY) {
// FIXME: All of that shall move to lean draft!
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
: failedAsserts.filter(
a => touched.includes(a.target) || a.additionalTargets?.some(t => touched.includes(t))
)
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
)
})
})