@cap-js/db-service
Version:
CDS base database service
304 lines (261 loc) • 10 kB
JavaScript
const cds = require('@sap/cds')
const { _target_name4 } = require('./SQLService')
const ROOT = Symbol('root')
// REVISIT: remove old path with cds^8
let _compareJson
const compareJson = (...args) => {
if (!_compareJson) {
try {
// new path
_compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson
} catch {
// old path
_compareJson = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson').compareJson
}
}
return _compareJson(...args)
}
const handledDeep = Symbol('handledDeep')
/**
* @callback nextCallback
* @param {Error|undefined} error
* @returns {Promise<unknown>}
*/
/**
* @param {import('@sap/cds/apis/services').Request} req
* @param {nextCallback} next
* @returns {Promise<number>}
*/
async function onDeep(req, next) {
const { query } = req
if (handledDeep in query) return next()
// REVISIT: req.target does not match the query.INSERT target for path insert
// const target = query.sources[Object.keys(query.sources)[0]]
if (!this.model?.definitions[_target_name4(req.query)]) return next()
if (!hasDeep(query)) return next()
const target = this.infer(query)._target
const beforeData = query.INSERT ? [] : await this.run(getExpandForDeep(query, target, true))
if (query.UPDATE && !beforeData.length) return 0
const queries = getDeepQueries(query, beforeData, target)
// first delete, then update, then insert because of potential unique constraints:
// - deletes never trigger unique constraints, but can prevent them -> execute first
// - updates can trigger and prevent unique constraints -> execute second
// - inserts can only trigger unique constraints -> execute last
await Promise.all(Array.from(queries.deletes.values()).map(query => this.onDELETE({ query, target: query._target })))
await Promise.all(queries.updates.map(query => this.onUPDATE({ query })))
const rootQuery = queries.inserts.get(ROOT)
queries.inserts.delete(ROOT)
const [rootResult] = await Promise.all([
rootQuery && this.onINSERT({ query: rootQuery }),
...Array.from(queries.inserts.values()).map(query => this.onINSERT({ query })),
])
return rootResult ?? beforeData.length
}
const hasDeep = (q) => {
const data = q.INSERT?.entries || (q.UPDATE?.data && [q.UPDATE.data]) || (q.UPDATE?.with && [q.UPDATE.with])
if (data)
for (const c in q._target.compositions) {
for (const row of data) if (row[c] !== undefined) return true
}
}
// unofficial config!
const DEEP_DELETE_MAX_RECURSION_DEPTH =
(cds.env.features.recursion_depth && Number(cds.env.features.recursion_depth)) || 4 // we use 4 here as our test data has a max depth of 3
// IMPORTANT: Skip only if @cds.persistence.skip is `true` → e.g. this skips skipping targets marked with @cds.persistence.skip: 'if-unused'
const _hasPersistenceSkip = target => target?.['@cds.persistence.skip'] === true
const getColumnsFromDataOrKeys = (data, target) => {
if (Array.isArray(data)) {
// loop and get all columns from current level
const columns = new Set()
data.forEach(row =>
Object.keys(row || target.keys)
.filter(propName => !target.elements[propName]?.isAssociation)
.forEach(entry => {
columns.add(entry)
}),
)
return Array.from(columns).map(c => ({ ref: [c] }))
} else {
// get all columns from current level
return Object.keys(data || target.keys)
.filter(propName => target.elements[propName] && !target.elements[propName].isAssociation)
.map(c => ({ ref: [c] }))
}
}
const _calculateExpandColumns = (target, data, expandColumns = [], elementMap = new Map()) => {
const compositions = target.compositions || {}
if (expandColumns.length === 0) {
// REVISIT: ensure that all keys are included in the expand columns
expandColumns.push(...getColumnsFromDataOrKeys(data, target))
}
for (const compName in compositions) {
let compositionData
if (data === null || (Array.isArray(data) && !data.length)) {
compositionData = null
} else {
compositionData = data[compName]
}
// ignore not provided compositions as nothing happens with them (expect deep delete)
if (compositionData === undefined) {
// fill columns in case
continue
}
const composition = compositions[compName]
const fqn = composition.parent.name + ':' + composition.name
const seen = elementMap.get(fqn)
if (seen && seen >= DEEP_DELETE_MAX_RECURSION_DEPTH) {
// recursion -> abort
return expandColumns
}
let expandColumn = expandColumns.find(expandColumn => expandColumn.ref[0] === composition.name)
if (!expandColumn) {
expandColumn = {
ref: [composition.name],
expand: getColumnsFromDataOrKeys(compositionData, composition._target),
}
expandColumns.push(expandColumn)
}
// expand deep
// Make a copy and do not share the same map among brother compositions
// as we're only interested in deep recursions, not wide recursions.
const newElementMap = new Map(elementMap)
newElementMap.set(fqn, (seen && seen + 1) || 1)
if (composition.is2many) {
// expandColumn.expand = getColumnsFromDataOrKeys(compositionData, composition._target)
if (compositionData === null || compositionData.length === 0) {
// deep delete, get all subitems until recursion depth
_calculateExpandColumns(composition._target, null, expandColumn.expand, newElementMap)
continue
}
for (const row of compositionData) {
_calculateExpandColumns(composition._target, row, expandColumn.expand, newElementMap)
}
} else {
// to one
_calculateExpandColumns(composition._target, compositionData, expandColumn.expand, newElementMap)
}
}
return expandColumns
}
/**
* @param {import('@sap/cds/apis/cqn').Query} query
* @param {import('@sap/cds/apis/csn').Definition} target
*/
const getExpandForDeep = (query, target) => {
const { entity, data = null, where } = query.UPDATE
const columns = _calculateExpandColumns(target, data)
return SELECT(columns).from(entity).where(where)
}
/**
* @param {import('@sap/cds/apis/cqn').Query} query
* @param {unknown[]} dbData
* @param {import('@sap/cds/apis/csn').Definition} target
* @returns
*/
const getDeepQueries = (query, dbData, target) => {
let queryData
if (query.INSERT) {
queryData = query.INSERT.entries
}
if (query.DELETE) {
queryData = []
}
if (query.UPDATE) {
queryData = [query.UPDATE.data]
}
let diff = compareJson(queryData, dbData, target)
if (!Array.isArray(diff)) {
diff = [diff]
}
return _getDeepQueries(diff, target)
}
const _hasManagedElements = target => {
return Object.keys(target.elements).filter(elementName => target.elements[elementName]['@cds.on.update']).length > 0
}
/**
* @param {unknown[]} diff
* @param {import('@sap/cds/apis/csn').Definition} target
* @param {Map<String, Object>} deletes
* @param {Map<String, Object>} inserts
* @param {Object[]} updates
* @param {boolean} [root=true]
* @returns {Object|Boolean}
*/
const _getDeepQueries = (diff, target, deletes = new Map(), inserts = new Map(), updates = [], root = true) => {
// flag to determine if queries were created
let dirty = false
for (const diffEntry of diff) {
if (diffEntry === undefined) continue
let childrenDirty = false
for (const prop in diffEntry) {
// handle deep operations
const propData = diffEntry[prop]
if (target.elements[prop] && _hasPersistenceSkip(target.elements[prop]._target)) {
delete diffEntry[prop]
} else if (target.compositions?.[prop]) {
const arrayed = Array.isArray(propData) ? propData : [propData]
childrenDirty =
arrayed
.map(subEntry =>
_getDeepQueries([subEntry], target.elements[prop]._target, deletes, inserts, updates, false),
)
.some(a => a) || childrenDirty
delete diffEntry[prop]
} else if (diffEntry[prop] === undefined) {
// restore current behavior, if property is undefined, not part of payload
delete diffEntry[prop]
}
}
// handle current entity level
const op = diffEntry._op
delete diffEntry._op
if (diffEntry._old != null) {
delete diffEntry._old
}
if (op === 'create') {
dirty = true
const id = root ? ROOT : target.name
const insert = inserts.get(id)
if (insert) {
insert.INSERT.entries.push(diffEntry)
} else {
const q = INSERT.into(target).entries(diffEntry)
inserts.set(id, q)
}
} else if (op === 'delete') {
dirty = true
const keys = cds.utils
.Object_keys(target.keys)
.filter(key => !target.keys[key].virtual && !target.keys[key].isAssociation)
const keyVals = keys.map(k => ({ val: diffEntry[k] }))
const currDelete = deletes.get(target.name)
if (currDelete) currDelete.DELETE.where[2].list.push({ list: keyVals })
else {
const left = { list: keys.map(k => ({ ref: [k] })) }
const right = { list: [{ list: keyVals }] }
deletes.set(target.name, DELETE.from(target).where([left, 'in', right]))
}
} else if (op === 'update' || (op === undefined && (root || childrenDirty) && _hasManagedElements(target))) {
dirty = true
// TODO do we need the where here?
const keys = target.keys
const cqn = UPDATE(target).with(diffEntry)
for (const key in keys) {
if (keys[key].virtual) continue
if (!keys[key].isAssociation) {
cqn.where(key + '=', diffEntry[key])
}
delete diffEntry[key]
}
cqn.with(diffEntry)
updates.push(cqn)
}
}
return root ? { updates, inserts, deletes } : dirty
}
module.exports = {
onDeep,
hasDeep,
getDeepQueries, // only for testing
getExpandForDeep, // only for testing
}