From 2fb599358b1761cc4c8099b3dd27f10348365317 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Mon, 31 Mar 2025 09:11:13 +0100 Subject: [PATCH 1/2] Minor README update --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index c469596..4f93077 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,11 @@ Hummingbird postgres provides implementations of the job and persist frameworks Setup run ```sh docker compose up -or -docker-compose up ``` -Tear down +Tear down ```sh docker compose down -or -docker-compose down ``` ## Documentation From b8c9814cda8e6c10eb4dd9c5b1ad8c080fb28fdb Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Mon, 31 Mar 2025 15:33:00 +0100 Subject: [PATCH 2/2] Use PostgresMigrations repo, and remove local version --- Package.swift | 19 +- README.md | 2 +- Sources/PostgresMigrations/Deprecations.swift | 26 - Sources/PostgresMigrations/Migration.swift | 65 --- .../PostgresMigrations/MigrationError.swift | 51 -- .../PostgresMigrations/MigrationService.swift | 52 -- Sources/PostgresMigrations/Migrations.swift | 492 ------------------ .../MigrationTests.swift | 476 ----------------- 8 files changed, 4 insertions(+), 1179 deletions(-) delete mode 100644 Sources/PostgresMigrations/Deprecations.swift delete mode 100644 Sources/PostgresMigrations/Migration.swift delete mode 100644 Sources/PostgresMigrations/MigrationError.swift delete mode 100644 Sources/PostgresMigrations/MigrationService.swift delete mode 100644 Sources/PostgresMigrations/Migrations.swift delete mode 100644 Tests/PostgresMigrationsTests/MigrationTests.swift diff --git a/Package.swift b/Package.swift index 4d4744b..b6e285b 100644 --- a/Package.swift +++ b/Package.swift @@ -9,30 +9,23 @@ let package = Package( name: "hummingbird-postgres", platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)], products: [ - .library(name: "HummingbirdPostgres", targets: ["HummingbirdPostgres"]), - .library(name: "PostgresMigrations", targets: ["PostgresMigrations"]), + .library(name: "HummingbirdPostgres", targets: ["HummingbirdPostgres"]) ], dependencies: [ .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.5.0"), + .package(url: "https://github.com/hummingbird-project/postgres-migrations", from: "0.1.0"), .package(url: "https://github.com/vapor/postgres-nio", from: "1.25.0"), ], targets: [ .target( name: "HummingbirdPostgres", dependencies: [ - "PostgresMigrations", + .product(name: "PostgresMigrations", package: "postgres-migrations"), .product(name: "Hummingbird", package: "hummingbird"), .product(name: "PostgresNIO", package: "postgres-nio"), ], swiftSettings: swiftSettings ), - .target( - name: "PostgresMigrations", - dependencies: [ - .product(name: "PostgresNIO", package: "postgres-nio") - ], - swiftSettings: swiftSettings - ), .testTarget( name: "HummingbirdPostgresTests", dependencies: [ @@ -40,12 +33,6 @@ let package = Package( .product(name: "HummingbirdTesting", package: "hummingbird"), ] ), - .testTarget( - name: "PostgresMigrationsTests", - dependencies: [ - "PostgresMigrations" - ] - ), ], swiftLanguageVersions: [.v5, .version("6")] ) diff --git a/README.md b/README.md index 4f93077..93f534d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ # Hummingbird Postgres -Hummingbird postgres provides implementations of the job and persist frameworks from Hummingbird and a database migration system all using the `PostgresClient` from PostgresNIO. +HummingbirdPostgres provides implementations of the persist frameworks from Hummingbird using `PostgresClient` from PostgresNIO. ## Local Development setup diff --git a/Sources/PostgresMigrations/Deprecations.swift b/Sources/PostgresMigrations/Deprecations.swift deleted file mode 100644 index 0f7809b..0000000 --- a/Sources/PostgresMigrations/Deprecations.swift +++ /dev/null @@ -1,26 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2024 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -// Below is a list of unavailable symbols with the "HB" prefix. These are available -// temporarily to ease transition from the old symbols that included the "HB" -// prefix to the new ones. -// -// This file will be removed before we do a 2.0 release - -@_documentation(visibility: internal) @available(*, deprecated, renamed: "DatabaseMigration") -public typealias PostgresMigration = DatabaseMigration -@_documentation(visibility: internal) @available(*, deprecated, renamed: "DatabaseMigrations") -public typealias PostgresMigrations = DatabaseMigrations -@_documentation(visibility: internal) @available(*, deprecated, renamed: "DatabaseMigrationGroup") -public typealias PostgresMigrationGroup = DatabaseMigrationGroup diff --git a/Sources/PostgresMigrations/Migration.swift b/Sources/PostgresMigrations/Migration.swift deleted file mode 100644 index e4df9f3..0000000 --- a/Sources/PostgresMigrations/Migration.swift +++ /dev/null @@ -1,65 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2024 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Logging -import PostgresNIO - -/// Protocol for a database migration -/// -/// Requires two functions one to apply the database migration and one to revert it. -public protocol DatabaseMigration: Sendable { - /// Apply database migration - func apply(connection: PostgresConnection, logger: Logger) async throws - /// Revert database migration - func revert(connection: PostgresConnection, logger: Logger) async throws - /// DatabaseMigration name - var name: String { get } - /// Group migration belongs to - var group: DatabaseMigrationGroup { get } -} - -extension DatabaseMigration { - /// Default implementaion of name - public var name: String { String(describing: Self.self) } - /// Default group is default - public var group: DatabaseMigrationGroup { .default } -} - -/// Group identifier for a group of migrations. -/// -/// DatabaseMigrations in one group are treated independently of migrations in other groups. You can add a -/// migration to a group and it will not affect any subsequent migrations not in that group. By default -/// all migrations belong to the ``default`` group. -/// -/// To add a migration to a separate group you first need to define the group by adding a static variable -/// to `DatabaseMigrationGroup`. -/// ``` -/// extension DatabaseMigrationGroup { -/// public static var `myGroup`: Self { .init("myGroup") } -/// } -/// ``` -/// After that set `DatabaseMigration.group` to `.myGroup`. -/// -/// Only use a group different from `.default` if you are certain that the database elements you are -/// creating within that group will always be independent of everything else in the database. Groups -/// are useful for libraries that use migrations to setup their database elements. -public struct DatabaseMigrationGroup: Hashable, Equatable, Sendable { - let name: String - - public init(_ name: String) { - self.name = name - } - - public static var `default`: Self { .init("_hb_default") } -} diff --git a/Sources/PostgresMigrations/MigrationError.swift b/Sources/PostgresMigrations/MigrationError.swift deleted file mode 100644 index de83e8b..0000000 --- a/Sources/PostgresMigrations/MigrationError.swift +++ /dev/null @@ -1,51 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2024 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -/// Error thrown by migration code -public struct DatabaseMigrationError: Error, Equatable { - enum _Internal { - case dupicateNames - case requiresChanges - case appliedMigrationsInconsistent - case cannotRevertMigration - } - - fileprivate let value: _Internal - - fileprivate init(_ value: _Internal) { - self.value = value - } - - /// The migration list has duplicate names in it - public static var dupicateNames: Self { .init(.dupicateNames) } - /// The database requires a migration before the application can run - public static var requiresChanges: Self { .init(.requiresChanges) } - /// Applied migrations are inconsistent with expected list - public static var appliedMigrationsInconsistent: Self { .init(.appliedMigrationsInconsistent) } - /// Cannot revert a migration as we do not have its details. Add it to the revert list using - /// PostgresMigrations.add(revert:) - public static var cannotRevertMigration: Self { .init(.cannotRevertMigration) } -} - -extension DatabaseMigrationError: CustomStringConvertible { - public var description: String { - switch self.value { - case .dupicateNames: "The migration list has duplicate names in it." - case .requiresChanges: "Database requires changes. Run `migrate` with `dryRun` set to false." - case .appliedMigrationsInconsistent: "Applied migrations are inconsistent with expected list." - case .cannotRevertMigration: - "Cannot revert migration because we don't have its details. Use `PostgresMigrations.register` to register the DatabaseMigration." - } - } -} diff --git a/Sources/PostgresMigrations/MigrationService.swift b/Sources/PostgresMigrations/MigrationService.swift deleted file mode 100644 index d4ba312..0000000 --- a/Sources/PostgresMigrations/MigrationService.swift +++ /dev/null @@ -1,52 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2025 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Logging -import PostgresNIO -import ServiceLifecycle - -/// Service that runs a database migration -public struct DatabaseMigrationService: Service { - let client: PostgresClient - let groups: [DatabaseMigrationGroup] - let migrations: DatabaseMigrations - let logger: Logger - let dryRun: Bool - - /// Initialize DatabaseMigrationService - /// - Parameters: - /// - client: Postgres client - /// - migrations: Migrations to apply - /// - groups: Migration groups to apply - /// - logger: logger - /// - dryRun: Is this a dry run - public init( - client: PostgresClient, - migrations: DatabaseMigrations, - groups: [DatabaseMigrationGroup] = [], - logger: Logger, - dryRun: Bool - ) { - self.client = client - self.groups = groups - self.migrations = migrations - self.logger = logger - self.dryRun = dryRun - } - - public func run() async throws { - try await self.migrations.apply(client: self.client, groups: self.groups, logger: self.logger, dryRun: self.dryRun) - try? await gracefulShutdown() - } -} diff --git a/Sources/PostgresMigrations/Migrations.swift b/Sources/PostgresMigrations/Migrations.swift deleted file mode 100644 index a7cf227..0000000 --- a/Sources/PostgresMigrations/Migrations.swift +++ /dev/null @@ -1,492 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2024 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Logging -import PostgresNIO - -/// Database migration support -public actor DatabaseMigrations { - enum State { - case waiting([CheckedContinuation]) - case completed - case failed(Error) - } - - var migrations: [DatabaseMigration] - var reverts: [String: DatabaseMigration] - var state: State - - /// Initialize a DatabaseMigrations object - public init() { - self.migrations = [] - self.reverts = [:] - self.state = .waiting([]) - } - - /// Add migration to list of migrations to be be applied - /// - Parameters - /// - migration: DatabaseMigration to be applied - /// - skipDuplicates: Only add migration if it doesn't exist in the list - public func add(_ migration: DatabaseMigration, skipDuplicates: Bool = false) { - if skipDuplicates { - let existingMigration = self.migrations.first { - type(of: $0) == type(of: migration) - } - guard existingMigration == nil else { return } - } - self.migrations.append(migration) - } - - /// Register migration without it being applied - /// - /// This is useful for migrations you might have to revert. - /// - Parameter migration: DatabaseMigration to be registerd - public func register(_ migration: DatabaseMigration) { - self.reverts[migration.name] = migration - } - - /// Apply database migrations - /// - /// This function compares the list of applied migrations and the list of desired migrations. If there - /// are migrations in the applied list that don't exist in the desired list or the order of migrations - /// is different in the applied list then an error is thrown. - /// - /// - Parameters: - /// - client: Postgres client - /// - groups: Migration groups to apply, an empty array means all groups - /// - logger: Logger to use - /// - dryRun: Should migrations actually be applied, or should we just report what would be applied and reverted - public func apply( - client: PostgresClient, - groups: [DatabaseMigrationGroup] = [], - logger: Logger, - dryRun: Bool - ) async throws { - try checkForDuplicates(logger: logger) - // wait a small period to ensure the PostgresClient has started up - try await Task.sleep(for: .microseconds(100)) - switch self.state { - case .completed, .failed: - self.state = .waiting([]) - case .waiting: - break - } - let migrations = self.migrations - let repository = PostgresMigrationRepository(client: client) - do { - // setup migration repository (create table) - _ = try await repository.setup(client: client, logger: logger) - // get migrations currently applied in the order they were applied - let appliedMigrations = try await repository.getAll(client: client, logger: logger) - - // if groups array passed in is empty then work out list of migration groups by combining - // list of groups from migrations and applied migrations - let groups = - groups.count == 0 - ? (migrations.map(\.group) + appliedMigrations.map(\.group)).uniqueElements - : groups - var migrationsToApply: [DatabaseMigration] = .init() - // for each group apply/revert migrations - for group in groups { - let groupMigrations = migrations.filter { $0.group == group } - let appliedGroupMigrations = appliedMigrations.filter { $0.group == group } - - let minMigrationCount = min(groupMigrations.count, appliedGroupMigrations.count) - var i = 0 - // while migrations and applied migrations are the same - while i < minMigrationCount, - appliedGroupMigrations[i].name == groupMigrations[i].name - { - i += 1 - } - guard i == appliedGroupMigrations.count else { - logger.error("Applied migrations in \(group.name) group are inconsistent with migration list") - printMigrationComparison(expected: groupMigrations.map(\.name), applied: appliedGroupMigrations.map(\.name), logger: logger) - throw DatabaseMigrationError.appliedMigrationsInconsistent - } - // Add migrations that have not been applied to list - migrationsToApply.append(contentsOf: groupMigrations[i...]) - for migration in migrationsToApply { - logger.info("Migrating \(migration.name) from group \(group.name) \(dryRun ? " (dry run)" : "")") - } - } - if dryRun { - if migrationsToApply.count > 0 { - throw DatabaseMigrationError.requiresChanges - } - } else if migrationsToApply.count > 0 { - _ = try await repository.withTransaction(logger: logger) { context in - for migration in migrationsToApply { - try await migration.apply( - connection: context.connection, - logger: context.logger - ) - try await repository.add(migration, context: context) - } - } - } - } catch { - self.setFailed(error) - throw error - } - self.setCompleted() - } - - /// Revert database migrations - /// - /// This will revert all the migrations in the applied migration list - /// - Parameters: - /// - client: Postgres client - /// - groups: Migration groups to revert, an empty array means all groups - /// - logger: Logger to use - /// - dryRun: Should migrations actually be reverted, or should we just report what would be reverted - public func revert( - client: PostgresClient, - groups: [DatabaseMigrationGroup] = [], - logger: Logger, - dryRun: Bool - ) async throws { - let repository = PostgresMigrationRepository(client: client) - do { - let migrations = self.migrations - // build map of registered migrations - let registeredMigrations = { - var registeredMigrations = self.reverts - for migration in migrations { - registeredMigrations[migration.name] = migration - } - return registeredMigrations - }() - - // setup migration repository (create table) - _ = try await repository.setup(client: client, logger: logger) - // get migrations currently applied in the order they were applied - let appliedMigrations = try await repository.getAll(client: client, logger: logger) - - // if groups array passed in is empty then work out list of migration groups by combining - // list of groups from migrations and applied migrations - let groups = - groups.count == 0 - ? (migrations.map(\.group) + appliedMigrations.map(\.group)).uniqueElements - : groups - var migrationsToRevert: [DatabaseMigration] = .init() - // for each group revert migrations - for group in groups { - let appliedGroupMigrations = appliedMigrations.filter { $0.group == group } - // Revert migrations in reverse - for j in (0.. 0 { - throw DatabaseMigrationError.requiresChanges - } - } else if migrationsToRevert.count > 0 { - _ = try await repository.withTransaction(logger: logger) { context in - for migration in migrationsToRevert { - try await migration.revert( - connection: context.connection, - logger: context.logger - ) - try await repository.remove(migration, context: context) - } - } - } - } catch { - self.setFailed(error) - throw error - } - } - - /// Revert database migrations that are inconsistent with the migration list - /// - /// This will revert any migrations in the applied migration list after an inconsistency has been found in - /// list eg a migration is missing or the order of migrations has changed. This is a destructive action - /// so it is best to run this with dryRun set to true before running it without so you know what migrations - /// it will revert. - /// - /// For a migration to be removed it has to have been registered either using - /// ``DatabaseMigrations/add(_:skipDuplicates:)`` or ``DatabaseMigrations/register(_:)``. - /// - /// - Parameters: - /// - client: Postgres client - /// - groups: Migration groups to revert, an empty array means all groups - /// - logger: Logger to use - /// - dryRun: Should migrations actually be reverted, or should we just report what would be reverted - public func revertInconsistent( - client: PostgresClient, - groups: [DatabaseMigrationGroup] = [], - logger: Logger, - dryRun: Bool - ) async throws { - let repository = PostgresMigrationRepository(client: client) - do { - let migrations = self.migrations - // build map of registered migrations - let registeredMigrations = { - var registeredMigrations = self.reverts - for migration in migrations { - registeredMigrations[migration.name] = migration - } - return registeredMigrations - }() - // setup migration repository (create table) - _ = try await repository.setup(client: client, logger: logger) - // get migrations currently applied in the order they were applied - let appliedMigrations = try await repository.getAll(client: client, logger: logger) - - var migrationsToRevert: [DatabaseMigration] = .init() - // if groups array passed in is empty then work out list of migration groups by combining - // list of groups from migrations and applied migrations - let groups = - groups.count == 0 - ? (migrations.map(\.group) + appliedMigrations.map(\.group)).uniqueElements - : groups - // for each group revert migrations - for group in groups { - let groupMigrations = migrations.filter { $0.group == group } - let appliedGroupMigrations = appliedMigrations.filter { $0.group == group } - - let minMigrationCount = min(groupMigrations.count, appliedGroupMigrations.count) - var i = 0 - // while migrations and applied migrations are the same - while i < minMigrationCount, - appliedGroupMigrations[i].name == groupMigrations[i].name - { - i += 1 - } - // Revert migrations in reverse - for j in (i.. 0 { - throw DatabaseMigrationError.requiresChanges - } - } else if migrationsToRevert.count > 0 { - _ = try await repository.withTransaction(logger: logger) { context in - for migration in migrationsToRevert { - try await migration.revert( - connection: context.connection, - logger: context.logger - ) - try await repository.remove(migration, context: context) - } - } - } - } catch { - self.setFailed(error) - throw error - } - } - - /// Report if the migration process has completed - public func waitUntilCompleted() async throws { - switch self.state { - case .waiting(var continuations): - return try await withCheckedThrowingContinuation { cont in - continuations.append(cont) - self.state = .waiting(continuations) - } - case .completed: - return - case .failed(let error): - throw error - } - } - - func setCompleted() { - switch self.state { - case .waiting(let continuations): - for cont in continuations { - cont.resume() - } - self.state = .completed - case .completed: - break - case .failed: - preconditionFailure("Cannot set it has completed after having set it has failed") - } - } - - func setFailed(_ error: Error) { - switch self.state { - case .waiting(let continuations): - for cont in continuations { - cont.resume(throwing: error) - } - self.state = .failed(error) - case .completed: - preconditionFailure("Cannot set it has failed after having set it has completed") - case .failed(let error): - self.state = .failed(error) - } - } - - /// verify migration list doesnt have duplicates - func checkForDuplicates(logger: Logger) throws { - var foundDuplicates = false - let groups = migrations.map(\.group).uniqueElements - for group in groups { - let groupMigrations = self.migrations.filter { $0.group == group } - let groupMigrationSet = Set(groupMigrations.map(\.name)) - guard groupMigrationSet.count != groupMigrations.count else { - continue - } - foundDuplicates = true - for index in 0.. $1.count }?.count ?? 0) + 4, 13) - func printLine(expected: String, applied: String) { - let gap = String(repeating: " ", count: maxLength - expected.count) - logger.error("\(expected)\(gap)\(applied)") - } - - var expectedIndex = 0 - var appliedIndex = 0 - printLine(expected: "Expected:", applied: "Applied:") - while true { - let expected = expectedIndex < expectedList.count ? expectedList[expectedIndex] : "" - var applied = appliedIndex < appliedList.count ? appliedList[appliedIndex] : "" - - if expected == "" && applied == "" { - break - } - if applied != "" { - if expected != applied { - applied += " ❌" - } - } - printLine(expected: expected, applied: applied) - - expectedIndex += 1 - appliedIndex += 1 - } - } -} - -/// Create, remove and list migrations -struct PostgresMigrationRepository: Sendable { - struct Context: Sendable { - let connection: PostgresConnection - let logger: Logger - } - - let client: PostgresClient - #if compiler(>=6.0) - func withTransaction( - logger: Logger, - isolation: isolated (any Actor)? = #isolation, - _ process: (Context) async throws -> Value - ) async throws -> Value { - try await self.client.withTransaction(logger: logger) { connection in - try await process(.init(connection: connection, logger: logger)) - } - } - #else - func withTransaction( - logger: Logger, - _ process: (Context) async throws -> Value - ) async throws -> Value { - try await self.client.withTransaction(logger: logger) { connection in - try await process(.init(connection: connection, logger: logger)) - } - } - #endif - - func setup(client: PostgresClient, logger: Logger) async throws { - try await self.createMigrationsTable(client: client, logger: logger) - } - - func add(_ migration: DatabaseMigration, context: Context) async throws { - try await context.connection.query( - "INSERT INTO _hb_pg_migrations (\"name\", \"group\") VALUES (\(migration.name), \(migration.group.name))", - logger: context.logger - ) - } - - func remove(_ migration: DatabaseMigration, context: Context) async throws { - try await context.connection.query( - "DELETE FROM _hb_pg_migrations WHERE name = \(migration.name)", - logger: context.logger - ) - } - - func getAll(client: PostgresClient, logger: Logger) async throws -> [(name: String, group: DatabaseMigrationGroup)] { - let stream = try await client.query( - "SELECT \"name\", \"group\" FROM _hb_pg_migrations ORDER BY \"order\"", - logger: logger - ) - var result: [(String, DatabaseMigrationGroup)] = [] - for try await (name, group) in stream.decode((String, String).self, context: .default) { - result.append((name, .init(group))) - } - return result - } - - private func createMigrationsTable(client: PostgresClient, logger: Logger) async throws { - try await client.query( - """ - CREATE TABLE IF NOT EXISTS _hb_pg_migrations ( - "order" SERIAL PRIMARY KEY, - "name" text, - "group" text - ) - """, - logger: logger - ) - } -} - -extension Array where Element: Equatable { - /// The list of unique elements in the list, in the order they are found - var uniqueElements: [Element] { - self.reduce([]) { result, name in - if result.first(where: { $0 == name }) == nil { - var result = result - result.append(name) - return result - } - return result - } - } -} diff --git a/Tests/PostgresMigrationsTests/MigrationTests.swift b/Tests/PostgresMigrationsTests/MigrationTests.swift deleted file mode 100644 index 234d1a8..0000000 --- a/Tests/PostgresMigrationsTests/MigrationTests.swift +++ /dev/null @@ -1,476 +0,0 @@ -import Atomics -import Foundation -import Logging -import PostgresNIO -import ServiceLifecycle -import XCTest - -@testable import PostgresMigrations - -func getPostgresConfiguration() async throws -> PostgresClient.Configuration { - .init( - host: ProcessInfo.processInfo.environment["POSTGRES_HOSTNAME"] ?? "localhost", - port: 5432, - username: ProcessInfo.processInfo.environment["POSTGRES_USER"] ?? "test_user", - password: ProcessInfo.processInfo.environment["POSTGRES_PASSWORD"] ?? "test_password", - database: ProcessInfo.processInfo.environment["POSTGRES_DB"] ?? "test_db", - tls: .disable - ) -} - -final class MigrationTests: XCTestCase { - /// Migration you can set name and group of - struct TestMigration: DatabaseMigration { - init(name: String, group: DatabaseMigrationGroup = .default) { - self.name = name - self.group = group - } - - func apply(connection: PostgresConnection, logger: Logger) async throws {} - func revert(connection: PostgresConnection, logger: Logger) async throws {} - - let name: String - let group: DatabaseMigrationGroup - } - /// Test migration used to verify order or apply and reverts - struct TestOrderMigration: DatabaseMigration { - final class Order: Sendable { - let value: ManagedAtomic - - init() { - self.value = .init(1) - } - - func expect(_ value: Int, file: StaticString = #filePath, line: UInt = #line) { - XCTAssertEqual(value, self.value.load(ordering: .relaxed), file: file, line: line) - self.value.wrappingIncrement(by: 1, ordering: .relaxed) - } - } - - init( - name: String, - order: Order = Order(), - applyOrder: Int? = nil, - revertOrder: Int? = nil, - group: DatabaseMigrationGroup = .default - ) { - self.order = order - self.name = name - self.group = group - self.expectedApply = applyOrder - self.expectedRevert = revertOrder - } - - func apply(connection: PostgresConnection, logger: Logger) async throws { - if let expectedApply { - self.order.expect(expectedApply) - } - } - - func revert(connection: PostgresConnection, logger: Logger) async throws { - if let expectedRevert { - self.order.expect(expectedRevert) - } - } - - let name: String - let group: DatabaseMigrationGroup - let order: Order - let expectedApply: Int? - let expectedRevert: Int? - } - - static let logger = Logger(label: "MigrationTests") - - override func setUp() async throws {} - - func testMigrations( - revert: Bool = true, - groups: [DatabaseMigrationGroup] = [.default], - _ setup: (DatabaseMigrations) async throws -> Void, - verify: (DatabaseMigrations, PostgresClient) async throws -> Void, - file: StaticString = #filePath, - line: UInt = #line - ) async throws { - let logger = { - var logger = Logger(label: "MigrationTests") - logger.logLevel = .debug - return logger - }() - let client = try await PostgresClient( - configuration: getPostgresConfiguration(), - backgroundLogger: logger - ) - let migrations = DatabaseMigrations() - try await setup(migrations) - do { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await client.run() - } - do { - try await verify(migrations, client) - if revert { try await migrations.revert(client: client, groups: groups, logger: logger, dryRun: false) } - } catch { - if revert { try await migrations.revert(client: client, groups: groups, logger: logger, dryRun: false) } - throw error - } - group.cancelAll() - } - } catch let error as PSQLError { - XCTFail("\(String(reflecting: error))", file: file, line: line) - } - } - - func getAll(client: PostgresClient, groups: [DatabaseMigrationGroup] = [.default]) async throws -> [String] { - let repository = PostgresMigrationRepository(client: client) - return try await repository.getAll(client: client, logger: Self.logger).compactMap { migration in - if groups.first(where: { group in group == migration.group }) != nil { - return migration.name - } else { - return nil - } - } - } - - // MARK: Tests - - func testMigrate() async throws { - let order = TestOrderMigration.Order() - try await self.testMigrations { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - order.expect(3) - let migrations = try await getAll(client: client) - XCTAssertEqual(migrations.count, 2) - XCTAssertEqual(migrations[0], "test1") - XCTAssertEqual(migrations[1], "test2") - } - } - - func testCheckForDuplicates() async throws { - let order = TestOrderMigration.Order() - try await self.testMigrations { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1), skipDuplicates: true) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - order.expect(2) - let migrations = try await getAll(client: client) - XCTAssertEqual(migrations.count, 1) - XCTAssertEqual(migrations[0], "test1") - } - } - - func testRevert() async throws { - let order = TestOrderMigration.Order() - try await self.testMigrations { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1, revertOrder: 4)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2, revertOrder: 3)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - try await migrations.revert(client: client, groups: [.default], logger: Self.logger, dryRun: false) - order.expect(5) - let migrations = try await getAll(client: client) - XCTAssertEqual(migrations.count, 0) - } - } - - func testSecondMigrate() async throws { - let order = TestOrderMigration.Order() - try await self.testMigrations(revert: false) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - } - try await self.testMigrations { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2)) - await migrations.add(TestOrderMigration(name: "test3", order: order, applyOrder: 3)) - await migrations.add(TestOrderMigration(name: "test4", order: order, applyOrder: 4)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - let migrations = try await getAll(client: client) - order.expect(5) - XCTAssertEqual(migrations.count, 4) - XCTAssertEqual(migrations[0], "test1") - XCTAssertEqual(migrations[1], "test2") - XCTAssertEqual(migrations[2], "test3") - XCTAssertEqual(migrations[3], "test4") - } - } - - func testRemoveMigration() async throws { - let order = TestOrderMigration.Order() - try await self.testMigrations(revert: false) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2)) - await migrations.add(TestOrderMigration(name: "test3", order: order, applyOrder: 3)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - } - do { - try await self.testMigrations { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2)) - await migrations.register(TestOrderMigration(name: "test3", order: order, applyOrder: 3, revertOrder: 4)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - XCTFail("Applying migrations should fail as a migration has been removed") - } - } catch let error as DatabaseMigrationError where error == .appliedMigrationsInconsistent { - } - } - - func testReplaceMigration() async throws { - let order = TestOrderMigration.Order() - try await self.testMigrations(revert: false) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2)) - await migrations.add(TestOrderMigration(name: "test3", order: order, applyOrder: 3)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - } - do { - try await self.testMigrations { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2)) - await migrations.add(TestOrderMigration(name: "test4", order: order, applyOrder: 5)) - await migrations.register(TestOrderMigration(name: "test3", order: order, applyOrder: 3, revertOrder: 4)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - XCTFail("Applying migrations should fail as a migration has been replaced") - } - } catch let error as DatabaseMigrationError where error == .appliedMigrationsInconsistent { - } - } - - func testRevertInconsistent() async throws { - let order = TestOrderMigration.Order() - try await self.testMigrations(revert: false) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2)) - await migrations.add(TestOrderMigration(name: "test3", order: order, applyOrder: 3)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - } - - try await self.testMigrations { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2)) - await migrations.register(TestOrderMigration(name: "test3", order: order, applyOrder: 3, revertOrder: 4)) - } verify: { migrations, client in - try await migrations.revertInconsistent(client: client, groups: [.default], logger: Self.logger, dryRun: false) - let migrations = try await getAll(client: client) - XCTAssertEqual(migrations.count, 2) - XCTAssertEqual(migrations[0], "test1") - XCTAssertEqual(migrations[1], "test2") - } - - } - - func testDryRun() async throws { - do { - try await self.testMigrations(groups: [.default, .test]) { migrations in - await migrations.add(TestOrderMigration(name: "test1")) - await migrations.add(TestOrderMigration(name: "test2")) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: true) - } - XCTFail("Shouldn't get here") - } catch let error as DatabaseMigrationError where error == .requiresChanges {} - try await self.testMigrations(groups: [.default, .test]) { migrations in - await migrations.add(TestOrderMigration(name: "test1")) - await migrations.add(TestOrderMigration(name: "test2")) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: true) - } - } - - func testGroups() async throws { - let order = TestOrderMigration.Order() - try await self.testMigrations(groups: [.default, .test]) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1, group: .default)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2, group: .test)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default, .test], logger: Self.logger, dryRun: false) - order.expect(3) - let migrations = try await getAll(client: client, groups: [.default, .test]) - XCTAssertEqual(migrations.count, 2) - XCTAssertEqual(migrations[0], "test1") - XCTAssertEqual(migrations[1], "test2") - } - } - - func testAddingToGroup() async throws { - let order = TestOrderMigration.Order() - // Add two migrations from different groups - try await self.testMigrations(revert: false, groups: [.default, .test]) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1, group: .default)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2, group: .test)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default, .test], logger: Self.logger, dryRun: false) - let migrations = try await getAll(client: client, groups: [.default, .test]) - XCTAssertEqual(migrations[0], "test1") - XCTAssertEqual(migrations[1], "test2") - } - // Add additional migration to default group before the migration from the test group - try await self.testMigrations(groups: [.default, .test]) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1, group: .default)) - await migrations.add(TestOrderMigration(name: "test1_2", order: order, applyOrder: 3, group: .default)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2, group: .test)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default, .test], logger: Self.logger, dryRun: false) - let migrations = try await getAll(client: client, groups: [.default, .test]) - XCTAssertEqual(migrations.count, 3) - XCTAssertEqual(migrations[0], "test1") - XCTAssertEqual(migrations[1], "test2") - XCTAssertEqual(migrations[2], "test1_2") - } - } - - func testRemovingFromGroup() async throws { - let order = TestOrderMigration.Order() - // Add two migrations from different groups - try await self.testMigrations(revert: false, groups: [.default, .test]) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1, group: .default)) - await migrations.add(TestOrderMigration(name: "test1_2", order: order, applyOrder: 2, group: .default)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 3, group: .test)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default, .test], logger: Self.logger, dryRun: false) - let migrations = try await getAll(client: client, groups: [.default, .test]) - XCTAssertEqual(migrations[0], "test1") - XCTAssertEqual(migrations[1], "test1_2") - XCTAssertEqual(migrations[2], "test2") - } - // Remove migration from default group before the migration from the test group - do { - try await self.testMigrations(groups: [.default, .test]) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1, group: .default)) - await migrations.register(TestOrderMigration(name: "test1_2", order: order, revertOrder: 4, group: .default)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2, group: .test)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default, .test], logger: Self.logger, dryRun: false) - XCTFail("Applying migrations should fail as a migration has been removed") - } - } catch let error as DatabaseMigrationError where error == .appliedMigrationsInconsistent { - } - - } - - func testGroupsIgnoreOtherGroups() async throws { - let order = TestOrderMigration.Order() - // Add two migrations from different groups - try await self.testMigrations(revert: false, groups: [.default, .test]) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1, group: .default)) - await migrations.add(TestOrderMigration(name: "test2", order: order, applyOrder: 2, group: .test)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default, .test], logger: Self.logger, dryRun: false) - let migrations = try await getAll(client: client, groups: [.default, .test]) - XCTAssertEqual(migrations.count, 2) - XCTAssertEqual(migrations[0], "test1") - XCTAssertEqual(migrations[1], "test2") - } - // Only add the migration from the first group, but also only process the first group - try await self.testMigrations(groups: [.default]) { migrations in - await migrations.add(TestOrderMigration(name: "test1", order: order, applyOrder: 1, revertOrder: 3, group: .default)) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default], logger: Self.logger, dryRun: false) - let migrations = try await getAll(client: client, groups: [.default, .test]) - XCTAssertEqual(migrations.count, 2) - XCTAssertEqual(migrations[0], "test1") - XCTAssertEqual(migrations[1], "test2") - } - try await self.testMigrations(groups: [.default, .test]) { migrations in - await migrations.register(TestOrderMigration(name: "test2", order: order, applyOrder: 2, revertOrder: 4, group: .test)) - } verify: { _, _ in - } - order.expect(5) - } - - func testUniqueElements() { - XCTAssertEqual([1, 4, 67, 2, 1, 1, 5, 4].uniqueElements, [1, 4, 67, 2, 5]) - XCTAssertEqual([1, 1, 1, 2, 2].uniqueElements, [1, 2]) - XCTAssertEqual([2, 1, 1, 1, 2, 2].uniqueElements, [2, 1]) - } - - /// Test we catch when migrations with duplicate names are added - func testDuplicateMigrations() async throws { - do { - try await self.testMigrations { migrations in - await migrations.add(TestMigration(name: "test1")) - await migrations.add(TestMigration(name: "test1")) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default, .test], logger: Self.logger, dryRun: true) - } - XCTFail("Duplicate migration names should throw an error") - } catch let error as DatabaseMigrationError where error == .dupicateNames { - } catch { - XCTFail("\(error)") - } - } - - /// Test we don't error on migrations with the same name but in different groups - func testDuplicateMigrationsAcrossGroups() async throws { - try await self.testMigrations { migrations in - await migrations.add(TestMigration(name: "test1")) - await migrations.add(TestMigration(name: "test1", group: .test)) - await migrations.add(TestMigration(name: "test2")) - } verify: { migrations, client in - try await migrations.apply(client: client, groups: [.default, .test], logger: Self.logger, dryRun: false) - } - } - - func testMigrationService() async throws { - struct WaitRevertMigrationService: Service { - let client: PostgresClient - let migrations: DatabaseMigrations - let logger: Logger - func run() async throws { - try await migrations.waitUntilCompleted() - try await migrations.revert(client: client, groups: [.test], logger: logger, dryRun: false) - } - } - let logger = { - var logger = Logger(label: "MigrationTests") - logger.logLevel = .debug - return logger - }() - let client = try await PostgresClient( - configuration: getPostgresConfiguration(), - backgroundLogger: logger - ) - let migrations = DatabaseMigrations() - await migrations.add(TestMigration(name: "testMigrationService", group: .test)) - let serviceGroup = ServiceGroup( - configuration: .init( - services: [ - client, - DatabaseMigrationService( - client: client, - migrations: migrations, - groups: [.test], - logger: logger, - dryRun: false - ), - WaitRevertMigrationService(client: client, migrations: migrations, logger: logger), - ], - gracefulShutdownSignals: [.sigterm, .sigint], - logger: logger - ) - ) - do { - try await serviceGroup.run() - } catch let error as ServiceGroupError where error == .serviceFinishedUnexpectedly() { - // ... we're good - } - } -} - -extension DatabaseMigrationGroup { - static var test: Self { .init("test") } -}