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/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/README.md b/README.md
index b4cf208814..3759053939 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@