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 1/2] 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) } From ad983f797119b98f11af6cfdcb5806e89ea7f866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 12 Oct 2024 14:03:56 +0200 Subject: [PATCH 2/2] 7.0.0-beta.4 --- CHANGELOG.md | 10 ++++++++-- Documentation/GRDB7MigrationGuide.md | 8 ++++---- GRDB.swift.podspec | 2 +- README.md | 2 +- Support/Info.plist | 2 +- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878a36eef4..46ddda6623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: #### 7.x Releases -- `7.0.0` Betas - [7.0.0-beta](#700-beta) - [7.0.0-beta.2](#700-beta2) - [7.0.0-beta.3](#700-beta3) +- `7.0.0` Betas - [7.0.0-beta](#700-beta) - [7.0.0-beta.2](#700-beta2) - [7.0.0-beta.3](#700-beta3) - [7.0.0-beta.4](#700-beta4) #### 6.x Releases @@ -131,6 +131,12 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: --- +## 7.0.0-beta.4 + +Released October 12, 2024 + +- **New**: Allow applications to handle DatabaseMigrator schema changes [@groue](https://github.com/groue) in [#1651](https://github.com/groue/GRDB.swift/pull/1651) + ## 7.0.0-beta.3 Released October 6, 2024 @@ -153,7 +159,7 @@ Released September 29, 2024 [Migrating From GRDB 6 to GRDB 7](Documentation/GRDB7MigrationGuide.md) describes in detail how to bump the GRDB version in your application. -The new [Swift Concurrency and GRDB](https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/swiftconcurrency) guide explains how to best integrate GRDB and Swift Concurrency. +The new [Swift Concurrency and GRDB](https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.4/documentation/grdb/swiftconcurrency) guide explains how to best integrate GRDB and Swift Concurrency. The [demo app](Documentation/DemoApps/) was rewritten from scratch in a brand new Xcode 16 project. diff --git a/Documentation/GRDB7MigrationGuide.md b/Documentation/GRDB7MigrationGuide.md index a13d442f01..35ddc1af8f 100644 --- a/Documentation/GRDB7MigrationGuide.md +++ b/Documentation/GRDB7MigrationGuide.md @@ -228,7 +228,7 @@ Do not miss [Swift Concurrency and GRDB], for more recommendations regarding non - The async sequence returned by [`ValueObservation.values`](https://swiftpackageindex.com/groue/grdb.swiftdocumentation/grdb/valueobservation/values(in:scheduling:bufferingpolicy:)) now iterates on the cooperative thread pool by default. Use .mainActor as the scheduler if you need the previous behavior. [Migrating to Swift 6]: https://www.swift.org/migration/documentation/migrationguide -[Sharing a Database]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/databasesharing -[Transaction Kinds]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/transactions#Transaction-Kinds -[Swift Concurrency and GRDB]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/swiftconcurrency -[Record]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/record +[Sharing a Database]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.4/documentation/grdb/databasesharing +[Transaction Kinds]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.4/documentation/grdb/transactions#Transaction-Kinds +[Swift Concurrency and GRDB]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.4/documentation/grdb/swiftconcurrency +[Record]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.4/documentation/grdb/record diff --git a/GRDB.swift.podspec b/GRDB.swift.podspec index a56a6a97a0..3bd168f2ae 100644 --- a/GRDB.swift.podspec +++ b/GRDB.swift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'GRDB.swift' - s.version = '7.0.0-beta.3' + s.version = '7.0.0-beta.4' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'A toolkit for SQLite databases, with a focus on application development.' diff --git a/README.md b/README.md index b4cf208814..3759053939 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ CI Status

-**Latest release**: October 6, 2024 • [version 7.0.0-beta.3](https://github.com/groue/GRDB.swift/tree/v7.0.0-beta.3) • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 6 to GRDB 7](Documentation/GRDB7MigrationGuide.md) +**Latest release**: October 12, 2024 • [version 7.0.0-beta.4](https://github.com/groue/GRDB.swift/tree/v7.0.0-beta.4) • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 6 to GRDB 7](Documentation/GRDB7MigrationGuide.md) **Requirements**: iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ • SQLite 3.20.0+ • Swift 6+ / Xcode 16+ diff --git a/Support/Info.plist b/Support/Info.plist index 0f9a7872c8..4fb2362844 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 7.0.0-beta.3 + 7.0.0-beta.4 CFBundleSignature ???? CFBundleVersion