536 lines
21 KiB
Swift
536 lines
21 KiB
Swift
#if canImport(Combine)
|
|
import Combine
|
|
#endif
|
|
import Foundation
|
|
|
|
// TODO: provide concurrent apis for migrations that run @Sendable closures.
|
|
/// A `DatabaseMigrator` registers and applies database migrations.
|
|
///
|
|
/// For an overview of database migrations and `DatabaseMigrator` usage,
|
|
/// see <doc:Migrations>.
|
|
///
|
|
/// ## Topics
|
|
///
|
|
/// ### Creating a DatabaseMigrator
|
|
///
|
|
/// - ``init()``
|
|
///
|
|
/// ### Registering Migrations
|
|
///
|
|
/// - ``registerMigration(_:foreignKeyChecks:migrate:)``
|
|
/// - ``ForeignKeyChecks``
|
|
///
|
|
/// ### Configuring a DatabaseMigrator
|
|
///
|
|
/// - ``eraseDatabaseOnSchemaChange``
|
|
/// - ``disablingDeferredForeignKeyChecks()``
|
|
///
|
|
/// ### Migrating a Database
|
|
///
|
|
/// - ``asyncMigrate(_:completion:)``
|
|
/// - ``migrate(_:)``
|
|
/// - ``migrate(_:upTo:)``
|
|
/// - ``migratePublisher(_:receiveOn:)``
|
|
///
|
|
/// ### Querying Migrations
|
|
///
|
|
/// - ``migrations``
|
|
/// - ``appliedIdentifiers(_:)``
|
|
/// - ``appliedMigrations(_:)``
|
|
/// - ``completedMigrations(_:)``
|
|
/// - ``hasBeenSuperseded(_:)``
|
|
/// - ``hasCompletedMigrations(_:)``
|
|
public struct DatabaseMigrator {
|
|
/// Controls how a migration handle foreign keys constraints.
|
|
public enum ForeignKeyChecks: Sendable {
|
|
/// The migration runs with disabled foreign keys.
|
|
///
|
|
/// Foreign keys are checked right before changes are committed on disk,
|
|
/// unless the `DatabaseMigrator` is the result of
|
|
/// ``DatabaseMigrator/disablingDeferredForeignKeyChecks()``.
|
|
///
|
|
/// In this case, you can perform your own deferred foreign key checks
|
|
/// with ``Database/checkForeignKeys(in:in:)`` or
|
|
/// ``Database/checkForeignKeys()``:
|
|
///
|
|
/// ```swift
|
|
/// migrator = migrator.disablingDeferredForeignKeyChecks()
|
|
/// migrator.registerMigration("Partially checked migration") { db in
|
|
/// ...
|
|
///
|
|
/// // Throws an error and stops migrations if there exists a
|
|
/// // foreign key violation in the 'book' table.
|
|
/// try db.checkForeignKeys(in: "book")
|
|
/// }
|
|
/// ```
|
|
case deferred
|
|
|
|
/// The migration runs with enabled foreign keys.
|
|
///
|
|
/// Immediate foreign key checks are NOT compatible with migrations that
|
|
/// recreate tables as described
|
|
/// in <doc:Migrations#Defining-the-Database-Schema-from-a-Migration>.
|
|
case immediate
|
|
}
|
|
|
|
/// A boolean value indicating whether the migrator recreates the whole
|
|
/// database from scratch if it detects a change in the definition
|
|
/// of migrations.
|
|
///
|
|
/// - warning: This flag can destroy your precious users' data!
|
|
///
|
|
/// When true, the database migrator wipes out the full database content,
|
|
/// and runs all migrations from the start, if one of those conditions
|
|
/// is met:
|
|
///
|
|
/// - A migration has been removed, or renamed.
|
|
/// - A schema change is detected. A schema change is any difference in
|
|
/// the `sqlite_master` table, which contains the SQL used to create
|
|
/// database tables, indexes, triggers, and views.
|
|
///
|
|
/// This flag is useful during application development: you are still
|
|
/// designing migrations, and the schema changes often.
|
|
///
|
|
/// It is recommended to not ship it in the distributed application, in
|
|
/// order to avoid undesired data loss. Use the `DEBUG`
|
|
/// compilation condition:
|
|
///
|
|
/// ```swift
|
|
/// var migrator = DatabaseMigrator()
|
|
/// #if DEBUG
|
|
/// // Speed up development by nuking the database when migrations change
|
|
/// migrator.eraseDatabaseOnSchemaChange = true
|
|
/// #endif
|
|
/// ```
|
|
public var eraseDatabaseOnSchemaChange = false
|
|
private var defersForeignKeyChecks = true
|
|
private var _migrations: [Migration] = []
|
|
|
|
/// A new migrator.
|
|
public init() {
|
|
}
|
|
|
|
// MARK: - Disabling Foreign Key Checks
|
|
|
|
/// Returns a migrator that disables foreign key checks in all newly
|
|
/// registered migrations.
|
|
///
|
|
/// The returned migrator is _unsafe_, because it no longer guarantees the
|
|
/// integrity of the database. It is now _your_ responsibility to register
|
|
/// migrations that do not break foreign key constraints. See
|
|
/// ``Database/checkForeignKeys()`` and ``Database/checkForeignKeys(in:in:)``.
|
|
///
|
|
/// Running migrations without foreign key checks can improve migration
|
|
/// performance on huge databases.
|
|
///
|
|
/// Example:
|
|
///
|
|
/// ```swift
|
|
/// var migrator = DatabaseMigrator()
|
|
/// migrator.registerMigration("A") { db in
|
|
/// // Runs with deferred foreign key checks
|
|
/// }
|
|
/// migrator.registerMigration("B", foreignKeyChecks: .immediate) { db in
|
|
/// // Runs with immediate foreign key checks
|
|
/// }
|
|
///
|
|
/// migrator = migrator.disablingDeferredForeignKeyChecks()
|
|
/// migrator.registerMigration("C") { db in
|
|
/// // Runs without foreign key checks
|
|
/// }
|
|
/// migrator.registerMigration("D", foreignKeyChecks: .immediate) { db in
|
|
/// // Runs with immediate foreign key checks
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// - warning: Before using this unsafe method, try to register your
|
|
/// migrations with the `foreignKeyChecks: .immediate` option, _if
|
|
/// possible_, as in the example above. This will enhance migration
|
|
/// performances, while preserving the database integrity guarantee.
|
|
public func disablingDeferredForeignKeyChecks() -> DatabaseMigrator {
|
|
with { $0.defersForeignKeyChecks = false }
|
|
}
|
|
|
|
// MARK: - Registering Migrations
|
|
|
|
/// Registers a migration.
|
|
///
|
|
/// The registered migration is appended to the list of migrations to run:
|
|
/// it will execute after previously registered migrations, and before
|
|
/// migrations that are registered later.
|
|
///
|
|
/// For example:
|
|
///
|
|
/// ```swift
|
|
/// migrator.registerMigration("createAuthors") { db in
|
|
/// try db.create(table: "author") { t in
|
|
/// t.autoIncrementedPrimaryKey("id")
|
|
/// t.column("creationDate", .datetime)
|
|
/// t.column("name", .text).notNull()
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// Database operations are wrapped in a transaction. If they throw an
|
|
/// error, the transaction is rollbacked, migrations are aborted, and the
|
|
/// error is thrown by the migrating method.
|
|
///
|
|
/// By default, database operations run with disabled foreign keys, and
|
|
/// foreign keys are checked right before changes are committed on disk. You
|
|
/// can control this behavior with the `foreignKeyChecks` argument.
|
|
///
|
|
/// Database operations run in the writer dispatch queue, serialized
|
|
/// with all database updates performed by the migrated `DatabaseWriter`.
|
|
///
|
|
/// The `Database` argument to `migrate` is valid only during the execution
|
|
/// of the closure. Do not store or return the database connection for
|
|
/// later use.
|
|
///
|
|
/// - parameters:
|
|
/// - identifier: The migration identifier.
|
|
/// - foreignKeyChecks: This parameter is ignored if the database
|
|
/// ``Configuration`` has disabled foreign keys.
|
|
///
|
|
/// The default `.deferred` checks have the migration run with
|
|
/// disabled foreign keys, until foreign keys are checked right before
|
|
/// changes are committed on disk. These deferred checks are not
|
|
/// executed if the migrator is the result of
|
|
/// ``disablingDeferredForeignKeyChecks()``.
|
|
///
|
|
/// The `.immediate` checks have the migration run with foreign
|
|
/// keys enabled. Make sure you only use `.immediate` if the migration
|
|
/// does not perform schema changes described in
|
|
/// <https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes>
|
|
/// - migrate: A closure that performs database operations.
|
|
/// - precondition: No migration with the same identifier as already
|
|
/// been registered.
|
|
public mutating func registerMigration(
|
|
_ identifier: String,
|
|
foreignKeyChecks: ForeignKeyChecks = .deferred,
|
|
migrate: @escaping (Database) throws -> Void)
|
|
{
|
|
let migrationChecks: Migration.ForeignKeyChecks
|
|
switch foreignKeyChecks {
|
|
case .deferred:
|
|
if defersForeignKeyChecks {
|
|
migrationChecks = .deferred
|
|
} else {
|
|
migrationChecks = .disabled
|
|
}
|
|
case .immediate:
|
|
migrationChecks = .immediate
|
|
}
|
|
registerMigration(Migration(identifier: identifier, foreignKeyChecks: migrationChecks, migrate: migrate))
|
|
}
|
|
|
|
// MARK: - Applying Migrations
|
|
|
|
/// Runs all unapplied migrations, in the same order as they
|
|
/// were registered.
|
|
///
|
|
/// - parameter writer: A DatabaseWriter.
|
|
/// - throws: The error thrown by the first failed migration.
|
|
public func migrate(_ writer: some DatabaseWriter) throws {
|
|
guard let lastMigration = _migrations.last else {
|
|
return
|
|
}
|
|
try migrate(writer, upTo: lastMigration.identifier)
|
|
}
|
|
|
|
/// Runs all unapplied migrations, in the same order as they
|
|
/// were registered, up to the target migration identifier (included).
|
|
///
|
|
/// - precondition: `targetIdentifier` is the identifier of a
|
|
/// registered migration.
|
|
///
|
|
/// - precondition: The database has not already been migrated beyond the
|
|
/// target migration.
|
|
///
|
|
/// - parameter writer: A DatabaseWriter.
|
|
/// - parameter targetIdentifier: A migration identifier.
|
|
/// - throws: The error thrown by the first failed migration.
|
|
public func migrate(_ writer: some DatabaseWriter, upTo targetIdentifier: String) throws {
|
|
try writer.barrierWriteWithoutTransaction { db in
|
|
try migrate(db, upTo: targetIdentifier)
|
|
}
|
|
}
|
|
|
|
/// Schedules unapplied migrations for execution, and returns immediately.
|
|
///
|
|
/// - parameter writer: A DatabaseWriter.
|
|
/// - parameter completion: A function that can access the database. Its
|
|
/// argument is a `Result` that provides a connection to the migrated
|
|
/// database, or the failure that prevented the migrations
|
|
/// from succeeding.
|
|
public func asyncMigrate(
|
|
_ writer: some DatabaseWriter,
|
|
completion: @escaping (Result<Database, Error>) -> Void)
|
|
{
|
|
writer.asyncBarrierWriteWithoutTransaction { dbResult in
|
|
do {
|
|
let db = try dbResult.get()
|
|
if let lastMigration = _migrations.last {
|
|
try migrate(db, upTo: lastMigration.identifier)
|
|
}
|
|
completion(.success(db))
|
|
} catch {
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Querying Migrations
|
|
|
|
/// The list of registered migration identifiers, in the same order as they
|
|
/// have been registered.
|
|
public var migrations: [String] {
|
|
_migrations.map(\.identifier)
|
|
}
|
|
|
|
/// Returns the identifiers of registered and applied migrations, in the
|
|
/// order of registration.
|
|
///
|
|
/// - parameter db: A database connection.
|
|
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs.
|
|
public func appliedMigrations(_ db: Database) throws -> [String] {
|
|
let appliedIdentifiers = try self.appliedIdentifiers(db)
|
|
return _migrations.map { $0.identifier }.filter { appliedIdentifiers.contains($0) }
|
|
}
|
|
|
|
/// Returns the applied migration identifiers, even unregistered ones.
|
|
///
|
|
/// - parameter db: A database connection.
|
|
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs.
|
|
public func appliedIdentifiers(_ db: Database) throws -> Set<String> {
|
|
do {
|
|
return try String.fetchSet(db, sql: "SELECT identifier FROM grdb_migrations")
|
|
} catch {
|
|
// Rethrow if we can't prove grdb_migrations does not exist yet
|
|
if (try? !db.tableExists("grdb_migrations")) ?? false {
|
|
return []
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/// Returns the identifiers of registered and completed migrations, in the
|
|
/// order of registration.
|
|
///
|
|
/// A migration is completed if and only if all previous migrations have
|
|
/// been applied.
|
|
///
|
|
/// - parameter db: A database connection.
|
|
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs.
|
|
public func completedMigrations(_ db: Database) throws -> [String] {
|
|
let appliedIdentifiers = try appliedMigrations(db)
|
|
let knownIdentifiers = _migrations.map(\.identifier)
|
|
return zip(appliedIdentifiers, knownIdentifiers)
|
|
.prefix(while: { (applied: String, known: String) in applied == known })
|
|
.map { $0.0 }
|
|
}
|
|
|
|
/// A boolean value indicating whether all registered migrations, and only
|
|
/// registered migrations, have been applied.
|
|
///
|
|
/// - parameter db: A database connection.
|
|
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs.
|
|
public func hasCompletedMigrations(_ db: Database) throws -> Bool {
|
|
try completedMigrations(db).last == _migrations.last?.identifier
|
|
}
|
|
|
|
/// A boolean value indicating whether the database refers to
|
|
/// unregistered migrations.
|
|
///
|
|
/// When the result is true, the database has likely been migrated by a
|
|
/// more recent migrator.
|
|
///
|
|
/// - parameter db: A database connection.
|
|
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs.
|
|
public func hasBeenSuperseded(_ db: Database) throws -> Bool {
|
|
let appliedIdentifiers = try self.appliedIdentifiers(db)
|
|
let knownIdentifiers = _migrations.map(\.identifier)
|
|
return appliedIdentifiers.contains { !knownIdentifiers.contains($0) }
|
|
}
|
|
|
|
// MARK: - Non public
|
|
|
|
private mutating func registerMigration(_ migration: Migration) {
|
|
GRDBPrecondition(
|
|
!_migrations.map({ $0.identifier }).contains(migration.identifier),
|
|
"already registered migration: \(String(reflecting: migration.identifier))")
|
|
_migrations.append(migration)
|
|
}
|
|
|
|
/// Returns unapplied migration identifier,
|
|
private func unappliedMigrations(upTo targetIdentifier: String, appliedIdentifiers: [String]) -> [Migration] {
|
|
var expectedMigrations: [Migration] = []
|
|
for migration in _migrations {
|
|
expectedMigrations.append(migration)
|
|
if migration.identifier == targetIdentifier {
|
|
break
|
|
}
|
|
}
|
|
|
|
// targetIdentifier must refer to a registered migration
|
|
GRDBPrecondition(
|
|
expectedMigrations.last?.identifier == targetIdentifier,
|
|
"undefined migration: \(String(reflecting: targetIdentifier))")
|
|
|
|
return expectedMigrations.filter { !appliedIdentifiers.contains($0.identifier) }
|
|
}
|
|
|
|
private func runMigrations(_ db: Database, upTo targetIdentifier: String) throws {
|
|
try db.execute(sql: "CREATE TABLE IF NOT EXISTS grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY)")
|
|
let appliedIdentifiers = try self.appliedMigrations(db)
|
|
|
|
// Subsequent migration must not be applied
|
|
if let targetIndex = _migrations.firstIndex(where: { $0.identifier == targetIdentifier }),
|
|
let lastAppliedIdentifier = appliedIdentifiers.last,
|
|
let lastAppliedIndex = _migrations.firstIndex(where: { $0.identifier == lastAppliedIdentifier }),
|
|
targetIndex < lastAppliedIndex
|
|
{
|
|
fatalError("database is already migrated beyond migration \(String(reflecting: targetIdentifier))")
|
|
}
|
|
|
|
let unappliedMigrations = self.unappliedMigrations(
|
|
upTo: targetIdentifier,
|
|
appliedIdentifiers: appliedIdentifiers)
|
|
|
|
if unappliedMigrations.isEmpty {
|
|
return
|
|
}
|
|
|
|
for migration in unappliedMigrations {
|
|
try migration.run(db)
|
|
}
|
|
}
|
|
|
|
private func migrate(_ db: Database, upTo targetIdentifier: String) throws {
|
|
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
|
|
}
|
|
}
|
|
|
|
return .commit
|
|
}
|
|
|
|
if needsErase {
|
|
try db.erase()
|
|
}
|
|
}
|
|
|
|
// Migrate to target schema
|
|
try runMigrations(db, upTo: targetIdentifier)
|
|
}
|
|
}
|
|
|
|
extension DatabaseMigrator: Refinable { }
|
|
|
|
#if canImport(Combine)
|
|
extension DatabaseMigrator {
|
|
// MARK: - Publishing Migrations
|
|
|
|
/// Returns a Publisher that asynchronously migrates a database.
|
|
///
|
|
/// The database is not accessed until subscription. Value and completion
|
|
/// are published on `scheduler` (the main dispatch queue by default).
|
|
///
|
|
/// - parameter writer: A DatabaseWriter.
|
|
/// where migrations should apply.
|
|
/// - parameter scheduler: A Combine Scheduler.
|
|
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
|
public func migratePublisher(
|
|
_ writer: some DatabaseWriter,
|
|
receiveOn scheduler: some Scheduler = DispatchQueue.main)
|
|
-> DatabasePublishers.Migrate
|
|
{
|
|
DatabasePublishers.Migrate(
|
|
upstream: OnDemandFuture { promise in
|
|
self.asyncMigrate(writer) { dbResult in
|
|
promise(dbResult.map { _ in })
|
|
}
|
|
}
|
|
.receiveValues(on: scheduler)
|
|
.eraseToAnyPublisher()
|
|
)
|
|
}
|
|
}
|
|
|
|
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
|
extension DatabasePublishers {
|
|
/// A publisher that migrates a database.
|
|
///
|
|
/// `Migrate` publishes exactly one element, or an error.
|
|
///
|
|
/// You build such a publisher from ``DatabaseMigrator``.
|
|
public struct Migrate: Publisher {
|
|
public typealias Output = Void
|
|
public typealias Failure = Error
|
|
|
|
fileprivate let upstream: AnyPublisher<Void, Error>
|
|
|
|
public func receive<S>(subscriber: S) where S: Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
|
|
upstream.receive(subscriber: subscriber)
|
|
}
|
|
}
|
|
}
|
|
#endif
|