Skip to content

Commit

Permalink
Merge pull request #3417 from nichwall/series_cleanup_2
Browse files Browse the repository at this point in the history
Add: series migration to be unique
  • Loading branch information
advplyr authored Oct 12, 2024
2 parents 1cac42a + e6e494a commit e58d7db
Show file tree
Hide file tree
Showing 6 changed files with 553 additions and 9 deletions.
3 changes: 1 addition & 2 deletions server/managers/MigrationManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class MigrationManager {
if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`)

this.migrationsDir = path.join(this.configPath, 'migrations')
await fs.ensureDir(this.migrationsDir)

this.serverVersion = this.extractVersionFromTag(serverVersion)
if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`)
Expand Down Expand Up @@ -222,8 +223,6 @@ class MigrationManager {
}

async copyMigrationsToConfigDir() {
await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists

if (!(await fs.pathExists(this.migrationsSourceDir))) return

const files = await fs.readdir(this.migrationsSourceDir)
Expand Down
6 changes: 3 additions & 3 deletions server/migrations/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

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 |
| -------------- | --------------------- | ----------- |
| | | |
| Server Version | Migration Script Name | Description |
| -------------- | ---------------------------- | ------------------------------------------------- |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
206 changes: 206 additions & 0 deletions server/migrations/v2.15.0-series-column-unique.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* @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 cleans any duplicate series in the `Series` table and
* adds a unique index on the `name` and `libraryId` columns.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique ')

// Check if the unique index already exists
const seriesIndexes = await queryInterface.showIndex('Series')
if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) {
logger.info('[2.15.0 migration] Unique index on Series.name and Series.libraryId already exists')
logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ')
return
}

// The steps taken to deduplicate the series are as follows:
// 1. Find all duplicate series in the `Series` table.
// 2. Iterate over the duplicate series and find all book IDs that are associated with the duplicate series in `bookSeries` table.
// 2.a For each book ID, check if the ID occurs multiple times for the duplicate series.
// 2.b If so, keep only one of the rows that has this bookId and seriesId.
// 3. Update `bookSeries` table to point to the most recent series.
// 4. Delete the older series.

// Use the queryInterface to get the series table and find duplicates in the `name` and `libraryId` column
const [duplicates] = await queryInterface.sequelize.query(`
SELECT name, libraryId
FROM Series
GROUP BY name, libraryId
HAVING COUNT(name) > 1
`)

// Print out how many duplicates were found
logger.info(`[2.15.0 migration] Found ${duplicates.length} duplicate series`)

// Iterate over each duplicate series
for (const duplicate of duplicates) {
// Report the series name that is being deleted
logger.info(`[2.15.0 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`)

// Determine any duplicate book IDs in the `bookSeries` table for the same series
const [duplicateBookIds] = await queryInterface.sequelize.query(
`
SELECT bookId
FROM BookSeries
WHERE seriesId IN (
SELECT id
FROM Series
WHERE name = :name AND libraryId = :libraryId
)
GROUP BY bookId
HAVING COUNT(bookId) > 1
`,
{
replacements: {
name: duplicate.name,
libraryId: duplicate.libraryId
}
}
)

// Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId
for (const { bookId } of duplicateBookIds) {
logger.info(`[2.15.0 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`)
// Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last
const [duplicateBookSeries] = await queryInterface.sequelize.query(
`
SELECT id
FROM BookSeries
WHERE bookId = :bookId
AND seriesId IN (
SELECT id
FROM Series
WHERE name = :name AND libraryId = :libraryId
)
ORDER BY sequence NULLS LAST
`,
{
replacements: {
bookId,
name: duplicate.name,
libraryId: duplicate.libraryId
}
}
)

// remove the first element from the array
duplicateBookSeries.shift()

// Delete the remaining duplicate rows
if (duplicateBookSeries.length > 0) {
const [deletedBookSeries] = await queryInterface.sequelize.query(
`
DELETE FROM BookSeries
WHERE id IN (:ids)
`,
{
replacements: {
ids: duplicateBookSeries.map((row) => row.id)
}
}
)
}
logger.info(`[2.15.0 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`)
}

// Get all the most recent series which matches the `name` and `libraryId`
const [mostRecentSeries] = await queryInterface.sequelize.query(
`
SELECT id
FROM Series
WHERE name = :name AND libraryId = :libraryId
ORDER BY updatedAt DESC
LIMIT 1
`,
{
replacements: {
name: duplicate.name,
libraryId: duplicate.libraryId
},
type: queryInterface.sequelize.QueryTypes.SELECT
}
)

if (mostRecentSeries) {
// Update all BookSeries records for this series to point to the most recent series
const [seriesUpdated] = await queryInterface.sequelize.query(
`
UPDATE BookSeries
SET seriesId = :mostRecentSeriesId
WHERE seriesId IN (
SELECT id
FROM Series
WHERE name = :name AND libraryId = :libraryId
AND id != :mostRecentSeriesId
)
`,
{
replacements: {
name: duplicate.name,
libraryId: duplicate.libraryId,
mostRecentSeriesId: mostRecentSeries.id
}
}
)

// Delete the older series
const seriesDeleted = await queryInterface.sequelize.query(
`
DELETE FROM Series
WHERE name = :name AND libraryId = :libraryId
AND id != :mostRecentSeriesId
`,
{
replacements: {
name: duplicate.name,
libraryId: duplicate.libraryId,
mostRecentSeriesId: mostRecentSeries.id
}
}
)
}
}

logger.info(`[2.15.0 migration] Deduplication complete`)

// Create a unique index based on the name and library ID for the `Series` table
await queryInterface.addIndex('Series', ['name', 'libraryId'], {
unique: true,
name: 'unique_series_name_per_library'
})
logger.info('[2.15.0 migration] Added unique index on Series.name and Series.libraryId')

logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ')
}

/**
* This removes the unique index on the `Series` table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info('[2.15.0 migration] DOWNGRADE BEGIN: 2.15.0-series-column-unique ')

// Remove the unique index
await queryInterface.removeIndex('Series', 'unique_series_name_per_library')
logger.info('[2.15.0 migration] Removed unique index on Series.name and Series.libraryId')

logger.info('[2.15.0 migration] DOWNGRADE END: 2.15.0-series-column-unique ')
}

module.exports = { up, down }
6 changes: 6 additions & 0 deletions server/models/Series.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ class Series extends Model {
// collate: 'NOCASE'
// }]
// },
{
// unique constraint on name and libraryId
fields: ['name', 'libraryId'],
unique: true,
name: 'unique_series_name_per_library'
},
{
fields: ['libraryId']
}
Expand Down
6 changes: 2 additions & 4 deletions test/server/managers/MigrationManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ describe('MigrationManager', () => {
await migrationManager.init(serverVersion)

// Assert
expect(fsEnsureDirStub.calledOnce).to.be.true
expect(fsEnsureDirStub.calledWith(migrationManager.migrationsDir)).to.be.true
expect(migrationManager.serverVersion).to.equal(serverVersion)
expect(migrationManager.sequelize).to.equal(sequelizeStub)
expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations'))
Expand Down Expand Up @@ -353,8 +355,6 @@ describe('MigrationManager', () => {
await migrationManager.copyMigrationsToConfigDir()

// Assert
expect(fsEnsureDirStub.calledOnce).to.be.true
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
expect(readdirStub.calledOnce).to.be.true
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
expect(fsCopyStub.calledTwice).to.be.true
Expand Down Expand Up @@ -382,8 +382,6 @@ describe('MigrationManager', () => {
} catch (error) {}

// Assert
expect(fsEnsureDirStub.calledOnce).to.be.true
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
expect(readdirStub.calledOnce).to.be.true
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
expect(fsCopyStub.calledTwice).to.be.true
Expand Down
Loading

0 comments on commit e58d7db

Please sign in to comment.