From 0aa849862097dfe5d5f7998a24c203d72bff83de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 12 Oct 2024 12:40:36 +0200 Subject: [PATCH] DatabaseMigrator.hasSchemaChanges --- GRDB/Migration/DatabaseMigrator.swift | 157 ++++++++++++-------- Tests/GRDBTests/DatabaseMigratorTests.swift | 58 ++++++++ 2 files changed, 154 insertions(+), 61 deletions(-) diff --git a/GRDB/Migration/DatabaseMigrator.swift b/GRDB/Migration/DatabaseMigrator.swift index 739945a270..17752e0d0e 100644 --- a/GRDB/Migration/DatabaseMigrator.swift +++ b/GRDB/Migration/DatabaseMigrator.swift @@ -40,6 +40,10 @@ import Foundation /// - ``completedMigrations(_:)`` /// - ``hasBeenSuperseded(_:)`` /// - ``hasCompletedMigrations(_:)`` +/// +/// ### Detecting Schema Changes +/// +/// - ``hasSchemaChanges(_:)`` public struct DatabaseMigrator: Sendable { /// Controls how a migration handle foreign keys constraints. public enum ForeignKeyChecks: Sendable { @@ -102,6 +106,8 @@ public struct DatabaseMigrator: Sendable { /// migrator.eraseDatabaseOnSchemaChange = true /// #endif /// ``` + /// + /// See also ``hasSchemaChanges(_:)``. public var eraseDatabaseOnSchemaChange = false private var defersForeignKeyChecks = true private var _migrations: [Migration] = [] @@ -279,6 +285,95 @@ public struct DatabaseMigrator: Sendable { } } + // MARK: - Detecting Schema Changes + + /// Returns a boolean value indicating whether the migrator detects a + /// change in the definition of migrations. + /// + /// The result is true if one of those conditions is met: + /// + /// - A migration has been removed, or renamed. + /// - There exists any difference in the `sqlite_master` table, which + /// contains the SQL used to create database tables, indexes, + /// triggers, and views. + /// + /// This method supports the ``eraseDatabaseOnSchemaChange`` option. + /// When `eraseDatabaseOnSchemaChange` does not exactly fit your + /// needs, you can implement it manually as below: + /// + /// ```swift + /// #if DEBUG + /// // Speed up development by nuking the database when migrations change + /// if dbQueue.read(migrator.hasSchemaChanges) { + /// try dbQueue.erase() + /// // Perform other needed logic + /// } + /// #endif + /// try migrator.migrate(dbQueue) + /// ``` + /// + public func hasSchemaChanges(_ db: Database) throws -> Bool { + let appliedIdentifiers = try appliedIdentifiers(db) + let knownIdentifiers = Set(_migrations.map { $0.identifier }) + if !appliedIdentifiers.isSubset(of: knownIdentifiers) { + // Database contains an unknown migration + return true + } + + if let lastAppliedIdentifier = _migrations + .map(\.identifier) + .last(where: { appliedIdentifiers.contains($0) }) + { + // Some migrations were already applied. + // + // Let's migrate a temporary database up to the same + // level, and compare the database schemas. If they + // differ, we'll return true + let tmpSchema = try { + // Make sure the temporary database is configured + // just as the migrated database + var tmpConfig = db.configuration + tmpConfig.targetQueue = nil // Avoid deadlocks + tmpConfig.writeTargetQueue = nil // Avoid deadlocks + tmpConfig.label = "GRDB.DatabaseMigrator.temporary" + + // Create the temporary database on disk, just in + // case migrations would involve a lot of data. + // + // SQLite supports temporary on-disk databases, but + // those are not guaranteed to accept the + // preparation functions provided by the user. + // + // See https://github.com/groue/GRDB.swift/issues/931 + // for an issue created by such databases. + // + // So let's create a "regular" temporary database: + let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString) + defer { + try? FileManager().removeItem(at: tmpURL) + } + let tmpDatabase = try DatabaseQueue(path: tmpURL.path, configuration: tmpConfig) + return try tmpDatabase.writeWithoutTransaction { db in + try runMigrations(db, upTo: lastAppliedIdentifier) + return try db.schema(.main) + } + }() + + // Only compare user objects + func isUserObject(_ object: SchemaObject) -> Bool { + !Database.isSQLiteInternalTable(object.name) && !Database.isGRDBInternalTable(object.name) + } + let tmpUserSchema = tmpSchema.filter(isUserObject) + let userSchema = try db.schema(.main).filter(isUserObject) + if userSchema != tmpUserSchema { + return true + } + } + + return false + } + // MARK: - Querying Migrations /// The list of registered migration identifiers, in the same order as they @@ -409,69 +504,9 @@ public struct DatabaseMigrator: Sendable { if eraseDatabaseOnSchemaChange { var needsErase = false try db.inTransaction(.deferred) { - let appliedIdentifiers = try appliedIdentifiers(db) - let knownIdentifiers = Set(_migrations.map { $0.identifier }) - if !appliedIdentifiers.isSubset(of: knownIdentifiers) { - // Database contains an unknown migration - needsErase = true - return .commit - } - - if let lastAppliedIdentifier = _migrations - .map(\.identifier) - .last(where: { appliedIdentifiers.contains($0) }) - { - // Some migrations were already applied. - // - // Let's migrate a temporary database up to the same - // level, and compare the database schemas. If they - // differ, we'll erase the database. - let tmpSchema = try { - // Make sure the temporary database is configured - // just as the migrated database - var tmpConfig = db.configuration - tmpConfig.targetQueue = nil // Avoid deadlocks - tmpConfig.writeTargetQueue = nil // Avoid deadlocks - tmpConfig.label = "GRDB.DatabaseMigrator.temporary" - - // Create the temporary database on disk, just in - // case migrations would involve a lot of data. - // - // SQLite supports temporary on-disk databases, but - // those are not guaranteed to accept the - // preparation functions provided by the user. - // - // See https://github.com/groue/GRDB.swift/issues/931 - // for an issue created by such databases. - // - // So let's create a "regular" temporary database: - let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString) - defer { - try? FileManager().removeItem(at: tmpURL) - } - let tmpDatabase = try DatabaseQueue(path: tmpURL.path, configuration: tmpConfig) - return try tmpDatabase.writeWithoutTransaction { db in - try runMigrations(db, upTo: lastAppliedIdentifier) - return try db.schema(.main) - } - }() - - // Only compare user objects - func isUserObject(_ object: SchemaObject) -> Bool { - !Database.isSQLiteInternalTable(object.name) && !Database.isGRDBInternalTable(object.name) - } - let tmpUserSchema = tmpSchema.filter(isUserObject) - let userSchema = try db.schema(.main).filter(isUserObject) - if userSchema != tmpUserSchema { - needsErase = true - return .commit - } - } - + needsErase = try hasSchemaChanges(db) return .commit } - if needsErase { try db.erase() } diff --git a/Tests/GRDBTests/DatabaseMigratorTests.swift b/Tests/GRDBTests/DatabaseMigratorTests.swift index d6b118923b..a407612593 100644 --- a/Tests/GRDBTests/DatabaseMigratorTests.swift +++ b/Tests/GRDBTests/DatabaseMigratorTests.swift @@ -655,9 +655,11 @@ class DatabaseMigratorTests : GRDBTestCase { var migrator = DatabaseMigrator() migrator.eraseDatabaseOnSchemaChange = true migrator.registerMigration("1", migrate: { _ in }) + try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) migrator.registerMigration("2", migrate: { _ in }) + try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) } @@ -669,9 +671,11 @@ class DatabaseMigratorTests : GRDBTestCase { var migrator = DatabaseMigrator() migrator.eraseDatabaseOnSchemaChange = true migrator.registerMigration("1", migrate: { _ in }) + try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) migrator.registerMigration("2", migrate: { _ in }) + try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) } @@ -714,10 +718,58 @@ class DatabaseMigratorTests : GRDBTestCase { // ... unless database gets erased migrator2.eraseDatabaseOnSchemaChange = true + try XCTAssertTrue(dbQueue.read(migrator2.hasSchemaChanges)) try migrator2.migrate(dbQueue) + try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges)) try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"]) } + func testManualEraseDatabaseOnSchemaChange() throws { + // 1st version of the migrator + var migrator1 = DatabaseMigrator() + migrator1.registerMigration("1") { db in + try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + t.column("name", .text) + } + } + + // 2nd version of the migrator + var migrator2 = DatabaseMigrator() + migrator2.registerMigration("1") { db in + try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + t.column("name", .text) + t.column("score", .integer) // <- schema change, because reasons (development) + } + } + migrator2.registerMigration("2") { db in + try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") + } + + // Apply 1st migrator + let dbQueue = try makeDatabaseQueue() + try migrator1.migrate(dbQueue) + + // Test than 2nd migrator can't run... + do { + try migrator2.migrate(dbQueue) + XCTFail("Expected DatabaseError") + } catch let error as DatabaseError { + XCTAssertEqual(error.resultCode, .SQLITE_ERROR) + XCTAssertEqual(error.message, "table player has no column named score") + } + try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1"]) + + // ... unless database gets erased + if try dbQueue.read(migrator2.hasSchemaChanges) { + try dbQueue.erase() + } + try migrator2.migrate(dbQueue) + try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges)) + try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"]) + } + func testEraseDatabaseOnSchemaChangeWithConfiguration() throws { // 1st version of the migrator var migrator1 = DatabaseMigrator() @@ -763,7 +815,9 @@ class DatabaseMigratorTests : GRDBTestCase { // ... unless database gets erased migrator2.eraseDatabaseOnSchemaChange = true + try XCTAssertTrue(dbQueue.read(migrator2.hasSchemaChanges)) try migrator2.migrate(dbQueue) + try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges)) try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"]) } @@ -792,6 +846,7 @@ class DatabaseMigratorTests : GRDBTestCase { CREATE TABLE t2(id INTEGER PRIMARY KEY); """) } + try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 1) try XCTAssertTrue(dbQueue.read { try $0.tableExists("t2") }) @@ -818,6 +873,7 @@ class DatabaseMigratorTests : GRDBTestCase { } // Then 2nd migration does not erase database + try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t") }, 1) } @@ -845,7 +901,9 @@ class DatabaseMigratorTests : GRDBTestCase { INSERT INTO t1(id) VALUES (2) """) } + try XCTAssertTrue(dbQueue.read(migrator2.hasSchemaChanges)) try migrator2.migrate(dbQueue) + try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges)) try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 2) }