Skip to content

Commit 18497b6

Browse files
committed
Merge branch 'development'
2 parents db695cf + 19e33e2 commit 18497b6

File tree

7 files changed

+422
-35
lines changed

7 files changed

+422
-35
lines changed

CHANGELOG.md

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

88
#### 7.x Releases
99

10+
- `7.8.x` Releases - [7.8.0](#780)
1011
- `7.7.x` Releases - [7.7.0](#770) - [7.7.1](#771)
1112
- `7.6.x` Releases - [7.6.0](#760) - [7.6.1](#761)
1213
- `7.5.x` Releases - [7.5.0](#750)
@@ -139,6 +140,12 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception:
139140

140141
---
141142

143+
## 7.8.0
144+
145+
Released October 2, 2025
146+
147+
- **New**: Merged Migrations by [@groue](https://github.com/groue) in [#1818](https://github.com/groue/GRDB.swift/pull/1818)
148+
142149
## 7.7.1
143150

144151
Released September 29, 2025

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.7.1'
3+
s.version = '7.8.0'
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: 149 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import Foundation
1818
/// ### Registering Migrations
1919
///
2020
/// - ``registerMigration(_:foreignKeyChecks:migrate:)``
21+
/// - ``registerMigration(_:foreignKeyChecks:merging:migrate:)``
2122
/// - ``ForeignKeyChecks``
2223
///
2324
/// ### Configuring a DatabaseMigrator
@@ -161,9 +162,8 @@ public struct DatabaseMigrator: Sendable {
161162

162163
/// Registers a migration.
163164
///
164-
/// The registered migration is appended to the list of migrations to run:
165-
/// it will execute after previously registered migrations, and before
166-
/// migrations that are registered later.
165+
/// The registered migration is appended to the list of migrations. It
166+
/// will execute after previously registered migrations.
167167
///
168168
/// For example:
169169
///
@@ -214,6 +214,107 @@ public struct DatabaseMigrator: Sendable {
214214
_ identifier: String,
215215
foreignKeyChecks: ForeignKeyChecks = .deferred,
216216
migrate: @escaping @Sendable (Database) throws -> Void)
217+
{
218+
registerMigration(identifier, foreignKeyChecks: foreignKeyChecks, merging: []) { db, ids in
219+
precondition(ids.isEmpty)
220+
try migrate(db)
221+
}
222+
}
223+
224+
/// Registers a merged migration.
225+
///
226+
/// Like migrations registered with ``registerMigration(_:foreignKeyChecks:migrate:)``,
227+
/// the merged migration is appended to the list of migrations. It
228+
/// will execute after previously registered migrations.
229+
///
230+
/// A merged migration merges and replaces a set of migrations defined
231+
/// in a previous version of the application. For example, to merge the
232+
/// migrations "v1", "v2" and "v3", redefine the "v3" migration so that
233+
/// it merges "v1" and "v2", as in the example below.
234+
///
235+
/// The second argument of the `migrate` closure is the subset of merged
236+
/// migrations that have already been applied when the merged
237+
/// migration runs.
238+
///
239+
/// ```swift
240+
/// // Old code
241+
/// migrator.registerMigration("v1") { db in
242+
/// // Apply schema version 1
243+
/// }
244+
/// migrator.registerMigration("v2") { db in
245+
/// // Apply schema version 2
246+
/// }
247+
/// migrator.registerMigration("v3") { db in
248+
/// // Apply schema version 3
249+
/// }
250+
///
251+
/// // New code:
252+
/// // - Migrations v1 and v2 are deleted.
253+
/// // - Migration v3 is redefined and merges v1 and v2:
254+
/// migrator.registerMigration("v3", merging: ["v1", "v2"]) { db, appliedIDs in
255+
/// if !appliedIDs.contains("v1") {
256+
/// // Apply schema version 1
257+
/// }
258+
/// if !appliedIDs.contains("v2") {
259+
/// // Apply schema version 2
260+
/// }
261+
/// // Apply schema version 3
262+
/// }
263+
/// ```
264+
///
265+
/// In the above sample code, the merged migration is named like the
266+
/// last one of the merged set. You can also give it a brand new name,
267+
/// as in the alternative below. Notice the different logic in the
268+
/// migration code.
269+
///
270+
/// **In all cases avoid naming the merged migration like the first
271+
/// elements in the merged set** (`v1` or `v2` in our example).
272+
///
273+
/// ```swift
274+
/// // Alternative new code:
275+
/// // - Migrations v1, v2 and v3 are deleted.
276+
/// // - The new migration v3-new merges v1, v2 and v3:
277+
/// migrator.registerMigration("v3-new", merging: ["v1", "v2", "v3"]) { db, appliedIDs in
278+
/// if !appliedIDs.contains("v1") {
279+
/// // Apply schema version 1
280+
/// }
281+
/// if !appliedIDs.contains("v2") {
282+
/// // Apply schema version 2
283+
/// }
284+
/// if !appliedIDs.contains("v3") {
285+
/// // Apply schema version 3
286+
/// }
287+
/// }
288+
/// ```
289+
///
290+
/// - parameters:
291+
/// - identifier: The migration identifier.
292+
/// - mergedIdentifiers: A set of previous migration identifiers
293+
/// that are merged in this migration.
294+
/// - foreignKeyChecks: This parameter is ignored if the database
295+
/// ``Configuration`` has disabled foreign keys.
296+
///
297+
/// The default `.deferred` checks have the migration run with
298+
/// disabled foreign keys, until foreign keys are checked right before
299+
/// changes are committed on disk. These deferred checks are not
300+
/// executed if the migrator is the result of
301+
/// ``disablingDeferredForeignKeyChecks()``.
302+
///
303+
/// The `.immediate` checks have the migration run with foreign
304+
/// keys enabled. Make sure you only use `.immediate` if the migration
305+
/// does not perform schema changes described in
306+
/// <https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes>
307+
/// - migrate: A closure that performs database operations. The
308+
/// first argument is a database connection. The second argument
309+
/// is the set of previous migrations that has been applied when
310+
/// the merged migration runs.
311+
/// - precondition: No migration with the same identifier as already
312+
/// been registered.
313+
public mutating func registerMigration(
314+
_ identifier: String,
315+
foreignKeyChecks: ForeignKeyChecks = .deferred,
316+
merging mergedIdentifiers: Set<String> = [],
317+
migrate: @escaping @Sendable (_ db: Database, _ appliedIdentifiers: Set<String>) throws -> Void)
217318
{
218319
let migrationChecks: Migration.ForeignKeyChecks
219320
switch foreignKeyChecks {
@@ -226,7 +327,11 @@ public struct DatabaseMigrator: Sendable {
226327
case .immediate:
227328
migrationChecks = .immediate
228329
}
229-
registerMigration(Migration(identifier: identifier, foreignKeyChecks: migrationChecks, migrate: migrate))
330+
registerMigration(Migration(
331+
identifier: identifier,
332+
mergedIdentifiers: mergedIdentifiers,
333+
foreignKeyChecks: migrationChecks,
334+
migrate: migrate))
230335
}
231336

232337
// MARK: - Applying Migrations
@@ -450,15 +555,25 @@ public struct DatabaseMigrator: Sendable {
450555

451556
// MARK: - Non public
452557

558+
private struct Execution {
559+
enum Mode {
560+
case run(mergedIdentifiers: Set<String>)
561+
case deleteMergedIdentifiers
562+
}
563+
564+
var migration: Migration
565+
var mode: Mode
566+
}
567+
453568
private mutating func registerMigration(_ migration: Migration) {
454569
GRDBPrecondition(
455570
!_migrations.map({ $0.identifier }).contains(migration.identifier),
456571
"already registered migration: \(String(reflecting: migration.identifier))")
457572
_migrations.append(migration)
458573
}
459574

460-
/// Returns unapplied migration identifier,
461-
private func unappliedMigrations(upTo targetIdentifier: String, appliedIdentifiers: [String]) -> [Migration] {
575+
/// Returns unapplied migration executions
576+
private func unappliedExecutions(upTo targetIdentifier: String, appliedIdentifiers: Set<String>) -> [Execution] {
462577
var expectedMigrations: [Migration] = []
463578
for migration in _migrations {
464579
expectedMigrations.append(migration)
@@ -472,32 +587,52 @@ public struct DatabaseMigrator: Sendable {
472587
expectedMigrations.last?.identifier == targetIdentifier,
473588
"undefined migration: \(String(reflecting: targetIdentifier))")
474589

475-
return expectedMigrations.filter { !appliedIdentifiers.contains($0.identifier) }
590+
return expectedMigrations.compactMap { migration in
591+
if appliedIdentifiers.contains(migration.identifier) {
592+
if migration.mergedIdentifiers.isDisjoint(with: appliedIdentifiers) {
593+
// Nothing to do
594+
return nil
595+
} else {
596+
// Migration is applied, but we have some merged identifiers to delete
597+
return Execution(migration: migration, mode: .deleteMergedIdentifiers)
598+
}
599+
} else {
600+
// Migration is not applied yet.
601+
let appliedMergedIdentifiers = migration.mergedIdentifiers.intersection(appliedIdentifiers)
602+
return Execution(migration: migration, mode: .run(mergedIdentifiers: appliedMergedIdentifiers))
603+
}
604+
}
476605
}
477606

478607
private func runMigrations(_ db: Database, upTo targetIdentifier: String) throws {
479608
try db.execute(sql: "CREATE TABLE IF NOT EXISTS grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY)")
480-
let appliedIdentifiers = try self.appliedMigrations(db)
481609

482610
// Subsequent migration must not be applied
611+
let appliedMigrations = try self.appliedMigrations(db) // Only known ids
483612
if let targetIndex = _migrations.firstIndex(where: { $0.identifier == targetIdentifier }),
484-
let lastAppliedIdentifier = appliedIdentifiers.last,
485-
let lastAppliedIndex = _migrations.firstIndex(where: { $0.identifier == lastAppliedIdentifier }),
613+
let lastAppliedMigration = appliedMigrations.last,
614+
let lastAppliedIndex = _migrations.firstIndex(where: { $0.identifier == lastAppliedMigration }),
486615
targetIndex < lastAppliedIndex
487616
{
488617
fatalError("database is already migrated beyond migration \(String(reflecting: targetIdentifier))")
489618
}
490619

491-
let unappliedMigrations = self.unappliedMigrations(
620+
let appliedIdentifiers = try self.appliedIdentifiers(db) // All ids, even unknown ones
621+
let unappliedExecutions = self.unappliedExecutions(
492622
upTo: targetIdentifier,
493623
appliedIdentifiers: appliedIdentifiers)
494624

495-
if unappliedMigrations.isEmpty {
625+
if unappliedExecutions.isEmpty {
496626
return
497627
}
498628

499-
for migration in unappliedMigrations {
500-
try migration.run(db)
629+
for execution in unappliedExecutions {
630+
switch execution.mode {
631+
case .run(let mergedIdentifiers):
632+
try execution.migration.run(db, mergedIdentifiers: mergedIdentifiers)
633+
case .deleteMergedIdentifiers:
634+
try execution.migration.deleteMergedIdentifiers(db)
635+
}
501636
}
502637
}
503638

GRDB/Migration/Migration.swift

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,56 +6,68 @@ struct Migration: Sendable {
66
case disabled
77
}
88

9+
typealias Migrate = @Sendable (_ db: Database, _ mergedIdentifiers: Set<String>) throws -> Void
10+
911
let identifier: String
12+
let mergedIdentifiers: Set<String>
1013
var foreignKeyChecks: ForeignKeyChecks
1114
// Private so that the guarantees of `run(_:)` are enforced.
12-
private let migrate: @Sendable (Database) throws -> Void
15+
private let migrate: Migrate
1316

1417
init(
1518
identifier: String,
19+
mergedIdentifiers: Set<String>,
1620
foreignKeyChecks: ForeignKeyChecks,
17-
migrate: @escaping @Sendable (Database) throws -> Void
21+
migrate: @escaping Migrate
1822
) {
1923
self.identifier = identifier
24+
self.mergedIdentifiers = mergedIdentifiers
2025
self.foreignKeyChecks = foreignKeyChecks
2126
self.migrate = migrate
2227
}
2328

24-
func run(_ db: Database) throws {
29+
func run(_ db: Database, mergedIdentifiers: Set<String>) throws {
2530
// Migrations access the raw SQLite schema, without alteration due
2631
// to the schemaSource. The goal is to ensure that migrations are
2732
// immutable, immune from spooky actions at a distance.
2833
try db.withSchemaSource(nil) {
2934
if try Bool.fetchOne(db, sql: "PRAGMA foreign_keys") ?? false {
3035
switch foreignKeyChecks {
3136
case .deferred:
32-
try runWithDeferredForeignKeysChecks(db)
37+
try runWithDeferredForeignKeysChecks(db, mergedIdentifiers: mergedIdentifiers)
3338
case .immediate:
34-
try runWithImmediateForeignKeysChecks(db)
39+
try runWithImmediateForeignKeysChecks(db, mergedIdentifiers: mergedIdentifiers)
3540
case .disabled:
36-
try runWithDisabledForeignKeysChecks(db)
41+
try runWithDisabledForeignKeysChecks(db, mergedIdentifiers: mergedIdentifiers)
3742
}
3843
} else {
39-
try runWithImmediateForeignKeysChecks(db)
44+
try runWithImmediateForeignKeysChecks(db, mergedIdentifiers: mergedIdentifiers)
4045
}
4146
}
4247
}
4348

44-
private func runWithImmediateForeignKeysChecks(_ db: Database) throws {
49+
50+
func deleteMergedIdentifiers(_ db: Database) throws {
51+
if mergedIdentifiers.isEmpty == false {
52+
try db.execute(literal: "DELETE FROM grdb_migrations WHERE identifier IN \(mergedIdentifiers)")
53+
}
54+
}
55+
56+
private func runWithImmediateForeignKeysChecks(_ db: Database, mergedIdentifiers: Set<String>) throws {
4557
try db.inTransaction(.immediate) {
46-
try migrate(db)
47-
try insertAppliedIdentifier(db)
58+
try migrate(db, mergedIdentifiers)
59+
try updateAppliedIdentifier(db)
4860
return .commit
4961
}
5062
}
5163

52-
private func runWithDisabledForeignKeysChecks(_ db: Database) throws {
64+
private func runWithDisabledForeignKeysChecks(_ db: Database, mergedIdentifiers: Set<String>) throws {
5365
try db.execute(sql: "PRAGMA foreign_keys = OFF")
5466
try throwingFirstError(
5567
execute: {
5668
try db.inTransaction(.immediate) {
57-
try migrate(db)
58-
try insertAppliedIdentifier(db)
69+
try migrate(db, mergedIdentifiers)
70+
try updateAppliedIdentifier(db)
5971
return .commit
6072
}
6173
},
@@ -64,7 +76,7 @@ struct Migration: Sendable {
6476
})
6577
}
6678

67-
private func runWithDeferredForeignKeysChecks(_ db: Database) throws {
79+
private func runWithDeferredForeignKeysChecks(_ db: Database, mergedIdentifiers: Set<String>) throws {
6880
// Support for database alterations described at
6981
// https://www.sqlite.org/lang_altertable.html#otheralter
7082
//
@@ -76,8 +88,8 @@ struct Migration: Sendable {
7688
execute: {
7789
// > 2. Start a transaction.
7890
try db.inTransaction(.immediate) {
79-
try migrate(db)
80-
try insertAppliedIdentifier(db)
91+
try migrate(db, mergedIdentifiers)
92+
try updateAppliedIdentifier(db)
8193

8294
// > 10. If foreign key constraints were originally enabled
8395
// > then run PRAGMA foreign_key_check to verify that the
@@ -96,7 +108,8 @@ struct Migration: Sendable {
96108
})
97109
}
98110

99-
private func insertAppliedIdentifier(_ db: Database) throws {
100-
try db.execute(sql: "INSERT INTO grdb_migrations (identifier) VALUES (?)", arguments: [identifier])
111+
private func updateAppliedIdentifier(_ db: Database) throws {
112+
try deleteMergedIdentifiers(db)
113+
try db.execute(literal: "INSERT INTO grdb_migrations (identifier) VALUES (\(identifier))")
101114
}
102115
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
---
2727

28-
**Latest release**: September 29, 2025 • [version 7.7.1](https://github.com/groue/GRDB.swift/tree/v7.7.0) • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 6 to GRDB 7](Documentation/GRDB7MigrationGuide.md)
28+
**Latest release**: October 2, 2025 • [version 7.8.0](https://github.com/groue/GRDB.swift/tree/v7.8.0) • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 6 to GRDB 7](Documentation/GRDB7MigrationGuide.md)
2929

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

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.7.1</string>
18+
<string>7.8.0</string>
1919
<key>CFBundleSignature</key>
2020
<string>????</string>
2121
<key>CFBundleVersion</key>

0 commit comments

Comments
 (0)