From 70f466d03c4c27d99070d764a2eddac0bdccc9f8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 28 Nov 2024 17:18:34 -0600 Subject: [PATCH] Add migration for v2.17.3 to fix dropped fk constraints --- server/migrations/changelog.md | 13 +- server/migrations/v2.17.3-fk-constraints.js | 219 ++++++++++++++++++++ 2 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 server/migrations/v2.17.3-fk-constraints.js diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8960ade2..51e82600 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,10 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration | diff --git a/server/migrations/v2.17.3-fk-constraints.js b/server/migrations/v2.17.3-fk-constraints.js new file mode 100644 index 00000000..a62307a3 --- /dev/null +++ b/server/migrations/v2.17.3-fk-constraints.js @@ -0,0 +1,219 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes foreign key constraints for the + * libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints') + + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + + // Disable foreign key constraints for the next sequence of operations + await execQuery(`PRAGMA foreign_keys = OFF;`) + + try { + await execQuery(`BEGIN TRANSACTION;`) + + logger.info('[2.17.3 migration] Updating libraryItems constraints') + const libraryItemsConstraints = [ + { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } + ] + await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints) + logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + + logger.info('[2.17.3 migration] Updating feeds constraints') + const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'feeds', feedsConstraints) + logger.info('[2.17.3 migration] Finished updating feeds constraints') + + if (await queryInterface.tableExists('mediaItemShares')) { + logger.info('[2.17.3 migration] Updating mediaItemShares constraints') + const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints) + logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints') + } else { + logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change') + } + + logger.info('[2.17.3 migration] Updating playbackSessions constraints') + const playbackSessionsConstraints = [ + { field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } + ] + await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints) + logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + + logger.info('[2.17.3 migration] Updating playlistMediaItems constraints') + const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints) + logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + + logger.info('[2.17.3 migration] Updating mediaProgresses constraints') + const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints) + logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + + await execQuery(`COMMIT;`) + } catch (error) { + logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error) + await execQuery(`ROLLBACK;`) + } + + await execQuery(`PRAGMA foreign_keys = ON;`) + + // Completed migration + logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints') +} + +/** + * This downward migration script is a no-op. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints') + + // This migration is a no-op + logger.info('[2.17.3 migration] No action required for downgrade') + + // Completed migration + logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints') +} + +/** + * @typedef ConstraintUpdateObj + * @property {string} field - The field to update + * @property {string} onDelete - The onDelete constraint + * @property {string} onUpdate - The onUpdate constraint + */ + +const formatFKsPragmaToSequelizeFK = (fk) => { + let onDelete = fk['on_delete'] + let onUpdate = fk['on_update'] + + if (fk.from === 'userId' || fk.from === 'libraryId' || fk.from === 'deviceId') { + onDelete = 'SET NULL' + onUpdate = 'CASCADE' + } + + return { + references: { + model: fk.table, + key: fk.to + }, + constraints: { + onDelete, + onUpdate + } + } +} + +/** + * Extends the Sequelize describeTable function to include the foreign keys constraints in sqlite dbs + * @param {import('sequelize').QueryInterface} queryInterface + * @param {String} tableName - The table name + * @param {ConstraintUpdateObj[]} constraints - constraints to update + */ +async function describeTableWithFKs(queryInterface, tableName, constraints) { + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + const quotedTableName = queryInterface.quoteIdentifier(tableName) + + const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`) + + const foreignKeysByColName = foreignKeys.reduce((prev, curr) => { + const fk = formatFKsPragmaToSequelizeFK(curr) + return { ...prev, [curr.from]: fk } + }, {}) + + const tableDescription = await queryInterface.describeTable(tableName) + + const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => { + let extendedAttributes = attributes + + if (foreignKeysByColName[col]) { + // Use the constraints from the constraints array if they exist, otherwise use the existing constraints + const onDelete = constraints.find((c) => c.field === col)?.onDelete || foreignKeysByColName[col].constraints.onDelete + const onUpdate = constraints.find((c) => c.field === col)?.onUpdate || foreignKeysByColName[col].constraints.onUpdate + + extendedAttributes = { + ...extendedAttributes, + references: foreignKeysByColName[col].references, + onDelete, + onUpdate + } + } + return { ...prev, [col]: extendedAttributes } + }, {}) + + return tableDescriptionWithFks +} + +/** + * @see https://www.sqlite.org/lang_altertable.html#otheralter + * @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {string} tableName + * @param {ConstraintUpdateObj[]} constraints + */ +async function changeConstraints(queryInterface, tableName, constraints) { + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + const quotedTableName = queryInterface.quoteIdentifier(tableName) + + const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup` + const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName) + + try { + const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, constraints) + + const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks) + + // Create the backup table + await queryInterface.createTable(backupTableName, attributes) + + const attributeNames = Object.keys(attributes) + .map((attr) => queryInterface.quoteIdentifier(attr)) + .join(', ') + + // Copy all data from the target table to the backup table + await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`) + + // Drop the old (original) table + await queryInterface.dropTable(tableName) + + // Rename the backup table to the original table's name + await queryInterface.renameTable(backupTableName, tableName) + + // Validate that all foreign key constraints are correct + const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, { + type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT + }) + + // There are foreign key violations, exit + if (result.length) { + return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`) + } + + return Promise.resolve() + } catch (error) { + return Promise.reject(error) + } +} + +module.exports = { up, down }