@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
230 lines (201 loc) • 8.45 kB
JavaScript
const cds = require('../../cds')
const { getFrom } = require('../../../../lib/compile/for/flows')
const FLOW_STATUS = '@flow.status'
const FROM = '@from'
const TO = '@to'
const FLOW_PREVIOUS = '$flow.previous'
const $transitions_ = Symbol.for('transitions_')
function isCurrentStatusInFrom(result, action, statusElementName, statusEnum) {
const fromList = getFrom(action)
const allowed = fromList.filter(from => {
const value = from['#'] ? (statusEnum[from['#']]?.val ?? statusEnum[from['#']]['$path'].at(-1)) : from
return result[statusElementName] === value
})
return allowed.length
}
async function checkStatus(subject, action, statusElementName, statusEnum) {
const result = await SELECT.one.from(subject)
if (!result) cds.error(404)
const allowed = isCurrentStatusInFrom(result, action, statusElementName, statusEnum)
if (!allowed) {
const from = getFrom(action)
const fromValues = JSON.stringify(from.flatMap(el => Object.values(el)))
cds.error({
status: 409,
message: from.length > 1 ? 'INVALID_FLOW_TRANSITION_MULTI' : 'INVALID_FLOW_TRANSITION_SINGLE',
args: [action.name, statusElementName, fromValues]
})
}
}
// REVISIT: what about renamed keys?
const buildUpKeys = async (entity, data, subject) => {
const parentKeys = Object.keys(entity.keys).filter(k => k !== 'IsActiveEntity')
// REVISIT: when do we not hava all keys?
const keyValues =
data && parentKeys.every(key => key in data) ? data : await SELECT.one.from(subject).columns(parentKeys)
const upKeys = {}
for (let i = 0; i < parentKeys.length; i++) {
upKeys[`up__${parentKeys[i]}`] = keyValues[parentKeys[i]]
}
return upKeys
}
const resolveTo = (action, statusEnum) => {
let to = action[TO]
to = to['#'] ? (statusEnum[to['#']].val ?? statusEnum[to['#']]['$path'].at(-1)) : to
return to
}
const handleTransition = async (entity, data, subject, to) => {
const isPrevious = to['='] === FLOW_PREVIOUS
if (isPrevious) {
const upKeys = await buildUpKeys(entity, data, subject)
const previous = await SELECT.one
.from(entity[$transitions_].target)
.where({ ...upKeys })
.orderBy('timestamp desc')
.limit(1, 1)
if (!previous)
cds.error({ status: 409, message: 'No change has been made yet, cannot transition to previous status.' })
to = previous.status
}
return to
}
const getStatusInfo = statusElement => {
let statusEnum, statusElementName
if (statusElement.enum) {
statusEnum = statusElement.enum
statusElementName = statusElement.name
} else if (statusElement?._target?.elements['code']) {
statusEnum = statusElement._target.elements['code'].enum
statusElementName = statusElement.name + '_code'
} else {
cds.error({
status: 409,
message: `Status element in ${statusElement.parent.name} must be an enum or target an entity with an enum named "code"`
})
}
return { statusEnum, statusElementName }
}
const from_factory = (entity, action, { statusElementName, statusEnum }) => {
async function handle_flow_from(req) {
const subject = cds.clone(req.subject)
if (entity.name.endsWith('.drafts')) subject.ref[0].id = entity.name
await checkStatus(subject, action, statusElementName, statusEnum)
}
handle_flow_from._initial = true
return handle_flow_from
}
const to___factory = (entity, action, { statusElementName, statusEnum }) => {
return async function handle_flow_to(req, next) {
const res = await next()
let subject = cds.clone(req.subject)
if (entity.name.endsWith('.drafts')) subject.ref[0].id = entity.name
// REVISIT: this only happens on CREATE, where req.subject is a collection
// -> could be avoided if setting the status would be done via req.data
if (!subject.ref[0].id) {
const keys = Object.keys(entity.keys).reduce((acc, cur) => {
acc[cur] = res[cur]
return acc
}, {})
subject = SELECT.from(entity.name, keys).SELECT.from
}
let to = resolveTo(action, statusEnum)
if (Object.prototype.hasOwnProperty.call(entity, $transitions_)) {
to = await handleTransition(entity, req.data, subject, to)
}
await UPDATE(subject).with({ [statusElementName]: to })
// REVISIT: for stack, we now need to delete the last to transitions
if (cds.env.features.flows_history_stack && resolveTo(action, statusEnum)['='] === FLOW_PREVIOUS) {
const upKeys = await buildUpKeys(entity, req.data, req.subject)
const timestamps = SELECT('timestamp')
.from(entity[$transitions_].target)
.where({ ...upKeys })
.orderBy('timestamp desc')
.limit(2)
await DELETE.from(entity[$transitions_].target)
.where({ ...upKeys })
.where(`timestamp in`, timestamps)
}
return res
}
}
/**
* handler registration
*/
module.exports = cds.service.impl(function () {
const b4 = []
const on = []
const after = []
for (const entity of this.entities) {
if (!entity.actions || !entity.elements) continue
const statusElement = Object.values(entity.elements).find(el => el[FLOW_STATUS])
if (!statusElement) continue
const statusInfo = getStatusInfo(statusElement)
// determine and cache target for transitions recording, if any
let base = entity
while (base.__proto__.kind === 'entity') base = base.__proto__
if (base.compositions?.transitions_) {
entity[$transitions_] = base.compositions.transitions_
// track changes on db level
cds.connect.to('db').then(db => {
db.after(['CREATE', 'UPDATE', 'UPSERT'], entity, async (res, req) => {
if ((res.affectedRows ?? res) !== 1) return
if (!(statusInfo.statusElementName in req.data)) return
const status = req.data[statusInfo.statusElementName]
const upKeys = await buildUpKeys(entity, req.data, req.subject)
const last = await SELECT.one.from(entity[$transitions_].target).orderBy('timestamp desc').where(upKeys)
if (last?.status !== status) await UPSERT.into(entity[$transitions_].target).entries({ ...upKeys, status })
})
})
}
// register handlers
for (const action of entity.actions) {
const to__ = action[TO]
const from = action[FROM]
// REVISIT: for CRUD and Draft, we could set status in before handlers (on db level) to save roundtrips
switch (action.name) {
// CRUD
case 'CREATE':
if (to__) on.push([action.name, entity, to___factory(entity, action, statusInfo)])
break
case 'READ':
// nothing to do
break
case 'UPDATE':
if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)])
if (to__) on.push([action.name, entity, to___factory(entity, action, statusInfo)])
break
case 'DELETE':
if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)])
break
// Draft
case 'NEW':
if (to__) on.push(['CREATE', entity.drafts, to___factory(entity.drafts, action, statusInfo)])
break
case 'PATCH':
if (from) b4.push(['UPDATE', entity.drafts, from_factory(entity.drafts, action, statusInfo)])
if (to__) on.push(['UPDATE', entity.drafts, to___factory(entity.drafts, action, statusInfo)])
break
case 'SAVE':
if (from) b4.push([action.name, entity.drafts, from_factory(entity.drafts, action, statusInfo)])
if (to__) on.push([action.name, entity.drafts, to___factory(entity, action, statusInfo)])
break
case 'EDIT':
if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)])
if (to__) on.push([action.name, entity, to___factory(entity.drafts, action, statusInfo)])
break
case 'DISCARD':
if (from) b4.push([action.name, entity.drafts, from_factory(entity.drafts, action, statusInfo)])
break
// custom actions
default:
if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)])
if (to__) on.push([action.name, entity, to___factory(entity, action, statusInfo)])
}
}
}
this.prepend(function () {
for (const each of b4) this.before(...each)
for (const each of on) this.on(...each)
for (const each of after) this.after(...each)
})
})