@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
474 lines (434 loc) • 16.4 kB
JavaScript
const cds = require('../../../cds')
const fs = require('fs').promises
const {existsSync} = require('fs')
class MigrationTableParser {
constructor() {
}
async read(filePath, options = { encoding: 'utf8' }) {
if (existsSync(filePath)) {
return this.parse((await fs.readFile(filePath, options)).replace(/\r\n/g, '\n'))
}
return null
}
/**
* Parses the given .hdbmigrationtable file content and returns the @see MigrationTableModel representation.
*
* @param {String} content The .hdbmigrationtable file content.
* @returns {MigrationTableModel} The migration table model representation of the given .hdbmigrationtable file content.
*/
parse(content) {
const lines = content.split('\n')
this._validate(lines)
const table = this._parseTable(lines)
const migrations = this._parseMigrations(lines, table)
return new MigrationTableModel(table, migrations)
}
_validate(lines) {
let isTableBegin = false, isTableEnd = false, isMigration = false
let tVersion, mVersion = -1
for (let idx = 0; idx < lines.length; idx++) {
if (MigrationTableParser._isVersionMarker(lines[idx])) {
tVersion = MigrationTableParser._parseVersionNumber(lines[idx])
if (isTableBegin || isTableEnd || isMigration) {
throw new Error(`Invalid format, version defintion must be very first statement`)
}
} else if (/^\s*(ROW(\s*COLUMN)?|COLUMN)\s*TABLE\s/.test(lines[idx])) {
if (tVersion === -1) {
throw new Error(`Invalid format, version entry not complying to format '^== version=d+'`)
}
if (isTableBegin) {
throw new Error(`Invalid format, multiple TABLE definitions found`)
}
if (isMigration) {
throw new Error(`Invalid format, migrations must not be mixed with TABLE definitions`)
}
isTableBegin = true
} else if (MigrationTableParser._isMigrationMarker(lines[idx])) {
const version = MigrationTableParser._parseVersionNumber(lines[idx])
if (version === -1) {
throw new Error(`Invalid format, migration entry not complying to format '^== version=d+'`)
}
if (version > mVersion) {
mVersion = version
}
if (!isMigration) {
if (!isTableBegin) {
throw new Error(`Invalid format, TABLE statement missing`)
}
// back search for end table
for (let tIdx = idx - 1; tIdx > 0; tIdx--) {
if (MigrationTableParser._isDDL(lines[tIdx])
|| MigrationTableParser._isComment(lines[tIdx])) {
isTableEnd = true
break
}
}
isMigration = true
}
} else if (isTableBegin && !isMigration && idx + 1 === lines.length) {
isTableEnd = true
}
}
if (!isTableBegin) {
throw new Error(`Invalid format, TABLE statement missing`)
}
if (!isTableEnd) {
throw new Error(`Invalid format, TABLE statement not correctly terminated`)
}
if (!isMigration && tVersion > 1) {
throw new Error(`Invalid format, '== migration=${tVersion}' entry missing`)
}
if (mVersion !== -1 && mVersion !== tVersion) {
throw new Error(`Invalid format, migration version ${mVersion} does not match table version ${tVersion}`)
}
}
_parseTable(lines) {
const format = { startLine: -1, endLine: -1 }
for (let idx = 0; idx < lines.length; idx++) {
if (format.startLine === -1) {
if (MigrationTableParser._isVersionMarker(lines[idx])) {
format.startLine = idx
}
} else if (format.endLine === -1) {
let tIdx = -1
if (MigrationTableParser._isMigrationMarker(lines[idx])) {
tIdx = idx - 1
} else if (idx + 1 === lines.length) {
tIdx = idx
}
// back search for end of table, comments belong to table
for (; tIdx > format.startLine; tIdx--) {
if (MigrationTableParser._isDDL(lines[tIdx])
|| MigrationTableParser._isComment(lines[tIdx])) {
format.endLine = tIdx
break
}
}
} else {
break
}
}
if (format.startLine === -1) {
throw new Error(`Invalid format, '== version=' entry missing`)
}
return new MigrationTable(lines, format)
}
_parseMigrations(lines, table) {
const migrations = []
let format = { startLine: -1, endLine: -1 }
for (let idx = table.lines.length; idx < lines.length; idx++) {
let nextMigration = false
if (MigrationTableParser._isMigrationMarker(lines[idx])) {
if (format.startLine === -1) {
format.startLine = idx
} else {
nextMigration = true
}
}
if (format.startLine !== -1 && (nextMigration || (idx + 1) === lines.length)) {
// skip empty lines
for (let mIdx = nextMigration ? idx - 1 : idx; mIdx > format.startLine; mIdx--) {
if (!/^\s*$/.test(lines[mIdx])) {
format.endLine = mIdx
break
}
}
migrations.push(new Migration(lines, format))
if (nextMigration) {
format = { startLine: idx, endLine: -1 }
}
}
}
return new Migrations(migrations)
}
// any lines that do not start with a comment or conflict marker and do not represent version tags
static _isDDL(line) {
return !/^\s*--|^\s*==|^\s*$|^\s*>>>>>/.test(line)
}
static _isComment(line) {
return /^\s*--/.test(line)
}
static _isConflictMarker(line) {
return /^\s*>>>>>/.test(line)
}
static _isVersionMarker(line) {
return /^\s*== version=\d+\s*$/.test(line)
}
static _isMigrationMarker(line) {
return /^\s*== migration=\d+\s*$/.test(line)
}
static _parseVersionNumber(line) {
let version = -1;
const match = line.match(/(^\s*== version=|^\s*== migration=)(\d+)\s*$/)
if (match && match.length === 3) {
version = parseInt(match[2])
}
if (version === -1) {
throw new Error(`Invalid format - ${line} is malformed, format '^== version=d+'|'^== migration=d+' expected`)
}
return version
}
}
/**
* Model representation of an entire .hdbmigrationtable file.
* <p>
* The MigrationTableModel provides access to underlying file contents using a well-defined API.
*/
class MigrationTableModel {
constructor(table, migrations) {
this._table = table
this._migrations = migrations
}
get versionNumber() {
return this._table.versionNumber
}
get table() {
return this._table
}
get migrations() {
return this._migrations
}
toString() {
return `${this._table.toString()}${(this._migrations.entries.length > 0 ? '\n\n' : '') + this._migrations.toString()}`
}
/**
* Incorporates migration versions from base (since the time their histories diverged from the current migration version)
* into the current branch representing the extension.
* <code>extension.merge(base, targetTable)</code>
*
* A---B---E---F extension X---Y extension
* / /
* A---B---C base A---B base
*
* || upgrade extension with base - migration version C is inserted into extension, its version number is updated,
* || its version number is rebased on the version number of the extension
* \/
*
* A---B---E---F---C extension A---B---X---Y extension
* / /
* A---B---C base A---B base
*
* @param {MigrationTableModel} base The new base migration table version from which all missing migration versions
* will be taken and merged into the new migration table result.
* @param {MigrationTable} targetTable The column table representing the merge result. Technically speaking - it is
* retrieved from the migration table file created by cds build based on the final CDS model version.
* @returns {MigrationTableModel} The merge result containg missing migrations from base.
*/
merge(base, targetTable, addBaseVersionAnnotation) {
let idxBase = this._findCommonBaseMigrationIdx(base)
const mergeResult = new MigrationTableModel(targetTable, this.migrations.clone())
// insert all migration versions since the histories diverged
for (let idx = idxBase >= 0 ? idxBase - 1 : base.migrations.entries.length - 1, versionNumber = this.versionNumber + 1; idx >= 0; idx--, versionNumber++) {
const migration = base.migrations.entries[idx].clone();
if (addBaseVersionAnnotation) {
migration._addBaseMigrationVersion(migration.versionNumber)
}
// update migration version number and insert at beginning
migration.versionNumber = mergeResult.table.versionNumber = versionNumber
mergeResult.migrations.entries.splice(0, 0, migration)
}
return mergeResult
}
clone() {
return new MigrationTableModel(this.table.clone(), this.migrations.clone())
}
/**
* Returns the index of the common base migration version (since the time their histories diverged)
* or -1 if there is no common base. The index points to the base migration table model.
* @param {MigrationTableModel} base The new base migration table version.
* @returns The index of the common base migration, -1 if there is no common base.
*/
_findCommonBaseMigrationIdx(base) {
// get the index based on the annotation '-- migration-base=<version number>'
const extMigration = this.migrations.entries.find(extMigration => extMigration._getBaseMigrationVersion() !== -1)
// get the index based on DDL equality
const idxBase = base.migrations.entries.findIndex(baseMigration => {
return this.migrations.entries.some(extMigration => this._compareMigrations(baseMigration, extMigration))
})
if (!extMigration && idxBase === -1) {
// no common base version - all good
return -1
}
if (extMigration && idxBase !== -1 && extMigration._getBaseMigrationVersion() === base.migrations.entries[idxBase].versionNumber) {
// both indices refer to the same base version - all good
return idxBase
}
// differences encountered, cause might be:
// 1. older versions may not add 'migration-base' annotations
// 2. the same DDL statements might exist in different migrations
if (!extMigration && idxBase !== -1) {
// if migration-base annotations do not exist use equality of DDL statements
// older versions did not add 'migration-base' annotations - most probably all good
return idxBase
}
if (extMigration) {
// favor 'migration-base' annotation over DDL statement equality
const baseVersion = extMigration._getBaseMigrationVersion()
const idxBase = base.migrations.entries.findIndex(baseMigration => baseMigration.versionNumber === baseVersion)
if (idxBase > -1) {
return idxBase
}
}
return idxBase
}
/**
* Migrations are identical if the corresponding DDL statements are identical,
* ignoring order and ignoring comments.
*/
_compareMigrations(m1, m2) {
if (m1.ddl.length !== m2.ddl.length) {
return false
}
const mDdl2 = m2.ddl.map(entry => this._compactLine(entry))
return m1.ddl.some(entry => mDdl2.includes(this._compactLine(entry)))
}
/**
* Delete comments and return only those lines representing DDL statements.
*/
_compactLine(line) {
return line.replaceAll(' ', '')
}
}
/**
* Representation of the table SQL statement within an .hdbmigrationtable file.
*/
class MigrationTable {
/**
* Constructor
* @param {*} lines If the format parameter is passed the given lines represent all lines of the migration table file where format defines start and the end line.
* If the format is ommitted the lines parameter only contains the lines of this table definition.
* @param {*} format The format defines the start and end line of the table definition within the given lines.
*/
constructor(lines, format) {
lines = Array.isArray(lines) ? lines : lines.split('\n')
if (format) {
if (format.startLine < 0 || format.startLine > format.endLine) {
throw Error("Invalid format of DDL table statement - end line has to be larger that start line")
}
this._lines = lines.slice(format.startLine, format.endLine + 1)
} else {
this._lines = lines
}
this._versionNumber = MigrationTableParser._parseVersionNumber(this._lines[0])
}
get versionNumber() {
return this._versionNumber
}
set versionNumber(newVersion) {
if (newVersion < 1) {
throw new Error(`Invalid migration table version number ${newVersion} `)
}
this._lines[0] = this._lines[0].replace(this.versionNumber, newVersion)
this._versionNumber = newVersion
}
get lines() {
return this._lines
}
toString() {
return this.lines.join('\n')
}
clone() {
return new MigrationTable(cds.clone(this.lines))
}
}
class Migrations {
constructor(migrations = []) {
this._migrations = migrations.sort((a, b) => b.versionNumber - a.versionNumber)
}
get versionNumber() {
return this._migrations.length > 0 ? this._migrations[0].versionNumber : 1
}
get entries() {
return this._migrations
}
toString() {
return this._migrations.map(migration => migration.toString()).join('\n\n')
}
clone() {
return new Migrations(this._migrations.map(migration => migration.clone()))
}
}
/**
* Representation of a migration version within an .hdbmigrationtable file.
* <p>
* The first line of a migration represents the version definition.
* A migration may contain multiple line comments and any number of DDL statements.
* The latter can be accessed using the changeset method.
*/
class Migration {
/**
* Constructor
* @param {*} lines If the format parameter is passed the given lines represent all lines of the migration table file where format defines start and the end line.
* If the format is ommitted the lines parameter only contains the lines of this migration version.
* @param {*} format The format defines the start and end line of the migration version within the given lines.
*/
constructor(lines, format) {
if (format) {
if (format.startLine < 0 || format.startLine > format.endLine) {
throw Error("Invalid migration format")
}
this._lines = lines.slice(format.startLine, format.endLine + 1)
} else {
this._lines = lines
}
this._versionNumber = MigrationTableParser._parseVersionNumber(this.lines[0])
}
/**
* Returns the version number of this migration.
*/
get versionNumber() {
return this._versionNumber
}
set versionNumber(newVersion) {
if (newVersion < 1) {
throw new Error(`Invalid migration table version number ${newVersion} `)
}
this._lines[0] = this._lines[0].replace(this.versionNumber, newVersion)
this._versionNumber = newVersion
}
/**
* Returns the entire content of this migration including version and any line comments.
*/
get lines() {
return this._lines
}
/**
* Returns the changeset containing the DDL statements of this migration.
*/
get changeset() {
return this._lines.filter(line => !MigrationTableParser._isMigrationMarker(line))
}
/**
* Returns the DDL statements of this changeset. Any lines that do not start with a comment or conflict marker
* and do not represent version tags are treated as valid DDL statements.
*/
get ddl() {
return this.changeset.filter(line => MigrationTableParser._isDDL(line))
}
_addBaseMigrationVersion(versionNumber) {
this._addComment(1, `-- migration-base=${versionNumber}`)
}
_getBaseMigrationVersion() {
let versionNumber = -1
if (this._lines.length > 1) {
const match = this._lines[1].match(/(^\s*-- migration-base=)(\d+)\s*$/)
if (match && match.length === 3) {
versionNumber = parseInt(match[2])
}
}
return versionNumber
}
_addComment(start, comment) {
this._lines.splice(start, 0, comment)
}
/**
* Returns the string representation of this migration.
*/
toString() {
return this.lines.join('\n')
}
clone() {
return new Migration(cds.clone(this.lines))
}
}
module.exports = new MigrationTableParser()