UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

397 lines (359 loc) 13.6 kB
const cds = require('../../index') const { WELL_KNOWN_EVENTS } = require('../../req/event') const FLOW_STATUS = '@flow.status' const FROM = '@from' const TO = '@to' const FLOW_PREVIOUS = '$flow.previous' const getFrom = action => { let from = action[FROM] return Array.isArray(from) ? from : [from] } function addOperationAvailableToActions(actions, statusEnum, statusElementName) { action: for (const action of Object.values(actions)) { const fromList = getFrom(action) const conditions = [] for (const from of fromList) { const value = from['#'] ? (statusEnum[from['#']]?.val ?? from['#']) : from if (typeof value !== 'string') { const msg = `Error while constructing @Core.OperationAvailable for action "${action.name}" of "${action.parent.name}". Value of @from must either be an enum symbol or a raw string.` cds.log('cds|edmx').warn(msg) continue action } conditions.push(`$self.${statusElementName} = '${value}'`) } const condition = `(${conditions.join(' OR ')})` const parsedXpr = cds.parse.expr(condition) action['@Core.OperationAvailable'] ??= { ...parsedXpr, ['=']: condition } } } function addSideEffectToActions(actions, statusElementName) { for (const action of Object.values(actions)) { const properties = [] if (statusElementName.endsWith('.code') || statusElementName.endsWith('_code')) { const baseName = statusElementName.substring(0, statusElementName.length - 5) properties.push(`in/${baseName}_code`) } else { properties.push(`in/${statusElementName}`) } const sideEffect = '@Common.SideEffects.TargetProperties' if (action[sideEffect]) { action[sideEffect].push(...properties) } else { action[sideEffect] = properties } } } function addActionsToTarget(targetAnnotation, entity, actions) { const identification = (entity[targetAnnotation] ??= []) for (const item of identification) { if ( item.$Type === 'UI.DataFieldForAction' && !Object.hasOwn(item, '@UI.Hidden') && entity['@odata.draft.enabled'] === true ) { item['@UI.Hidden'] = { '=': true, xpr: [{ ref: ['$self', 'IsActiveEntity'] }, '=', { val: false }] } } } const existingActionNames = identification?.filter(item => item.$Type === 'UI.DataFieldForAction').map(item => item.Action.split('.').pop()) ?? [] actions.forEach(action => { const actionName = action.name if (!existingActionNames.includes(actionName)) { identification.push({ $Type: 'UI.DataFieldForAction', Action: `${entity._service.name}.${actionName}`, Label: action['@Common.Label'] ?? action['@title'] ?? `{i18n>${actionName}}`, ...(entity['@odata.draft.enabled'] && { '@UI.Hidden': { '=': true, xpr: [{ ref: ['$self', 'IsActiveEntity'] }, '=', { val: false }] } }) }) } }) } function resolveStatusEnum(csn, codeElem) { if (codeElem.enum !== undefined) return codeElem.enum if (codeElem.type) { const typeDef = csn.definitions[codeElem.type] return typeDef ? typeDef.enum : undefined } } function enhanceCSNwithFlowAnnotations4FE(csn) { for (const definition of Object.values(csn.definitions)) { if (definition.kind !== 'entity') continue const entity = definition if (!entity.elements || !entity.actions) continue for (const [elemName, element] of Object.entries(entity.elements)) { if (!element[FLOW_STATUS]) continue const fromActions = [] const toActions = [] for (const action of Object.values(entity.actions)) { if (action[FROM]) fromActions.push(action) if (action[TO]) toActions.push(action) } if (fromActions.length === 0 && toActions.length === 0) continue addActionsToTarget('@UI.Identification', entity, toActions) addActionsToTarget('@UI.LineItem', entity, toActions) if (element.enum) { // Element is an enum directly addSideEffectToActions(toActions, elemName) addOperationAvailableToActions(fromActions, element.enum, elemName) } else if (element.target) { // Element is an association to a codelist const targetDef = csn.definitions[element.target] if (targetDef?.elements?.code) { const codeElem = targetDef.elements.code const statusEnum = resolveStatusEnum(csn, codeElem) if (statusEnum) { const hasElemNameCode = entity.elements && Object.prototype.hasOwnProperty.call(entity.elements, `${elemName}_code`) const statusElementName = hasElemNameCode ? `${elemName}_code` : `${elemName}.code` addSideEffectToActions(toActions, statusElementName) addOperationAvailableToActions(fromActions, statusEnum, statusElementName) } } } else if (element['@odata.foreignKey4']) { // when compiling to edmx, the foreign key is also annotated with @flow.status, but has no info about the target continue } else { cds.error( `Status element in entity ${entity.name} is not an enum and does not have a valid target with code enum.` ) } } } } module.exports = function cds_compile_for_flows(csn) { const { history_for_flows, skip_flows_validation } = cds.env.features const _requires_history = !history_for_flows ? () => false : history_for_flows === 'all' ? def => { for (const each in def.elements) { if (def.elements[each]['@flow.status']) { return true } } } : def => { for (const each in def.actions) { const action = def.actions[each] if (action && action[TO]?.['='] === FLOW_PREVIOUS) { return true } } } const _validate_status_types = skip_flows_validation ? () => {} : (name, def, status, csn, errors) => { // enum let enumVals if (status.type === 'cds.Association') { const target = csn.definitions[status.target] if (!status.keys || status.keys.length !== 1) { errors.push(`Status element in entity ${name} must have exactly one key when used as association`) return } const code = target.elements[status.keys[0].ref[0]] enumVals = code.enum || csn.definitions[code.type]?.enum } else { enumVals = status.enum ?? csn.definitions[status.type]?.enum } if (!enumVals) { errors.push(`Status element in entity ${name} must be an enum or target an entity with an enum named "code"`) return } // actions for (const each in def.actions) { const action = def.actions[each] const from = action[FROM] if (from !== undefined) { if (Array.isArray(from)) { for (let i = 0; i < from.length; i++) { if (from[i] !== null) { let val = from[i]['#'] if (!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val))) { errors.push(`Invalid ${FROM} value at position ${i} in action ${each}`) } } } } else if (from !== null) { let val = from['#'] if (!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val))) { errors.push(`Invalid ${FROM} value in action ${each}`) } } } const to = action[TO] if (to !== undefined) { if (Array.isArray(to)) { errors.push(`${TO} must not be an array in action ${each}`) } else if (to !== null && to['='] !== FLOW_PREVIOUS) { let val = to['#'] if (!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val))) { errors.push(`Invalid ${TO} value in action ${each}`) } } } } } /* * 1. propagate flows for well-known actions from extensions to definitions */ if (csn.extensions) { for (const ext of csn.extensions) { if (!ext.actions) continue const def = csn.definitions[ext.annotate] if (!def || !def.kind || def.kind !== 'entity') continue for (const each in ext.actions) { if (!(each in WELL_KNOWN_EVENTS)) continue def.actions ??= {} def.actions[each] ??= { kind: 'action' } Object.assign(def.actions[each], ext.actions[each]) } } } const errors = [] const to_be_extended = new Set() for (const name in csn.definitions) { const def = csn.definitions[name] if (!def.kind || def.kind !== 'entity' || !def.elements || !def.actions) continue /* * 2. propagate @flow.status to respective element and make it @readonly */ if (def['@flow.status']?.['=']) { const element = def.elements?.[def['@flow.status']['=']] if (element) { element['@flow.status'] = true if (!('@readonly' in element)) element['@readonly'] = true } } /* * 3. normalize @from and @to annotations */ for (const each in def.actions) { const action = def.actions[each] if (action['@flow.from']) action['@from'] = action['@flow.from'] if (action['@flow.to']) action['@to'] = action['@flow.to'] } /* * 4. validate annotations */ let status = Object.values(def.elements).filter(e => e['@flow.status']) if (status.length === 0) continue if (status.length > 1) { errors.push(`Entity ${name} has multiple status elements. Only one @flow.status element is allowed per entity`) continue } status = status[0] _validate_status_types(name, def, status, csn, errors) /* * 5. automatically apply aspect FlowHistory if needed and not present yet */ if (!_requires_history(def)) continue const projections = _get_projection_stack(name, csn) const base_name = projections.pop() const base = csn.definitions[base_name] if (base.elements?.transitions_) continue //> manually added -> don't interfere // add aspect FlowHistory to db entity to_be_extended.add(base_name) } /* * 6. throw validation errors, if any */ if (errors.length) { if (errors.length === 1) cds.error(errors[0]) else cds.error('MULTIPLE_ERRORS', { details: errors.map(message => ({ message })) }) } /* * 7. apply the extensions */ if (to_be_extended.size) { // REVISIT: ensure sap.common.FlowHistory is there csn.definitions['sap.common.FlowHistory'] ??= JSON.parse(FlowHistory) const extensions = [...to_be_extended].map(extend => ({ extend, includes: ['sap.common.FlowHistory'] })) const dsn = cds.extend(csn).with({ extensions }) // REVISIT: annotate all generated X.transitions_ with @cds.autoexpose: false for (const each of to_be_extended) dsn.definitions[`${each}.transitions_`]['@cds.autoexpose'] = false // hack for "excludes" not possible via extensions for (const name in dsn.definitions) { const _new = dsn.definitions[name] if ( _new.kind !== 'entity' || to_be_extended.has(name.replace(/\.transitions_$/, '')) || (!name.match(/\.transitions_$/) && !_new.elements.transitions_) ) { continue } const _old = csn.definitions[name] if (!_old) delete dsn.definitions[name] else if (_new.elements.transitions_ && !_old.elements.transitions_) delete _new.elements.transitions_ } csn = dsn } /* * 8. exclude transitions_ from draft */ // REVISIT: annotate all X.transitions_ with @odata.draft.enabled: false for (const name in csn.definitions) if (name.endsWith('.transitions_')) csn.definitions[name]['@odata.draft.enabled'] = false return csn } function _get_projection_stack(name, csn, stack = []) { stack.push(name) const def = csn.definitions[name] if (def.projection || def.query) { const base = (def.projection || def.query.SELECT)?.from?.ref?.[0] if (!base) throw new Error(`Unable to determine base entity of ${name}`) return _get_projection_stack(base, csn, stack) } return stack } const FlowHistory = `{ "kind": "aspect", "@cds.persistence.skip": "if-unused", "elements": { "transitions_": { "@odata.draft.enabled": false, "type": "cds.Composition", "cardinality": { "max": "*" }, "targetAspect": { "elements": { "timestamp": { "@cds.on.insert": { "=": "$now" }, "@UI.HiddenFilter": true, "@UI.ExcludeFromNavigationContext": true, "@Core.Immutable": true, "@title": "{i18n>CreatedAt}", "@readonly": true, "key": true, "type": "cds.Timestamp" }, "user": { "@cds.on.insert": { "=": "$user" }, "@UI.HiddenFilter": true, "@UI.ExcludeFromNavigationContext": true, "@Core.Immutable": true, "@title": "{i18n>CreatedBy}", "@readonly": true, "@description": "{i18n>UserID.Description}", "type": "cds.String", "length": 255 }, "status": { "type": "cds.String" }, "comment": { "type": "cds.String" } } } } } }` module.exports.enhanceCSNwithFlowAnnotations4FE = enhanceCSNwithFlowAnnotations4FE