UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

208 lines (182 loc) 7.21 kB
const cds = require('../cds') module.exports = function to_mermaid (model, options={}) { if (typeof model === 'string' && /mta.*\.ya?ml/.test(model)) return to_mta_mermaid(model) options = Object.assign({ assocnames: false, // include association names elements : false, // include all elements { falsy | 'all'|true | 'keys' } min : false, // minify the model, i.e. remove unused definitions, available in CLI namespaces: true, // group entities by namespace/service queries : false, // include relations for queries service : undefined, // filter service by name, available in CLI direction : undefined // direction of the graph }, cds.env.mermaid ?? {}, options) const elemOpts = options.elements === 'none' ? false : options.elements if (options.min) model = cds.minify(model) model = cds.linked(model) const allServices = model.all('service') // filter by one service if its name is given or only one service is present const srvFilter = options.service ?? (Object.keys(allServices).length === 1 ? allServices[0].name : undefined) if (srvFilter && !model.definitions[srvFilter]) { throw new Error(`Service "${srvFilter}" not found among ${model.all('service').map(s => s.name).sort()}`) } const seenEntities = new Set() const namespaces = {} const entityFilter = e => !e.name.startsWith('cds.') && !e.name.endsWith('.texts') let diag = '' const direction = options.direction ? ` direction ${options.direction}\n` : '' // if requested, filter by one service model.all('service') .filter (srv => !srvFilter || srv.name === srvFilter) .forEach(srv => { namespaces[srv.name] = [] for (let e of model.entities(srv)) if (entityFilter(e)) { namespaces[srv.name].push(e) seenEntities.add(e.name) } }) // group other namespaces/services if (options.namespaces) { model.all('entity') .filter (e => !seenEntities.has(e.name)) .filter (entityFilter) .forEach (e => { const ns = e.name.slice(0,e.name.lastIndexOf('.')) if (!srvFilter || ns === srvFilter) { namespaces[ns] = namespaces[ns] || [] namespaces[ns].push(e) seenEntities.add(e.name) } }) // create namespace groups and classes Object.keys(namespaces) .filter (ns => namespaces[ns].length) // empty namespaces can cause rendering errors, so skip them .forEach(ns => { if (!diag.length) diag = `classDiagram\n${direction}` diag += ` namespace ${ns.replaceAll('.','_')} {\n` namespaces[ns].forEach (e => { const simpleName = e.name.slice(e.name.lastIndexOf('.')+1) diag += ` class \`${e.name}\`` diag += `["${simpleName}"]` // simple name as label if (elemOpts) { diag += ` {\n` if (e.elements) [...e.elements].forEach (el => { if ((elemOpts === 'all' || elemOpts === true) || (el.key && elemOpts == 'keys')) { diag += ` +${el._type?.replace('cds.','')} ${el.name}` diag += `\n` } }) diag += ` }` } diag += `\n` }) diag += ` }\n` }) diag += `\n` } // add associations, either from collected entities or all entities const entities = seenEntities.size ? Array.from(seenEntities).map(name => model.definitions[name]) : model.all('entity').filter (entityFilter) // if no grouping and no service filter is on entities.forEach(e => { if (!diag.length) diag = `classDiagram\n${direction}` if (e.elements) [...e.elements].forEach (el => { if (el instanceof cds.Association) { if (el.target.endsWith('.texts')) return const type = el.type === 'cds.Composition' ? '*-->' : '-->' const card = el.cardinality?.max === '*' ? '"*"' : '' diag += ` \`${e.name}\` ${type} ${card} \`${el.target}\`` if (options.assocnames) { diag += `: ${el.name}` } diag += `\n` } }) // add relations for queries if (e.query && options.queries) { resolveTargets(e.query).forEach(t => { diag += ` \`${e.name}\` ..> \`${t}\`\n` }) } }) return diag.trim() } function resolveTargets (query) { if (query._target) return [ query._target.name ] // simple query, like projection on // SELECT: { from: { args: [{ ref: ['t1'] }, { ref: ['t2'] }], join: ..., on: ... } } const targets = query.SELECT?.from?.args?.filter(arg => arg.ref?.length).map (arg => arg.ref[0]) return targets } function to_mta_mermaid (file) { const content = cds.utils.fs.readFileSync(file, 'utf8') const mta = cds.utils.yaml.parse(content) const { modules = [], resources = [] } = mta const all = new Set([...modules.map(m => m.name), ...resources.map(r => r.name)]) const provided = new Set() const required = new Set() let diag = 'graph LR\n' const appModules = modules.filter(module => module.provides && module.provides.length > 0) const utilityModules = modules.filter(module => !module.provides || module.provides.length === 0) diag += ' subgraph Modules\n' appModules.forEach(module => { diag += ` subgraph ${module.name}\n` module.provides.forEach(provide => { provided.add(provide.name) diag += ` ${provide.name}["${provide.name}"]\n` }) diag += ` end\n` }) utilityModules.forEach(module => { diag += ` ${module.name}["${module.name}"]:::module\n` }) diag += ' end\n' diag += ' subgraph Resources\n' resources.forEach(resource => { diag += ` ${resource.name}["${resource.name}"]:::resource\n` }) diag += ' end\n' modules.forEach(module => { if (module.requires) { module.requires.forEach(require => { required.add(require.name) if (!all.has(require.name) && !provided.has(require.name)) { diag += ` ${module.name} -->|requires| ${require.name}:::missing\n` } else { diag += ` ${module.name} -->|requires| ${require.name}\n` } }) } }) resources.forEach(resource => { if (resource.requires) { resource.requires.forEach(require => { required.add(require.name) if (!all.has(require.name) && !provided.has(require.name)) { diag += ` ${resource.name} -->|requires| ${require.name}:::missing\n` } else { diag += ` ${resource.name} -->|requires| ${require.name}\n` } }) } }) provided.forEach(api => { if (!required.has(api)) { diag += ` class ${api} unused\n` } else { diag += ` class ${api} api\n` } }) modules.forEach(module => { diag += ` class ${module.name} module\n` }) resources.forEach(resource => { diag += ` class ${resource.name} resource\n` }) diag += ` classDef module fill:#36aeff,stroke:#333,stroke-width:2px; classDef resource fill:#2bab43,stroke:#333,stroke-width:2px; classDef api fill:#e03da4,stroke:#333,stroke-width:2px; classDef missing fill:#ffcccc,stroke:#ff0000,stroke-width:2px,color:#ff0000; classDef unused fill:#ffcccc,stroke:#ff0000,stroke-width:2px,color:#ff0000; ` return diag }