@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
208 lines (182 loc) • 7.21 kB
JavaScript
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
}