Skip to content

Commit

Permalink
Merge branch 'development'
Browse files Browse the repository at this point in the history
  • Loading branch information
groue committed Oct 12, 2024
2 parents bccf312 + ad983f7 commit d6b50b2
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 70 deletions.
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions Documentation/GRDB7MigrationGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion GRDB.swift.podspec
Original file line number Diff line number Diff line change
@@ -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.'
Expand Down
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<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>
</p>

**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+ &bull; SQLite 3.20.0+ &bull; Swift 6+ / Xcode 16+

Expand Down
2 changes: 1 addition & 1 deletion Support/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>7.0.0-beta.3</string>
<string>7.0.0-beta.4</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
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 d6b50b2

Please sign in to comment.