UNPKG

@sap/cds-dk

Version:

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

474 lines (434 loc) 16.4 kB
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()