Skip to content

Commit

Permalink
Merge pull request #1651 from groue/dev/hasSchemaChanges
Browse files Browse the repository at this point in the history
Allow applications to handle DatabaseMigrator schema changes.
  • Loading branch information
groue authored Oct 12, 2024
2 parents bccf312 + 0aa8498 commit 7cd075b
Show file tree
Hide file tree
Showing 2 changed files with 154 additions and 61 deletions.
157 changes: 96 additions & 61 deletions GRDB/Migration/DatabaseMigrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
58 changes: 58 additions & 0 deletions Tests/GRDBTests/DatabaseMigratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"])
}

Expand Down Expand Up @@ -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") })
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}

Expand Down

0 comments on commit 7cd075b

Please sign in to comment.