0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00
ghost/core/server/data/schema/commands.js
Daniel Lockyer d8e58b53d0
Altered migration utility to always use algorithm=copy for MySQL (#14699)
refs https://github.com/TryGhost/Toolbox/issues/309
refs 2bfd8f8b7e

- we've previously had issues when dropping a column on `posts`
  because it's a large table and it can take a veeeeeeery long time with
  the default SQL produced by Knex
- we found a magic incantation that makes it super speedy (context in
  commit above) => `, algorithm=copy`
- for that migration, we did it all manually but we can now change the
  utility to always append this to the generated SQL so we don't have to
  think about the specific table size when adding or dropping columns
- this changes the `addColumn` and `dropColumn` utilities to append the
  string to SQL in MySQL, or just executes the SQL for SQLite
2022-05-09 10:16:36 +01:00

466 lines
18 KiB
JavaScript

const _ = require('lodash');
const Promise = require('bluebird');
const logging = require('@tryghost/logging');
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const db = require('../db');
const DatabaseInfo = require('@tryghost/database-info');
const schema = require('./schema');
const clients = require('./clients');
const messages = {
hasPrimaryKeySQLiteError: 'Must use hasPrimaryKeySQLite on an SQLite3 database',
hasForeignSQLite3: 'Must use hasForeignSQLite3 on an SQLite3 database',
noSupportForDatabase: 'No support for database client {client}'
};
function addTableColumn(tableName, table, columnName, columnSpec = schema[tableName][columnName]) {
let column;
// creation distinguishes between text with fieldtype, string with maxlength and all others
if (columnSpec.type === 'text' && Object.prototype.hasOwnProperty.call(columnSpec, 'fieldtype')) {
column = table[columnSpec.type](columnName, columnSpec.fieldtype);
} else if (columnSpec.type === 'string') {
if (Object.prototype.hasOwnProperty.call(columnSpec, 'maxlength')) {
column = table[columnSpec.type](columnName, columnSpec.maxlength);
} else {
column = table[columnSpec.type](columnName, 191);
}
} else {
column = table[columnSpec.type](columnName);
}
if (Object.prototype.hasOwnProperty.call(columnSpec, 'nullable') && columnSpec.nullable === true) {
column.nullable();
} else {
column.nullable(false);
}
if (Object.prototype.hasOwnProperty.call(columnSpec, 'primary') && columnSpec.primary === true) {
column.primary();
}
if (Object.prototype.hasOwnProperty.call(columnSpec, 'unique') && columnSpec.unique) {
column.unique();
}
if (Object.prototype.hasOwnProperty.call(columnSpec, 'unsigned') && columnSpec.unsigned) {
column.unsigned();
}
if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) {
// check if table exists?
column.references(columnSpec.references);
}
if (Object.prototype.hasOwnProperty.call(columnSpec, 'cascadeDelete') && columnSpec.cascadeDelete === true) {
column.onDelete('CASCADE');
}
if (Object.prototype.hasOwnProperty.call(columnSpec, 'defaultTo')) {
column.defaultTo(columnSpec.defaultTo);
}
if (Object.prototype.hasOwnProperty.call(columnSpec, 'index') && columnSpec.index === true) {
column.index();
}
}
function setNullable(tableName, column, transaction = db.knex) {
return transaction.schema.table(tableName, function (table) {
table.setNullable(column);
});
}
function dropNullable(tableName, column, transaction = db.knex) {
return transaction.schema.table(tableName, function (table) {
table.dropNullable(column);
});
}
async function addColumn(tableName, column, transaction = db.knex, columnSpec) {
const addColumnBuilder = transaction.schema.table(tableName, function (table) {
addTableColumn(tableName, table, column, columnSpec);
});
// Use the default flow for SQLite because .toSQL() is tricky with SQLite when
// it does the table dance
if (DatabaseInfo.isSQLite(transaction)) {
await addColumnBuilder;
return;
}
let sql = addColumnBuilder.toSQL()[0].sql;
if (DatabaseInfo.isMySQL(transaction)) {
// Guard against an ending semicolon
sql = sql.replace(/;\s*$/, '') + ', algorithm=copy';
}
await transaction.raw(sql);
}
async function dropColumn(tableName, column, transaction = db.knex, columnSpec = {}) {
if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) {
const [toTable, toColumn] = columnSpec.references.split('.');
await dropForeign({fromTable: tableName, fromColumn: column, toTable, toColumn, transaction});
}
const dropTableBuilder = transaction.schema.table(tableName, function (table) {
table.dropColumn(column);
});
// Use the default flow for SQLite because .toSQL() is tricky with SQLite when
// it does the table dance
if (DatabaseInfo.isSQLite(transaction)) {
await dropTableBuilder;
return;
}
let sql = dropTableBuilder.toSQL()[0].sql;
if (DatabaseInfo.isMySQL(transaction)) {
// Guard against an ending semicolon
sql = sql.replace(/;\s*$/, '') + ', algorithm=copy';
}
await transaction.raw(sql);
}
/**
* Adds an unique index to a table over the given columns.
*
* @param {string} tableName - name of the table to add unique constraint to
* @param {string|[string]} columns - column(s) to form unique constraint with
* @param {import('knex')} transaction - connection object containing knex reference
*/
async function addUnique(tableName, columns, transaction = db.knex) {
try {
logging.info(`Adding unique constraint for '${columns}' in table '${tableName}'`);
return await transaction.schema.table(tableName, function (table) {
table.unique(columns);
});
} catch (err) {
if (err.code === 'SQLITE_ERROR') {
logging.warn(`Constraint for '${columns}' already exists for table '${tableName}'`);
return;
}
if (err.code === 'ER_DUP_KEYNAME') {
logging.warn(`Constraint for '${columns}' already exists for table '${tableName}'`);
return;
}
throw err;
}
}
/**
* Drops a unique key constraint from a table.
*
* @param {string} tableName - name of the table to drop unique constraint from
* @param {string|[string]} columns - column(s) unique constraint was formed
* @param {import('knex')} transaction - connection object containing knex reference
*/
async function dropUnique(tableName, columns, transaction = db.knex) {
try {
logging.info(`Dropping unique constraint for '${columns}' in table '${tableName}'`);
return await transaction.schema.table(tableName, function (table) {
table.dropUnique(columns);
});
} catch (err) {
if (err.code === 'SQLITE_ERROR') {
logging.warn(`Constraint for '${columns}' does not exist for table '${tableName}'`);
return;
}
if (err.code === 'ER_CANT_DROP_FIELD_OR_KEY') {
logging.warn(`Constraint for '${columns}' does not exist for table '${tableName}'`);
return;
}
throw err;
}
}
/**
* Checks if a foreign key exists in a table over the given columns.
*
* @param {Object} configuration - contains all configuration for this function
* @param {string} configuration.fromTable - name of the table to add the foreign key to
* @param {string} configuration.fromColumn - column of the table to add the foreign key to
* @param {string} configuration.toTable - name of the table to point the foreign key to
* @param {string} configuration.toColumn - column of the table to point the foreign key to
* @param {import('knex')} configuration.transaction - connection object containing knex reference
*/
async function hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, transaction = db.knex}) {
if (!DatabaseInfo.isSQLite(transaction)) {
throw new errors.InternalServerError({
message: tpl(messages.hasForeignSQLite3)
});
}
const foreignKeys = await transaction.raw(`PRAGMA foreign_key_list('${fromTable}');`);
const hasForeignKey = foreignKeys.some(foreignKey => foreignKey.table === toTable && foreignKey.from === fromColumn && foreignKey.to === toColumn);
return hasForeignKey;
}
/**
* Adds a foreign key to a table.
*
* @param {Object} configuration - contains all configuration for this function
* @param {string} configuration.fromTable - name of the table to add the foreign key to
* @param {string} configuration.fromColumn - column of the table to add the foreign key to
* @param {string} configuration.toTable - name of the table to point the foreign key to
* @param {string} configuration.toColumn - column of the table to point the foreign key to
* @param {Boolean} configuration.cascadeDelete - adds the "on delete cascade" option if true
* @param {import('knex')} configuration.transaction - connection object containing knex reference
*/
async function addForeign({fromTable, fromColumn, toTable, toColumn, cascadeDelete = false, transaction = db.knex}) {
if (DatabaseInfo.isSQLite(transaction)) {
const foreignKeyExists = await hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, transaction});
if (foreignKeyExists) {
logging.warn(`Skipped adding foreign key from ${fromTable}.${fromColumn} to ${toTable}.${toColumn} - already exists`);
return;
}
}
try {
logging.info(`Adding foreign key from ${fromTable}.${fromColumn} to ${toTable}.${toColumn}`);
//disable and re-enable foreign key checks on sqlite because of https://github.com/knex/knex/issues/4155
let foreignKeysEnabled;
if (DatabaseInfo.isSQLite(transaction)) {
foreignKeysEnabled = await db.knex.raw('PRAGMA foreign_keys;');
if (foreignKeysEnabled[0].foreign_keys) {
await db.knex.raw('PRAGMA foreign_keys = OFF;');
}
}
await transaction.schema.table(fromTable, function (table) {
if (cascadeDelete) {
table.foreign(fromColumn).references(`${toTable}.${toColumn}`).onDelete('CASCADE');
} else {
table.foreign(fromColumn).references(`${toTable}.${toColumn}`);
}
});
if (DatabaseInfo.isSQLite(transaction)) {
if (foreignKeysEnabled[0].foreign_keys) {
await db.knex.raw('PRAGMA foreign_keys = ON;');
}
}
} catch (err) {
if (err.code === 'ER_DUP_KEY' || err.code === 'ER_FK_DUP_KEY' || err.code === 'ER_FK_DUP_NAME') {
logging.warn(`Skipped adding foreign key from ${fromTable}.${fromColumn} to ${toTable}.${toColumn} - already exists`);
return;
}
throw err;
}
}
/**
* Drops a foreign key from a table.
*
* @param {Object} configuration - contains all configuration for this function
* @param {string} configuration.fromTable - name of the table to add the foreign key to
* @param {string} configuration.fromColumn - column of the table to add the foreign key to
* @param {string} configuration.toTable - name of the table to point the foreign key to
* @param {string} configuration.toColumn - column of the table to point the foreign key to
* @param {import('knex')} configuration.transaction - connection object containing knex reference
*/
async function dropForeign({fromTable, fromColumn, toTable, toColumn, transaction = db.knex}) {
if (DatabaseInfo.isSQLite(transaction)) {
const foreignKeyExists = await hasForeignSQLite({fromTable, fromColumn, toTable, toColumn, transaction});
if (!foreignKeyExists) {
logging.warn(`Skipped dropping foreign key from ${fromTable}.${fromColumn} to ${toTable}.${toColumn} - does not exist`);
return;
}
}
try {
logging.info(`Dropping foreign key from ${fromTable}.${fromColumn} to ${toTable}.${toColumn}`);
//disable and re-enable foreign key checks on sqlite because of https://github.com/knex/knex/issues/4155
let foreignKeysEnabled;
if (DatabaseInfo.isSQLite(transaction)) {
foreignKeysEnabled = await db.knex.raw('PRAGMA foreign_keys;');
if (foreignKeysEnabled[0].foreign_keys) {
await db.knex.raw('PRAGMA foreign_keys = OFF;');
}
}
await transaction.schema.table(fromTable, function (table) {
table.dropForeign(fromColumn);
});
if (DatabaseInfo.isSQLite(transaction)) {
if (foreignKeysEnabled[0].foreign_keys) {
await db.knex.raw('PRAGMA foreign_keys = ON;');
}
}
} catch (err) {
if (err.code === 'ER_CANT_DROP_FIELD_OR_KEY') {
logging.warn(`Skipped dropping foreign key from ${fromTable}.${fromColumn} to ${toTable}.${toColumn} - does not exist`);
return;
}
throw err;
}
}
/**
* Checks if primary key index exists in a table over the given columns.
*
* @param {string} tableName - name of the table to check primary key constraint on
* @param {import('knex')} transaction - connection object containing knex reference
*/
async function hasPrimaryKeySQLite(tableName, transaction = db.knex) {
if (!DatabaseInfo.isSQLite(transaction)){
throw new errors.InternalServerError({
message: tpl(messages.hasPrimaryKeySQLiteError)
});
}
const rawConstraints = await transaction.raw(`PRAGMA index_list('${tableName}');`);
const tablePrimaryKey = rawConstraints.find(c => c.origin === 'pk');
return tablePrimaryKey;
}
/**
* Adds an primary key index to a table over the given columns.
*
* @param {string} tableName - name of the table to add primaykey constraint to
* @param {string|[string]} columns - column(s) to form primary key constraint with
* @param {import('knex')} transaction - connection object containing knex reference
*/
async function addPrimaryKey(tableName, columns, transaction = db.knex) {
if (DatabaseInfo.isSQLite(transaction)) {
const primaryKeyExists = await hasPrimaryKeySQLite(tableName, transaction);
if (primaryKeyExists) {
logging.warn(`Primary key constraint for '${columns}' already exists for table '${tableName}'`);
return;
}
}
try {
logging.info(`Adding primary key constraint for '${columns}' in table '${tableName}'`);
return await transaction.schema.table(tableName, function (table) {
table.primary(columns);
});
} catch (err) {
if (err.code === 'ER_MULTIPLE_PRI_KEY') {
logging.warn(`Primary key constraint for '${columns}' already exists for table '${tableName}'`);
return;
}
throw err;
}
}
/**
* Adds a table according to the provided spec, or falls back to the current schema
*
* NOTE: this function does NOT check if the table already exists - use the migration
* utils if you want that
*
* @param {String} table - name of the table to create
* @param {import('knex').Transaction} transaction - connection to the DB
* @param {Object} [tableSpec] - table schema to generate table with
*/
function createTable(table, transaction = db.knex, tableSpec = schema[table]) {
return transaction.schema.createTable(table, function (t) {
Object.keys(tableSpec)
.filter(column => !(column.startsWith('@@')))
.forEach(column => addTableColumn(table, t, column, tableSpec[column]));
if (tableSpec['@@INDEXES@@']) {
tableSpec['@@INDEXES@@'].forEach(index => t.index(index));
}
if (tableSpec['@@UNIQUE_CONSTRAINTS@@']) {
tableSpec['@@UNIQUE_CONSTRAINTS@@'].forEach(unique => t.unique(unique));
}
});
}
function deleteTable(table, transaction = db.knex) {
return transaction.schema.dropTableIfExists(table);
}
function getTables(transaction = db.knex) {
const client = transaction.client.config.client;
if (_.includes(_.keys(clients), client)) {
return clients[client].getTables(transaction);
}
return Promise.reject(tpl(messages.noSupportForDatabase, {client: client}));
}
function getIndexes(table, transaction = db.knex) {
const client = transaction.client.config.client;
if (_.includes(_.keys(clients), client)) {
return clients[client].getIndexes(table, transaction);
}
return Promise.reject(tpl(messages.noSupportForDatabase, {client: client}));
}
function getColumns(table, transaction = db.knex) {
const client = transaction.client.config.client;
if (_.includes(_.keys(clients), client)) {
return clients[client].getColumns(table);
}
return Promise.reject(tpl(messages.noSupportForDatabase, {client: client}));
}
function checkTables(transaction = db.knex) {
const client = transaction.client.config.client;
if (DatabaseInfo.isMySQL(transaction)) {
return clients[client].checkPostTable();
}
}
function createColumnMigration(...migrations) {
async function runColumnMigration(conn, migration) {
const {
table,
column,
dbIsInCorrectState,
operation,
operationVerb,
columnDefinition
} = migration;
const hasColumn = await conn.schema.hasColumn(table, column);
const isInCorrectState = dbIsInCorrectState(hasColumn);
if (isInCorrectState) {
logging.warn(`${operationVerb} ${table}.${column} column - skipping as table is correct`);
} else {
logging.info(`${operationVerb} ${table}.${column} column`);
await operation(table, column, conn, columnDefinition);
}
}
return async function columnMigration(conn) {
for (const migration of migrations) {
await runColumnMigration(conn, migration);
}
};
}
module.exports = {
checkTables: checkTables,
createTable: createTable,
deleteTable: deleteTable,
getTables: getTables,
getIndexes: getIndexes,
addUnique: addUnique,
dropUnique: dropUnique,
addPrimaryKey: addPrimaryKey,
addForeign: addForeign,
dropForeign: dropForeign,
addColumn: addColumn,
dropColumn: dropColumn,
setNullable: setNullable,
dropNullable: dropNullable,
getColumns: getColumns,
createColumnMigration,
// NOTE: below are exposed for testing purposes only
_hasForeignSQLite: hasForeignSQLite,
_hasPrimaryKeySQLite: hasPrimaryKeySQLite
};