Skip to content

Commit d6b50b2

Browse files
committed
Merge branch 'development'
2 parents bccf312 + ad983f7 commit d6b50b2

File tree

7 files changed

+169
-70
lines changed

7 files changed

+169
-70
lines changed

CHANGELOG.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception:
77

88
#### 7.x Releases
99

10-
- `7.0.0` Betas - [7.0.0-beta](#700-beta) - [7.0.0-beta.2](#700-beta2) - [7.0.0-beta.3](#700-beta3)
10+
- `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)
1111

1212
#### 6.x Releases
1313

@@ -131,6 +131,12 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception:
131131

132132
---
133133

134+
## 7.0.0-beta.4
135+
136+
Released October 12, 2024
137+
138+
- **New**: Allow applications to handle DatabaseMigrator schema changes [@groue](https://github.com/groue) in [#1651](https://github.com/groue/GRDB.swift/pull/1651)
139+
134140
## 7.0.0-beta.3
135141

136142
Released October 6, 2024
@@ -153,7 +159,7 @@ Released September 29, 2024
153159

154160
[Migrating From GRDB 6 to GRDB 7](Documentation/GRDB7MigrationGuide.md) describes in detail how to bump the GRDB version in your application.
155161

156-
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.
162+
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.
157163

158164
The [demo app](Documentation/DemoApps/) was rewritten from scratch in a brand new Xcode 16 project.
159165

Documentation/GRDB7MigrationGuide.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ Do not miss [Swift Concurrency and GRDB], for more recommendations regarding non
228228
- 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.
229229

230230
[Migrating to Swift 6]: https://www.swift.org/migration/documentation/migrationguide
231-
[Sharing a Database]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/databasesharing
232-
[Transaction Kinds]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/transactions#Transaction-Kinds
233-
[Swift Concurrency and GRDB]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/swiftconcurrency
234-
[Record]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/record
231+
[Sharing a Database]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.4/documentation/grdb/databasesharing
232+
[Transaction Kinds]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.4/documentation/grdb/transactions#Transaction-Kinds
233+
[Swift Concurrency and GRDB]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.4/documentation/grdb/swiftconcurrency
234+
[Record]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.4/documentation/grdb/record

GRDB.swift.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Pod::Spec.new do |s|
22
s.name = 'GRDB.swift'
3-
s.version = '7.0.0-beta.3'
3+
s.version = '7.0.0-beta.4'
44

55
s.license = { :type => 'MIT', :file => 'LICENSE' }
66
s.summary = 'A toolkit for SQLite databases, with a focus on application development.'

GRDB/Migration/DatabaseMigrator.swift

Lines changed: 96 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ import Foundation
4040
/// - ``completedMigrations(_:)``
4141
/// - ``hasBeenSuperseded(_:)``
4242
/// - ``hasCompletedMigrations(_:)``
43+
///
44+
/// ### Detecting Schema Changes
45+
///
46+
/// - ``hasSchemaChanges(_:)``
4347
public struct DatabaseMigrator: Sendable {
4448
/// Controls how a migration handle foreign keys constraints.
4549
public enum ForeignKeyChecks: Sendable {
@@ -102,6 +106,8 @@ public struct DatabaseMigrator: Sendable {
102106
/// migrator.eraseDatabaseOnSchemaChange = true
103107
/// #endif
104108
/// ```
109+
///
110+
/// See also ``hasSchemaChanges(_:)``.
105111
public var eraseDatabaseOnSchemaChange = false
106112
private var defersForeignKeyChecks = true
107113
private var _migrations: [Migration] = []
@@ -279,6 +285,95 @@ public struct DatabaseMigrator: Sendable {
279285
}
280286
}
281287

288+
// MARK: - Detecting Schema Changes
289+
290+
/// Returns a boolean value indicating whether the migrator detects a
291+
/// change in the definition of migrations.
292+
///
293+
/// The result is true if one of those conditions is met:
294+
///
295+
/// - A migration has been removed, or renamed.
296+
/// - There exists any difference in the `sqlite_master` table, which
297+
/// contains the SQL used to create database tables, indexes,
298+
/// triggers, and views.
299+
///
300+
/// This method supports the ``eraseDatabaseOnSchemaChange`` option.
301+
/// When `eraseDatabaseOnSchemaChange` does not exactly fit your
302+
/// needs, you can implement it manually as below:
303+
///
304+
/// ```swift
305+
/// #if DEBUG
306+
/// // Speed up development by nuking the database when migrations change
307+
/// if dbQueue.read(migrator.hasSchemaChanges) {
308+
/// try dbQueue.erase()
309+
/// // Perform other needed logic
310+
/// }
311+
/// #endif
312+
/// try migrator.migrate(dbQueue)
313+
/// ```
314+
///
315+
public func hasSchemaChanges(_ db: Database) throws -> Bool {
316+
let appliedIdentifiers = try appliedIdentifiers(db)
317+
let knownIdentifiers = Set(_migrations.map { $0.identifier })
318+
if !appliedIdentifiers.isSubset(of: knownIdentifiers) {
319+
// Database contains an unknown migration
320+
return true
321+
}
322+
323+
if let lastAppliedIdentifier = _migrations
324+
.map(\.identifier)
325+
.last(where: { appliedIdentifiers.contains($0) })
326+
{
327+
// Some migrations were already applied.
328+
//
329+
// Let's migrate a temporary database up to the same
330+
// level, and compare the database schemas. If they
331+
// differ, we'll return true
332+
let tmpSchema = try {
333+
// Make sure the temporary database is configured
334+
// just as the migrated database
335+
var tmpConfig = db.configuration
336+
tmpConfig.targetQueue = nil // Avoid deadlocks
337+
tmpConfig.writeTargetQueue = nil // Avoid deadlocks
338+
tmpConfig.label = "GRDB.DatabaseMigrator.temporary"
339+
340+
// Create the temporary database on disk, just in
341+
// case migrations would involve a lot of data.
342+
//
343+
// SQLite supports temporary on-disk databases, but
344+
// those are not guaranteed to accept the
345+
// preparation functions provided by the user.
346+
//
347+
// See https://github.com/groue/GRDB.swift/issues/931
348+
// for an issue created by such databases.
349+
//
350+
// So let's create a "regular" temporary database:
351+
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
352+
.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString)
353+
defer {
354+
try? FileManager().removeItem(at: tmpURL)
355+
}
356+
let tmpDatabase = try DatabaseQueue(path: tmpURL.path, configuration: tmpConfig)
357+
return try tmpDatabase.writeWithoutTransaction { db in
358+
try runMigrations(db, upTo: lastAppliedIdentifier)
359+
return try db.schema(.main)
360+
}
361+
}()
362+
363+
// Only compare user objects
364+
func isUserObject(_ object: SchemaObject) -> Bool {
365+
!Database.isSQLiteInternalTable(object.name) && !Database.isGRDBInternalTable(object.name)
366+
}
367+
let tmpUserSchema = tmpSchema.filter(isUserObject)
368+
let userSchema = try db.schema(.main).filter(isUserObject)
369+
if userSchema != tmpUserSchema {
370+
return true
371+
}
372+
}
373+
374+
return false
375+
}
376+
282377
// MARK: - Querying Migrations
283378

284379
/// The list of registered migration identifiers, in the same order as they
@@ -409,69 +504,9 @@ public struct DatabaseMigrator: Sendable {
409504
if eraseDatabaseOnSchemaChange {
410505
var needsErase = false
411506
try db.inTransaction(.deferred) {
412-
let appliedIdentifiers = try appliedIdentifiers(db)
413-
let knownIdentifiers = Set(_migrations.map { $0.identifier })
414-
if !appliedIdentifiers.isSubset(of: knownIdentifiers) {
415-
// Database contains an unknown migration
416-
needsErase = true
417-
return .commit
418-
}
419-
420-
if let lastAppliedIdentifier = _migrations
421-
.map(\.identifier)
422-
.last(where: { appliedIdentifiers.contains($0) })
423-
{
424-
// Some migrations were already applied.
425-
//
426-
// Let's migrate a temporary database up to the same
427-
// level, and compare the database schemas. If they
428-
// differ, we'll erase the database.
429-
let tmpSchema = try {
430-
// Make sure the temporary database is configured
431-
// just as the migrated database
432-
var tmpConfig = db.configuration
433-
tmpConfig.targetQueue = nil // Avoid deadlocks
434-
tmpConfig.writeTargetQueue = nil // Avoid deadlocks
435-
tmpConfig.label = "GRDB.DatabaseMigrator.temporary"
436-
437-
// Create the temporary database on disk, just in
438-
// case migrations would involve a lot of data.
439-
//
440-
// SQLite supports temporary on-disk databases, but
441-
// those are not guaranteed to accept the
442-
// preparation functions provided by the user.
443-
//
444-
// See https://github.com/groue/GRDB.swift/issues/931
445-
// for an issue created by such databases.
446-
//
447-
// So let's create a "regular" temporary database:
448-
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
449-
.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString)
450-
defer {
451-
try? FileManager().removeItem(at: tmpURL)
452-
}
453-
let tmpDatabase = try DatabaseQueue(path: tmpURL.path, configuration: tmpConfig)
454-
return try tmpDatabase.writeWithoutTransaction { db in
455-
try runMigrations(db, upTo: lastAppliedIdentifier)
456-
return try db.schema(.main)
457-
}
458-
}()
459-
460-
// Only compare user objects
461-
func isUserObject(_ object: SchemaObject) -> Bool {
462-
!Database.isSQLiteInternalTable(object.name) && !Database.isGRDBInternalTable(object.name)
463-
}
464-
let tmpUserSchema = tmpSchema.filter(isUserObject)
465-
let userSchema = try db.schema(.main).filter(isUserObject)
466-
if userSchema != tmpUserSchema {
467-
needsErase = true
468-
return .commit
469-
}
470-
}
471-
507+
needsErase = try hasSchemaChanges(db)
472508
return .commit
473509
}
474-
475510
if needsErase {
476511
try db.erase()
477512
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<a href="https://github.com/groue/GRDB.swift/actions/workflows/CI.yml"><img alt="CI Status" src="https://github.com/groue/GRDB.swift/actions/workflows/CI.yml/badge.svg?branch=master"></a>
1616
</p>
1717

18-
**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)
18+
**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)
1919

2020
**Requirements**: iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ &bull; SQLite 3.20.0+ &bull; Swift 6+ / Xcode 16+
2121

Support/Info.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<key>CFBundlePackageType</key>
1616
<string>FMWK</string>
1717
<key>CFBundleShortVersionString</key>
18-
<string>7.0.0-beta.3</string>
18+
<string>7.0.0-beta.4</string>
1919
<key>CFBundleSignature</key>
2020
<string>????</string>
2121
<key>CFBundleVersion</key>

Tests/GRDBTests/DatabaseMigratorTests.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,9 +655,11 @@ class DatabaseMigratorTests : GRDBTestCase {
655655
var migrator = DatabaseMigrator()
656656
migrator.eraseDatabaseOnSchemaChange = true
657657
migrator.registerMigration("1", migrate: { _ in })
658+
try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges))
658659
try migrator.migrate(dbQueue)
659660

660661
migrator.registerMigration("2", migrate: { _ in })
662+
try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges))
661663
try migrator.migrate(dbQueue)
662664
}
663665

@@ -669,9 +671,11 @@ class DatabaseMigratorTests : GRDBTestCase {
669671
var migrator = DatabaseMigrator()
670672
migrator.eraseDatabaseOnSchemaChange = true
671673
migrator.registerMigration("1", migrate: { _ in })
674+
try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges))
672675
try migrator.migrate(dbQueue)
673676

674677
migrator.registerMigration("2", migrate: { _ in })
678+
try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges))
675679
try migrator.migrate(dbQueue)
676680
}
677681

@@ -714,10 +718,58 @@ class DatabaseMigratorTests : GRDBTestCase {
714718

715719
// ... unless database gets erased
716720
migrator2.eraseDatabaseOnSchemaChange = true
721+
try XCTAssertTrue(dbQueue.read(migrator2.hasSchemaChanges))
717722
try migrator2.migrate(dbQueue)
723+
try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges))
718724
try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"])
719725
}
720726

727+
func testManualEraseDatabaseOnSchemaChange() throws {
728+
// 1st version of the migrator
729+
var migrator1 = DatabaseMigrator()
730+
migrator1.registerMigration("1") { db in
731+
try db.create(table: "player") { t in
732+
t.autoIncrementedPrimaryKey("id")
733+
t.column("name", .text)
734+
}
735+
}
736+
737+
// 2nd version of the migrator
738+
var migrator2 = DatabaseMigrator()
739+
migrator2.registerMigration("1") { db in
740+
try db.create(table: "player") { t in
741+
t.autoIncrementedPrimaryKey("id")
742+
t.column("name", .text)
743+
t.column("score", .integer) // <- schema change, because reasons (development)
744+
}
745+
}
746+
migrator2.registerMigration("2") { db in
747+
try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)")
748+
}
749+
750+
// Apply 1st migrator
751+
let dbQueue = try makeDatabaseQueue()
752+
try migrator1.migrate(dbQueue)
753+
754+
// Test than 2nd migrator can't run...
755+
do {
756+
try migrator2.migrate(dbQueue)
757+
XCTFail("Expected DatabaseError")
758+
} catch let error as DatabaseError {
759+
XCTAssertEqual(error.resultCode, .SQLITE_ERROR)
760+
XCTAssertEqual(error.message, "table player has no column named score")
761+
}
762+
try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1"])
763+
764+
// ... unless database gets erased
765+
if try dbQueue.read(migrator2.hasSchemaChanges) {
766+
try dbQueue.erase()
767+
}
768+
try migrator2.migrate(dbQueue)
769+
try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges))
770+
try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"])
771+
}
772+
721773
func testEraseDatabaseOnSchemaChangeWithConfiguration() throws {
722774
// 1st version of the migrator
723775
var migrator1 = DatabaseMigrator()
@@ -763,7 +815,9 @@ class DatabaseMigratorTests : GRDBTestCase {
763815

764816
// ... unless database gets erased
765817
migrator2.eraseDatabaseOnSchemaChange = true
818+
try XCTAssertTrue(dbQueue.read(migrator2.hasSchemaChanges))
766819
try migrator2.migrate(dbQueue)
820+
try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges))
767821
try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"])
768822
}
769823

@@ -792,6 +846,7 @@ class DatabaseMigratorTests : GRDBTestCase {
792846
CREATE TABLE t2(id INTEGER PRIMARY KEY);
793847
""")
794848
}
849+
try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges))
795850
try migrator.migrate(dbQueue)
796851
try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 1)
797852
try XCTAssertTrue(dbQueue.read { try $0.tableExists("t2") })
@@ -818,6 +873,7 @@ class DatabaseMigratorTests : GRDBTestCase {
818873
}
819874

820875
// Then 2nd migration does not erase database
876+
try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges))
821877
try migrator.migrate(dbQueue)
822878
try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t") }, 1)
823879
}
@@ -845,7 +901,9 @@ class DatabaseMigratorTests : GRDBTestCase {
845901
INSERT INTO t1(id) VALUES (2)
846902
""")
847903
}
904+
try XCTAssertTrue(dbQueue.read(migrator2.hasSchemaChanges))
848905
try migrator2.migrate(dbQueue)
906+
try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges))
849907
try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 2)
850908
}
851909

0 commit comments

Comments
 (0)