@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
225 lines (202 loc) • 9.04 kB
JavaScript
const path = require('path')
const parser = require('./migrationtable')
const { BuildError, BuildMessage } = require('../../util')
const { SEVERITY_WARNING } = require('../../constants')
const cds = require('../../../cds'), { compiler: cdsc } = cds
const cdscVersion = `-- generated by cds-compiler version ${cdsc.version()}`
const { getArtifactCdsPersistenceName } = cdsc
const LOG = cds.log('cli|build'), DEBUG = cds.debug('cli|build')
async function compileToHdbMigration(model, beforeImage, srcPath, options = {}) {
const { definitions, migrations, afterImage, migrationTableNames } = _compileToHana(model, options, beforeImage)
const definitionResult = []
for (const { name, suffix, sql } of definitions) {
let definitionEntry = { name, suffix, content: sql }
if (suffix === '.hdbtable') {
if (migrationTableNames.has(name)) {
const migration = migrations.find(migration => migration.name === name)
definitionEntry = await _2migrationtable(srcPath, migration || _emptyMigration(name), sql)
}
}
if (definitionEntry) {
definitionResult.push(definitionEntry)
}
}
return { definitions: definitionResult, afterImage }
}
function getJournalEntities(model, options = {}) {
return cds.reflect(model).all(item => {
if (item.kind === 'entity' && item['@cds.persistence.journal'] === true) {
if (item['@cds.persistence.skip'] === true || item['@cds.persistence.exists'] === true) {
options.messages?.push(new BuildMessage(`[hdbmigrationtable] annotation @cds.persistence.journal skipped for entity '${item.name}' as persistence exists`, SEVERITY_WARNING))
}
return true
}
return false
})
}
function getMigrationTableName(entity, model) {
return getArtifactCdsPersistenceName(entity.name, 'quoted', model, 'hana')
}
function _compileToHana(model, options, beforeImage) {
let definitions = []
let migrations = []
let afterImage
const result = cds.compile.to.hana(model, options, beforeImage);
for (const [content, { file }] of result) {
switch (file) {
case 'deletions.json':
break
case 'migrations.json':
migrations = content
break
case 'afterImage.json':
afterImage = content
break
default: {
const { name, ext: suffix } = path.parse(file)
definitions.push({ name, suffix, sql: content })
}
}
}
let migrationTableNames = new Set()
if (afterImage) {
// 1. determine journal file names before _filterJournalArtifacts is called as getArtifactCdsPersistenceName
// might no longer return the proper file name for the reduced CSN, e.g. for draft entities
migrationTableNames = _getMigrationTableNames(afterImage, options)
// 2. leave only persisted journal-related entities in afterImage
afterImage = _filterJournalArtifacts(afterImage)
}
_debug(definitions, migrations, migrationTableNames)
return {
definitions, migrations, afterImage, migrationTableNames
}
}
/**
* Returns an object providing access to the .hdbmigrationtable file content and its corresponding filename.
*
* @param {String} srcPath Fully qualified path of the directory containing .hdbmigrationtable files
* @param {String} migration the migration descriptor
* @param {String} tableSql SQL TABLE definition
* @returns {Promise<Object>} Providing access to 'content' and 'fileName'.
*/
async function _2migrationtable(srcPath, migration, tableSql) {
let migrationTableModel = null
const file = path.join(srcPath, migration.name + migration.suffix)
try {
migrationTableModel = await parser.read(file)
} catch (e) {
// abort build in order to ensure consistent afterImage model state / hdbmigrationtable file state
throw new BuildError(`${path.relative(process.cwd(), file)}: ${e.message}`)
}
if (migrationTableModel) {
// adding new changeset if change exist, ignore otherwise
const migrationEntry = _getNewMigrationEntry(migration.changeset, migrationTableModel.versionNumber)
if (migrationEntry) {
const versionNumber = migrationTableModel.versionNumber + 1
const migrationCount = migrationTableModel.migrations.entries.length
const migrations = `${migrationEntry.content}${migrationCount > 0 ? '\n' : ''}${migrationTableModel.migrations.toString()}${migrationCount > 0 ? '\n' : ''}`
return {
name: migration.name,
suffix: migration.suffix,
content: `== version=${versionNumber}\n${tableSql}\n\n${migrations}`,
changed: true,
dropColumns: migrationEntry.dropColumns
}
} else {
// existing migration file version
return { name: migration.name, suffix: migration.suffix, content: migrationTableModel.toString(), changed: false }
}
}
// initial migration file version
return { name: migration.name, suffix: migration.suffix, content: `== version=1\n${tableSql}\n`, changed: true }
}
function _getNewMigrationEntry(changeset, currentVersion) {
if (changeset && changeset.length > 0) {
const dropColumns = changeset.some(e => e.drop)
const manualChange = changeset.some(e => !e.sql)
const enableDrop = cds.env.hana?.journal?.['enable-drop']
const content = changeset.reduce((acc, e) => {
if (!acc) {
acc = `== migration=${currentVersion + 1}\n`
acc += `${cdscVersion}\n`
if (dropColumns && enableDrop !== true) {
acc += `>>>>> Manual resolution required - DROP statements causing data loss are disabled by default.
>>>>> You may either:
>>>>> uncomment statements to allow incompatible changes, or
>>>>> refactor statements, e.g. replace DROP/ADD by single RENAME statement
>>>>> After manual resolution delete all lines starting with >>>>>\n`
} else if (manualChange) {
acc += `>>>>> Manual resolution required - insert ALTER statement(s) as described below.
>>>>> After manual resolution delete all lines starting with >>>>>\n`
}
}
if (e.sql) {
if (e.drop && enableDrop !== true) {
acc += `${e.sql.replace(/^/gm, '-- ')}\n`;
} else {
acc += `${e.sql}\n`
}
} else {
acc +=
`>>>>> Insert ALTER statement for: ${e.description}\n`
}
return acc
}, null)
return { dropColumns, content }
}
return null
}
function _emptyMigration(name) {
return { name, suffix: ".hdbmigrationtable", changeset: [] }
}
function _getMigrationTableNames(model, options) {
const migrationTableNames = getJournalEntities(model, options).map(entity => getMigrationTableName(entity, model))
DEBUG?.(`\n[hdbmigrationtable] found ${migrationTableNames.length} model entities annotated with '@cds.persistence.journal`)
DEBUG?.(`[hdbmigrationtable] ${migrationTableNames.join(', ')}\n`)
return new Set(migrationTableNames)
}
// only keep journal related entities
function _filterJournalArtifacts(afterImage) {
if (!afterImage) {
return
}
const dict = afterImage.definitions
for (const name in dict) {
if (!_isPersistedAsJournalTable(dict[name])) {
delete dict[name]
}
}
// mark CSN as modified by cds
afterImage.meta.build = `CDS Build v${cds.version}`
return afterImage
}
// see cds-compiler/lib/model/csnUtils.js#isPersistedAsTable
function _isPersistedAsJournalTable(artifact) {
return (
artifact.kind === 'entity' &&
artifact['@cds.persistence.journal'] === true &&
!artifact.abstract &&
artifact['@cds.persistence.skip'] !== true && // might have value 'if-unused'
artifact['@cds.persistence.exists'] !== true &&
(!artifact.query && !artifact.projection || artifact['@cds.persistence.table'] === true)
)
}
function _debug(definitions, migrations, journalFileNames) {
if (LOG._debug) {
DEBUG('compile.to.hana returned')
for (const { name, suffix, sql } of definitions) {
if (suffix === '.hdbtable' && journalFileNames.has(name)) {
const migration = migrations.find(migration => migration.name === name)
DEBUG(`File ${name + '.hdbmigrationtable'} - ${migration ? migration.changeset.length : 0} new changes
${sql}
${migration ? migration.changeset.map(change => change.sql).join('\n') : 'Empty changeset'}
`)
}
}
}
}
module.exports = {
compileToHdbMigration,
getJournalEntities,
getMigrationTableName
}