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 @@
-**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