From 14e10b35dfec7df73c3b6c15dedc6b511f499ea6 Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Thu, 23 Oct 2025 08:02:27 -0500 Subject: [PATCH 1/5] Bump Swift minimum to 6.0, update CI, convert tests to SwiftTesting --- .github/workflows/api-docs.yml | 3 + .github/workflows/test.yml | 86 ++-- Package.swift | 12 +- README.md | 4 +- .../Docs.docc/theme-settings.json | 6 +- .../PostgresError+Database.swift | 5 - .../FluentPostgresDriverTests.swift | 392 ++++++++++-------- ...luentPostgresTransactionControlTests.swift | 111 ++--- 8 files changed, 314 insertions(+), 305 deletions(-) diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index c6086d5..829c389 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -3,6 +3,9 @@ on: push: branches: - main +permissions: + contents: read + id-token: write jobs: build-and-deploy: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cd903c4..e2f5a40 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,22 +5,22 @@ concurrency: on: pull_request: { types: [opened, reopened, synchronize, ready_for_review] } push: { branches: [ main ] } - +permissions: + contents: read env: LOG_LEVEL: info - SWIFT_DETERMINISTIC_HASHING: 1 - POSTGRES_HOSTNAME: 'psql-a' - POSTGRES_HOSTNAME_A: 'psql-a' - POSTGRES_HOSTNAME_B: 'psql-b' - POSTGRES_DB: 'test_database_a' - POSTGRES_DB_A: 'test_database_a' - POSTGRES_DB_B: 'test_database_b' - POSTGRES_USER: 'test_username' - POSTGRES_USER_A: 'test_username' - POSTGRES_USER_B: 'test_username' - POSTGRES_PASSWORD: 'test_password' - POSTGRES_PASSWORD_A: 'test_password' - POSTGRES_PASSWORD_B: 'test_password' + POSTGRES_HOSTNAME_A: &postgres_host_a 'psql-a' + POSTGRES_HOSTNAME_B: &postgres_host_b 'psql-b' + POSTGRES_HOSTNAME: *postgres_host_a + POSTGRES_DB_A: &postgres_db_a 'test_database_a' + POSTGRES_DB_B: &postgres_db_b 'test_database_b' + POSTGRES_DB: *postgres_db_a + POSTGRES_USER_A: &postgres_user_a 'test_username' + POSTGRES_USER_B: &postgres_user_b 'test_username' + POSTGRES_USER: *postgres_user_a + POSTGRES_PASSWORD_A: &postgres_pass_a 'test_password' + POSTGRES_PASSWORD_B: &postgres_pass_b 'test_password' + POSTGRES_PASSWORD: *postgres_pass_a jobs: api-breakage: @@ -42,35 +42,35 @@ jobs: fail-fast: false matrix: include: - - postgres-image-a: 'postgres:12' - postgres-image-b: 'postgres:13' + - postgres-image-a: 'postgres:13' + postgres-image-b: 'postgres:14' postgres-auth: 'trust' - swift-image: 'swift:5.10-jammy' - - postgres-image-a: 'postgres:14' - postgres-image-b: 'postgres:15' - postgres-auth: 'md5' swift-image: 'swift:6.0-noble' - - postgres-image-a: 'postgres:16' - postgres-image-b: 'postgres:17' - postgres-auth: 'scram-sha-256' + - postgres-image-a: 'postgres:15' + postgres-image-b: 'postgres:16' + postgres-auth: 'md5' swift-image: 'swift:6.1-noble' + - postgres-image-a: 'postgres:17' + postgres-image-b: 'postgres:18' + postgres-auth: 'scram-sha-256' + swift-image: 'swift:6.2-noble' container: ${{ matrix.swift-image }} runs-on: ubuntu-latest services: - psql-a: + *postgres_host_a: image: ${{ matrix.postgres-image-a }} env: - POSTGRES_USER: 'test_username' - POSTGRES_DB: 'test_database_a' - POSTGRES_PASSWORD: 'test_password' + POSTGRES_USER: *postgres_user_a + POSTGRES_DB: *postgres_db_a + POSTGRES_PASSWORD: *postgres_pass_a POSTGRES_HOST_AUTH_METHOD: ${{ matrix.postgres-auth }} POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.postgres-auth }} - psql-b: + *postgres_host_b: image: ${{ matrix.postgres-image-b }} env: - POSTGRES_USER: 'test_username' - POSTGRES_DB: 'test_database_b' - POSTGRES_PASSWORD: 'test_password' + POSTGRES_USER: *postgres_user_b + POSTGRES_DB: *postgres_db_b + POSTGRES_PASSWORD: *postgres_pass_b POSTGRES_HOST_AUTH_METHOD: ${{ matrix.postgres-auth }} POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.postgres-auth }} steps: @@ -79,7 +79,7 @@ jobs: - name: Check out package uses: actions/checkout@v5 - name: Run all tests - run: swift test --enable-code-coverage + run: swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable - name: Submit coverage report to Codecov.io uses: vapor/swift-codecov-action@v0.3 with: @@ -93,6 +93,8 @@ jobs: include: - macos-version: macos-15 xcode-version: latest-stable + - macos-version: macos-26 + xcode-version: latest-stable runs-on: ${{ matrix.macos-version }} env: LOG_LEVEL: debug @@ -108,7 +110,7 @@ jobs: run: | brew upgrade || true export PATH="$(brew --prefix)/opt/postgresql@16/bin:$PATH" PGDATA=/tmp/vapor-postgres-test PGUSER="${POSTGRES_USER_A}" - brew install postgresql@17 && brew link --force postgresql@17 + brew install postgresql@18 && brew link --force postgresql@18 initdb --locale=C --auth-host "scram-sha-256" -U "${POSTGRES_USER_A}" --pwfile=<(echo "${POSTGRES_PASSWORD_A}") pg_ctl start --wait PGPASSWORD="${POSTGRES_PASSWORD_A}" createdb -w -O "${POSTGRES_USER_A}" "${POSTGRES_DB_A}" @@ -119,4 +121,20 @@ jobs: - name: Checkout code uses: actions/checkout@v5 - name: Run all tests - run: swift test + run: swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable + - name: Submit coverage report to Codecov.io + uses: vapor/swift-codecov-action@v0.3 + with: + codecov_token: ${{ secrets.CODECOV_TOKEN }} + + musl: + runs-on: ubuntu-latest + container: swift:6.2-noble + timeout-minutes: 30 + steps: + - name: Check out code + uses: actions/checkout@v5 + - name: Install SDK + run: swift sdk install https://download.swift.org/swift-6.2-release/static-sdk/swift-6.2-RELEASE/swift-6.2-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum d2225840e592389ca517bbf71652f7003dbf45ac35d1e57d98b9250368769378 + - name: Build + run: swift build --swift-sdk x86_64-swift-linux-musl diff --git a/Package.swift b/Package.swift index 0ea5ef3..93320b0 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:6.0 import PackageDescription let package = Package( @@ -14,7 +14,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.52.2"), - .package(url: "https://github.com/vapor/postgres-kit.git", from: "2.14.0"), + .package(url: "https://github.com/vapor/postgres-kit.git", from: "2.14.1"), .package(url: "https://github.com/vapor/async-kit.git", from: "1.21.0"), ], targets: [ @@ -41,9 +41,9 @@ let package = Package( var swiftSettings: [SwiftSetting] { [ .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), .enableUpcomingFeature("MemberImportVisibility"), - .enableUpcomingFeature("ConciseMagicFile"), - .enableUpcomingFeature("ForwardTrailingClosures"), - .enableUpcomingFeature("DisableOutwardActorInference"), - .enableExperimentalFeature("StrictConcurrency=complete"), + .enableUpcomingFeature("InferIsolatedConformances"), + //.enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("ImmutableWeakCaptures"), ] } diff --git a/README.md b/README.md index 632b114..08a2ba1 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Team Chat MIT License Continuous Integration - -Swift 5.10+ +Code Coverage +Swift 6.0+


diff --git a/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json b/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json index 4ce4f1c..c0625a0 100644 --- a/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json +++ b/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json @@ -1,6 +1,6 @@ { "theme": { - "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, + "aside": { "border-radius": "16px", "border-width": "3px", "border-style": "double" }, "border-radius": "0", "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, @@ -8,9 +8,9 @@ "fluentpsqldriver": "#336791", "documentation-intro-fill": "radial-gradient(circle at top, var(--color-fluentpsqldriver) 30%, #000 100%)", "documentation-intro-accent": "var(--color-fluentpsqldriver)", - "documentation-intro-eyebrow": "white", + "hero-eyebrow": "white", "documentation-intro-figure": "white", - "documentation-intro-title": "white", + "hero-title": "white", "logo-base": { "dark": "#fff", "light": "#000" }, "logo-shape": { "dark": "#000", "light": "#fff" }, "fill": { "dark": "#000", "light": "#fff" } diff --git a/Sources/FluentPostgresDriver/PostgresError+Database.swift b/Sources/FluentPostgresDriver/PostgresError+Database.swift index cd6865e..595df63 100644 --- a/Sources/FluentPostgresDriver/PostgresError+Database.swift +++ b/Sources/FluentPostgresDriver/PostgresError+Database.swift @@ -107,10 +107,5 @@ extension PSQLError { } } -#if compiler(<6) -extension PostgresError: DatabaseError {} -extension PSQLError: DatabaseError {} -#else extension PostgresError: @retroactive DatabaseError {} extension PSQLError: @retroactive DatabaseError {} -#endif diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift index a45ebc7..ff6b07c 100644 --- a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift +++ b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift @@ -5,37 +5,60 @@ import FluentSQL import Logging import PostgresKit import SQLKit +import Testing import XCTest -func XCTAssertThrowsErrorAsync( - _ expression: @autoclosure () async throws -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line, - _ callback: (any Error) -> Void = { _ in } -) async { +func withDbs(_ closure: @escaping @Sendable (_ dbs: Databases, _ db: any Database) async throws -> Void) async throws { + let databases = Databases(threadPool: .singleton, on: MultiThreadedEventLoopGroup.singleton) + + databases.use(.testPostgres(subconfig: "A"), as: .a) + databases.use(.testPostgres(subconfig: "B"), as: .b) + do { - _ = try await expression() - XCTAssertThrowsError({}(), message(), file: file, line: line, callback) + let a = databases.database(.a, logger: .init(label: "test.fluent.a"), on: databases.eventLoopGroup.any())! + _ = try await (a as! any SQLDatabase).raw("drop schema if exists public cascade").run() + _ = try await (a as! any SQLDatabase).raw("create schema public").run() + + let b = databases.database(.b, logger: .init(label: "test.fluent.b"), on: databases.eventLoopGroup.any())! + _ = try await (b as! any SQLDatabase).raw("drop schema if exists public cascade").run() + _ = try await (b as! any SQLDatabase).raw("create schema public").run() + + try await closure(databases, a) + await databases.shutdownAsync() } catch { - XCTAssertThrowsError(try { throw error }(), message(), file: file, line: line, callback) + print(String(reflecting: error)) + await databases.shutdownAsync() + throw error } } -func XCTAssertNoThrowAsync( - _ expression: @autoclosure () async throws -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line -) async { - do { - _ = try await expression() - } catch { - XCTAssertNoThrow(try { throw error }(), message(), file: file, line: line) +final class FluentBenchmarksTests: XCTestCase { + var benchmarker: FluentBenchmarker { .init(databases: self.dbs) } + var dbs: Databases! + + override func setUp() async throws { + try await super.setUp() + + XCTAssert(isLoggingConfigured) + self.dbs = Databases(threadPool: .singleton, on: MultiThreadedEventLoopGroup.singleton) + + self.dbs.use(.testPostgres(subconfig: "A"), as: .a) + self.dbs.use(.testPostgres(subconfig: "B"), as: .b) + + let a = self.dbs.database(.a, logger: .init(label: "test.fluent.a"), on: self.dbs.eventLoopGroup.any()) + _ = try await (a as! any PostgresDatabase).query("drop schema public cascade").get() + _ = try await (a as! any PostgresDatabase).query("create schema public").get() + + let b = self.dbs.database(.b, logger: .init(label: "test.fluent.b"), on: self.dbs.eventLoopGroup.any()) + _ = try await (b as! any PostgresDatabase).query("drop schema public cascade").get() + _ = try await (b as! any PostgresDatabase).query("create schema public").get() + } + + override func tearDown() async throws { + await self.dbs.shutdownAsync() + try await super.tearDown() } -} -final class FluentPostgresDriverTests: XCTestCase { func testAggregate() throws { try self.benchmarker.testAggregate() } func testArray() throws { try self.benchmarker.testArray() } func testBatch() throws { try self.benchmarker.testBatch() } @@ -68,23 +91,35 @@ final class FluentPostgresDriverTests: XCTestCase { func testTimestamp() throws { try self.benchmarker.testTimestamp() } func testTransaction() throws { try self.benchmarker.testTransaction() } func testUnique() throws { try self.benchmarker.testUnique() } +} - func testDatabaseError() async throws { - let sql1 = (self.db as! any SQLDatabase) - await XCTAssertThrowsErrorAsync(try await sql1.raw("asdf").run()) { - XCTAssertTrue(($0 as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: $0))") - XCTAssertFalse(($0 as? any DatabaseError)?.isConstraintFailure ?? true, "\(String(reflecting: $0))") - XCTAssertFalse(($0 as? any DatabaseError)?.isConnectionClosed ?? true, "\(String(reflecting: $0))") - } +@Suite(.serialized) +struct AllSuites {} - let sql2 = (self.dbs.database(.a, logger: .init(label: "test.fluent.a"), on: self.eventLoopGroup.any())!) as! any SQLDatabase - try await sql2.drop(table: "foo").ifExists().run() - try await sql2.create(table: "foo").column("name", type: .text, .unique).run() - try await sql2.insert(into: "foo").columns("name").values("bar").run() - await XCTAssertThrowsErrorAsync(try await sql2.insert(into: "foo").columns("name").values("bar").run()) { - XCTAssertTrue(($0 as? any DatabaseError)?.isConstraintFailure ?? false, "\(String(reflecting: $0))") - XCTAssertFalse(($0 as? any DatabaseError)?.isSyntaxError ?? true, "\(String(reflecting: $0))") - XCTAssertFalse(($0 as? any DatabaseError)?.isConnectionClosed ?? true, "\(String(reflecting: $0))") +extension AllSuites { +@Suite +struct FluentPostgresDriverTests { + init() { + #expect(isLoggingConfigured) + } + + @Test + func databaseError() async throws { + try await withDbs { dbs, db in + let sql1 = (db as! any SQLDatabase) + let error = await #expect(throws: (any Error).self) { try await sql1.raw("asdf").run() } + #expect((error as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: error))") + #expect(!((error as? any DatabaseError)?.isConstraintFailure ?? true), "\(String(reflecting: error))") + #expect(!((error as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error))") + + let sql2 = (dbs.database(.a, logger: .init(label: "test.fluent.a"), on: dbs.eventLoopGroup.any())!) as! any SQLDatabase + try await sql2.drop(table: "foo").ifExists().run() + try await sql2.create(table: "foo").column("name", type: .text, .unique).run() + try await sql2.insert(into: "foo").columns("name").values("bar").run() + await #expect(throws: (any Error).self) { try await sql2.insert(into: "foo").columns("name").values("bar").run() } + #expect((error as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: error))") + #expect(!((error as? any DatabaseError)?.isConstraintFailure ?? true), "\(String(reflecting: error))") + #expect(!((error as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error))") } // Disabled until we figure out why it hangs instead of throwing an error. @@ -100,7 +135,8 @@ final class FluentPostgresDriverTests: XCTestCase { //} } - func testBlob() async throws { + @Test + func blob() async throws { struct CreateFoo: AsyncMigration { func prepare(on database: any Database) async throws { try await database.schema("foos") @@ -114,11 +150,14 @@ final class FluentPostgresDriverTests: XCTestCase { } } - try await CreateFoo().prepare(on: self.db) - try await CreateFoo().revert(on: self.db) + try await withDbs { _, db in + try await CreateFoo().prepare(on: db) + try await CreateFoo().revert(on: db) + } } - func testSaveModelWithBool() async throws { + @Test + func saveModelWithBool() async throws { final class Organization: Model, @unchecked Sendable { static let schema = "orgs" @@ -141,84 +180,93 @@ final class FluentPostgresDriverTests: XCTestCase { } } - try await CreateOrganization().prepare(on: self.db) - do { - let new = Organization() - new.disabled = false - try await new.save(on: self.db) - } catch { - try? await CreateOrganization().revert(on: self.db) - throw error + try await withDbs { _, db in + try await CreateOrganization().prepare(on: db) + do { + let new = Organization() + new.disabled = false + try await new.save(on: db) + } catch { + try? await CreateOrganization().revert(on: db) + throw error + } + try await CreateOrganization().revert(on: db) } - try await CreateOrganization().revert(on: self.db) } - func testCustomJSON() async throws { - let jsonEncoder = JSONEncoder() - jsonEncoder.dateEncodingStrategy = .iso8601 - let jsonDecoder = JSONDecoder() - jsonDecoder.dateDecodingStrategy = .iso8601 - - self.dbs.use( - .testPostgres( - subconfig: "A", - encodingContext: .init(jsonEncoder: jsonEncoder), - decodingContext: .init(jsonDecoder: jsonDecoder) - ), - as: .iso8601 - ) - let db = self.dbs.database( - .iso8601, - logger: .init(label: "test"), - on: self.eventLoopGroup.any() - )! - - try await EventMigration().prepare(on: db) - do { - let date = Date() - let event = Event() - event.id = 1 - event.metadata = Metadata(createdAt: date) - try await event.save(on: db) - - let rows = try await EventStringlyTyped.query(on: db).filter(\.$id == 1).all() - let expected = ISO8601DateFormatter().string(from: date) - XCTAssertEqual(rows[0].metadata["createdAt"], expected) - } catch { - try? await EventMigration().revert(on: db) - throw error + @Test + func customJSON() async throws { + try await withDbs { dbs, _ in + let jsonEncoder = JSONEncoder() + jsonEncoder.dateEncodingStrategy = .iso8601 + let jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .iso8601 + + dbs.use( + .testPostgres( + subconfig: "A", + encodingContext: .init(jsonEncoder: jsonEncoder), + decodingContext: .init(jsonDecoder: jsonDecoder) + ), + as: .iso8601 + ) + let db = dbs.database( + .iso8601, + logger: .init(label: "test"), + on: dbs.eventLoopGroup.any() + )! + + try await EventMigration().prepare(on: db) + do { + let date = Date() + let event = Event() + event.id = 1 + event.metadata = Metadata(createdAt: date) + try await event.save(on: db) + + let rows = try await EventStringlyTyped.query(on: db).filter(\.$id == 1).all() + let expected = ISO8601DateFormatter().string(from: date) + #expect(rows[0].metadata["createdAt"] == expected) + } catch { + try? await EventMigration().revert(on: db) + throw error + } + try await EventMigration().revert(on: db) } - try await EventMigration().revert(on: db) } - func testEnumAddingMultipleCases() async throws { - try await EnumMigration().prepare(on: self.db) - do { - try await EventWithFooMigration().prepare(on: self.db) + @Test + func enumAddingMultipleCases() async throws { + try await withDbs { _, db in + try await EnumMigration().prepare(on: db) do { - let event = EventWithFoo() - event.foobar = .foo - try await event.save(on: self.db) - - await XCTAssertNoThrowAsync(try await EnumAddMultipleCasesMigration().prepare(on: self.db)) - - event.foobar = .baz - await XCTAssertNoThrowAsync(try await event.update(on: self.db)) - event.foobar = .qux - await XCTAssertNoThrowAsync(try await event.update(on: self.db)) - - await XCTAssertNoThrowAsync(try await EnumAddMultipleCasesMigration().revert(on: self.db)) + try await EventWithFooMigration().prepare(on: db) + do { + let event = EventWithFoo() + event.foobar = .foo + try await event.save(on: db) + + await #expect(throws: Never.self) { try await EnumAddMultipleCasesMigration().prepare(on: db) } + + event.foobar = .baz + await #expect(throws: Never.self) { try await event.update(on: db) } + event.foobar = .qux + await #expect(throws: Never.self) { try await event.update(on: db) } + + await #expect(throws: Never.self) { try await EnumAddMultipleCasesMigration().revert(on: db) } + } catch { + try? await EventWithFooMigration().revert(on: db) + throw error + } } catch { - try? await EventWithFooMigration().revert(on: self.db) + try? await EnumMigration().revert(on: db) throw error } - } catch { - try? await EnumMigration().revert(on: self.db) - throw error } } - func testEncodingArrayOfModels() async throws { + @Test + func encodingArrayOfModels() async throws { final class Elem: Model, ExpressibleByIntegerLiteral, @unchecked Sendable { static let schema = "" @ID(custom: .id) var id: Int? @@ -233,65 +281,37 @@ final class FluentPostgresDriverTests: XCTestCase { init(nilLiteral: ()) { self.list = nil } init(arrayLiteral el: Elem...) { self.list = el } } - do { - try await self.db.schema(Seq.schema).field(.id, .int, .identifier(auto: true)).field("list", .sql(embed: "JSONB[]")).create() - - let s1: Seq = [1, 2] - let s2: Seq = nil - try [s1, s2].forEach { try $0.create(on: self.db).wait() } - - // Make sure it went into the DB as "array of jsonb" rather than as "array of one jsonb containing array" or such. - let raws = try await (self.db as! any SQLDatabase).raw("SELECT array_to_json(list)::text t FROM seqs").all().map { - try $0.decode(column: "t", as: String?.self) + try await withDbs { _, db in + do { + try await db.schema(Seq.schema).field(.id, .int, .identifier(auto: true)).field("list", .sql(embed: "JSONB[]")).create() + + let s1: Seq = [1, 2] + let s2: Seq = nil + try await s1.create(on: db) + try await s2.create(on: db) + + // Make sure it went into the DB as "array of jsonb" rather than as "array of one jsonb containing array" or such. + let raws = try await (db as! any SQLDatabase).raw("SELECT array_to_json(list)::text t FROM seqs").all().map { + try $0.decode(column: "t", as: String?.self) + } + #expect(raws == [#"[{"id": 1},{"id": 2}]"#, nil]) + + // Make sure it round-trips through Fluent. + let seqs = try await Seq.query(on: db).all() + + #expect(seqs.count == 2) + #expect(seqs.dropFirst(0).first?.id == s1.id) + #expect(seqs.dropFirst(0).first?.list?.map(\.id) == s1.list?.map(\.id)) + #expect(seqs.dropFirst(1).first?.id == s2.id) + #expect(seqs.dropFirst(1).first?.list?.map(\.id) == s2.list?.map(\.id)) + } catch let error { + Issue.record("caught error: \(String(reflecting: error))") } - XCTAssertEqual(raws, [#"[{"id": 1},{"id": 2}]"#, nil]) - - // Make sure it round-trips through Fluent. - let seqs = try await Seq.query(on: self.db).all() - - XCTAssertEqual(seqs.count, 2) - XCTAssertEqual(seqs.dropFirst(0).first?.id, s1.id) - XCTAssertEqual(seqs.dropFirst(0).first?.list?.map(\.id), s1.list?.map(\.id)) - XCTAssertEqual(seqs.dropFirst(1).first?.id, s2.id) - XCTAssertEqual(seqs.dropFirst(1).first?.list?.map(\.id), s2.list?.map(\.id)) - } catch let error { - XCTFail("caught error: \(String(reflecting: error))") + try await db.schema(Seq.schema).delete() } - try await db.schema(Seq.schema).delete() - } - - var benchmarker: FluentBenchmarker { .init(databases: self.dbs) } - var eventLoopGroup: any EventLoopGroup { MultiThreadedEventLoopGroup.singleton } - var threadPool: NIOThreadPool { NIOThreadPool.singleton } - var dbs: Databases! - var db: (any Database)! - var postgres: any PostgresDatabase { self.db as! any PostgresDatabase } - - override func setUp() async throws { - try await super.setUp() - - XCTAssert(isLoggingConfigured) - self.dbs = Databases(threadPool: self.threadPool, on: self.eventLoopGroup) - - self.dbs.use(.testPostgres(subconfig: "A"), as: .a) - self.dbs.use(.testPostgres(subconfig: "B"), as: .b) - - let a = self.dbs.database(.a, logger: .init(label: "test.fluent.a"), on: self.eventLoopGroup.any()) - _ = try await (a as! any PostgresDatabase).query("drop schema public cascade").get() - _ = try await (a as! any PostgresDatabase).query("create schema public").get() - - let b = self.dbs.database(.b, logger: .init(label: "test.fluent.b"), on: self.eventLoopGroup.any()) - _ = try await (b as! any PostgresDatabase).query("drop schema public cascade").get() - _ = try await (b as! any PostgresDatabase).query("create schema public").get() - - self.db = a - } - - override func tearDown() async throws { - await self.dbs.shutdownAsync() - try await super.tearDown() } } +} extension DatabaseConfigurationFactory { static func testPostgres( @@ -300,11 +320,11 @@ extension DatabaseConfigurationFactory { decodingContext: PostgresDecodingContext = .default ) -> Self { let baseSubconfig = SQLPostgresConfiguration( - hostname: env("POSTGRES_HOSTNAME_\(subconfig)") ?? "localhost", - port: env("POSTGRES_PORT_\(subconfig)").flatMap(Int.init) ?? SQLPostgresConfiguration.ianaPortNumber, - username: env("POSTGRES_USER_\(subconfig)") ?? "test_username", - password: env("POSTGRES_PASSWORD_\(subconfig)") ?? "test_password", - database: env("POSTGRES_DB_\(subconfig)") ?? "test_database", + hostname: ProcessInfo.processInfo.environment["POSTGRES_HOSTNAME_\(subconfig)"] ?? "localhost", + port: ProcessInfo.processInfo.environment["POSTGRES_PORT_\(subconfig)"].flatMap(Int.init) ?? SQLPostgresConfiguration.ianaPortNumber, + username: ProcessInfo.processInfo.environment["POSTGRES_USER_\(subconfig)"] ?? "test_username", + password: ProcessInfo.processInfo.environment["POSTGRES_PASSWORD_\(subconfig)"] ?? "test_password", + database: ProcessInfo.processInfo.environment["POSTGRES_DB_\(subconfig)"] ?? "test_database", tls: try! .prefer(.init(configuration: .makeClientConfiguration())) ) @@ -423,15 +443,37 @@ struct EnumAddMultipleCasesMigration: AsyncMigration { } } -func env(_ name: String) -> String? { - ProcessInfo.processInfo.environment[name] -} - let isLoggingConfigured: Bool = { - LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? .info - return handler - } + LoggingSystem.bootstrap { QuickLogHandler(label: $0, level: ProcessInfo.processInfo.environment["LOG_LEVEL"].flatMap { .init(rawValue: $0) } ?? .info) } return true }() + +struct QuickLogHandler: LogHandler { + private let label: String + var logLevel = Logger.Level.info, metadataProvider = LoggingSystem.metadataProvider, metadata = Logger.Metadata() + subscript(metadataKey key: String) -> Logger.Metadata.Value? { get { self.metadata[key] } set { self.metadata[key] = newValue } } + init(label: String, level: Logger.Level) { (self.label, self.logLevel) = (label, level) } + func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt) { + print("\(self.timestamp()) \(level) \(self.label) :\(self.prettify(metadata ?? [:]).map { " \($0)" } ?? "") [\(source)] \(message)") + } + private func prettify(_ metadata: Logger.Metadata) -> String? { + self.metadata.merging(self.metadataProvider?.get() ?? [:]) { $1 }.merging(metadata) { $1 }.sorted { $0.0 < $1.0 }.map { "\($0)=\($1.mvDesc)" }.joined(separator: " ") + } + private func timestamp() -> String { .init(unsafeUninitializedCapacity: 255) { buffer in + var timestamp = time(nil) + return localtime(×tamp).map { strftime(buffer.baseAddress!, buffer.count, "%Y-%m-%dT%H:%M:%S%z", $0) } ?? buffer.initialize(fromContentsOf: "".utf8) + } } +} +extension Logger.MetadataValue { + var mvDesc: String { switch self { + case .dictionary(let dict): "[\(dict.mapValues(\.mvDesc).lazy.sorted { $0.0 < $1.0 }.map { "\($0): \($1)" }.joined(separator: ", "))]" + case .array(let list): "[\(list.map(\.mvDesc).joined(separator: ", "))]" + case .string(let str): #""\#(str)""# + case .stringConvertible(let repr): switch repr { + case let repr as Bool: "\(repr)" + case let repr as any FixedWidthInteger: "\(repr)" + case let repr as any BinaryFloatingPoint: "\(repr)" + default: #""\#(String(describing: repr))""# + } + } } +} diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift index 4bdc52f..fac35b6 100644 --- a/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift +++ b/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift @@ -1,92 +1,43 @@ -import FluentBenchmark import FluentKit -import FluentPostgresDriver -import Logging -import PostgresKit -import XCTest - -final class FluentPostgresTransactionControlTests: XCTestCase { - func testRollback() async throws { - do { - try await self.db.withConnection { db -> EventLoopFuture in - (db as! any TransactionControlDatabase).beginTransaction().flatMap { () -> EventLoopFuture in - let todo1 = Todo(title: "Test") - return todo1.save(on: db) - }.flatMap { () -> EventLoopFuture in - let duplicate = Todo(title: "Test") - return duplicate.create(on: db) - .flatMap { - (db as! any TransactionControlDatabase).commitTransaction() - }.flatMapError { (e: Error) -> EventLoopFuture in - return (db as! any TransactionControlDatabase).rollbackTransaction() - .flatMap { db.eventLoop.makeFailedFuture(e) } - } +import Testing + +extension AllSuites { +@Suite +struct FluentPostgresTransactionControlTests { + init() { #expect(isLoggingConfigured) } + + @Test + func rollback() async throws { + try await withDbs { _, db in try await db.withConnection { db in + try await CreateTodo().prepare(on: db) + do { + try await (db as! any TransactionControlDatabase).beginTransaction().get() + let error = await #expect(throws: (any Error).self) { + try await [Todo(title: "Test"), Todo(title: "Test")].create(on: db) + try await (db as! any TransactionControlDatabase).commitTransaction().get() } - }.get() - XCTFail("Expected error but none was thrown") - } catch let error where String(reflecting: error).contains("sqlState: 23505") { - // ignore - } catch { - XCTFail("Expected SQL state 23505 but got \(String(reflecting: error))") - } - - let count2 = try await Todo.query(on: self.db).count() - XCTAssertEqual(count2, 0) - } - - var eventLoopGroup: any EventLoopGroup { MultiThreadedEventLoopGroup.singleton } - var threadPool: NIOThreadPool { NIOThreadPool.singleton } - var dbs: Databases! - var db: (any Database)! - - override func setUp() async throws { - try await super.setUp() - - XCTAssert(isLoggingConfigured) - self.dbs = Databases(threadPool: self.threadPool, on: self.eventLoopGroup) - - self.dbs.use(.testPostgres(subconfig: "A"), as: .a) - - self.db = self.dbs.database(.a, logger: Logger(label: "test.fluent.a"), on: self.eventLoopGroup.any()) - _ = try await (self.db as! any PostgresDatabase).query("drop schema public cascade").get() - _ = try await (self.db as! any PostgresDatabase).query("create schema public").get() - - try await CreateTodo().prepare(on: self.db) - } - - override func tearDown() async throws { - try await CreateTodo().revert(on: self.db) - await self.dbs.shutdownAsync() - try await super.tearDown() + #expect(String(reflecting: error).contains("sqlState: 23505"), "\(String(reflecting: error))") + try await (db as! any TransactionControlDatabase).rollbackTransaction().get() + #expect(try await Todo.query(on: db).count() == 0) + } catch { + try? await CreateTodo().revert(on: db) + throw error + } + try await CreateTodo().revert(on: db) + } } } final class Todo: Model, @unchecked Sendable { static let schema = "todos" - - @ID - var id: UUID? - - @Field(key: "title") - var title: String - + @ID var id + @Field(key: "title") var title: String init() {} - init(title: String) { - self.title = title - id = nil - } + init(title: String) { self.title = title } } struct CreateTodo: AsyncMigration { - func prepare(on database: any Database) async throws { - try await database.schema("todos") - .id() - .field("title", .string, .required) - .unique(on: "title") - .create() - } - - func revert(on database: any Database) async throws { - try await database.schema("todos").delete() - } + func prepare(on database: any Database) async throws { try await database.schema(Todo.schema).id().field("title", .string, .required).unique(on: "title").create() } + func revert(on database: any Database) async throws { try await database.schema(Todo.schema).delete() } } } +} From 39b36329bb639063528cb8aecba0ea73798d7f00 Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Fri, 24 Oct 2025 03:12:21 -0500 Subject: [PATCH 2/5] For some reason only 6.0 complains about non-public imports --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 93320b0..b211c69 100644 --- a/Package.swift +++ b/Package.swift @@ -41,7 +41,7 @@ let package = Package( var swiftSettings: [SwiftSetting] { [ .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault"), + //.enableUpcomingFeature("InternalImportsByDefault"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("InferIsolatedConformances"), //.enableUpcomingFeature("NonisolatedNonsendingByDefault"), From 22e581c06c5b5c0316d5650e68463de155705434 Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Mon, 27 Oct 2025 12:21:00 -0500 Subject: [PATCH 3/5] Fix tests in 6.0 --- .../FluentPostgresDriverTests.swift | 20 ++++++++++--------- ...luentPostgresTransactionControlTests.swift | 4 +++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift index ff6b07c..0af223c 100644 --- a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift +++ b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift @@ -102,24 +102,25 @@ struct FluentPostgresDriverTests { init() { #expect(isLoggingConfigured) } - + + #if !compiler(<6.1) // #expect(throws:) doesn't return the Error until 6.1 @Test func databaseError() async throws { try await withDbs { dbs, db in let sql1 = (db as! any SQLDatabase) - let error = await #expect(throws: (any Error).self) { try await sql1.raw("asdf").run() } - #expect((error as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: error))") - #expect(!((error as? any DatabaseError)?.isConstraintFailure ?? true), "\(String(reflecting: error))") - #expect(!((error as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error))") + let error1 = await #expect(throws: (any Error).self) { try await sql1.raw("asdf").run() } + #expect((error1 as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: error1))") + #expect(!((error1 as? any DatabaseError)?.isConstraintFailure ?? true), "\(String(reflecting: error1))") + #expect(!((error1 as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error1))") let sql2 = (dbs.database(.a, logger: .init(label: "test.fluent.a"), on: dbs.eventLoopGroup.any())!) as! any SQLDatabase try await sql2.drop(table: "foo").ifExists().run() try await sql2.create(table: "foo").column("name", type: .text, .unique).run() try await sql2.insert(into: "foo").columns("name").values("bar").run() - await #expect(throws: (any Error).self) { try await sql2.insert(into: "foo").columns("name").values("bar").run() } - #expect((error as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: error))") - #expect(!((error as? any DatabaseError)?.isConstraintFailure ?? true), "\(String(reflecting: error))") - #expect(!((error as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error))") + let error2 = await #expect(throws: (any Error).self) { try await sql2.insert(into: "foo").columns("name").values("bar").run() } + #expect((error2 as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: error2))") + #expect(!((error2 as? any DatabaseError)?.isConstraintFailure ?? true), "\(String(reflecting: error2))") + #expect(!((error2 as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error2))") } // Disabled until we figure out why it hangs instead of throwing an error. @@ -134,6 +135,7 @@ struct FluentPostgresDriverTests { // XCTAssertFalse(($0 as? any DatabaseError)?.isConstraintFailure ?? true, "\(String(reflecting: $0))") //} } + #endif @Test func blob() async throws { diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift index fac35b6..50d2e35 100644 --- a/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift +++ b/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift @@ -6,6 +6,7 @@ extension AllSuites { struct FluentPostgresTransactionControlTests { init() { #expect(isLoggingConfigured) } + #if !compiler(<6.1) // #expect(throws:) doesn't return the Error until 6.1 @Test func rollback() async throws { try await withDbs { _, db in try await db.withConnection { db in @@ -26,7 +27,8 @@ struct FluentPostgresTransactionControlTests { try await CreateTodo().revert(on: db) } } } - + #endif + final class Todo: Model, @unchecked Sendable { static let schema = "todos" @ID var id From 3ebf94dfa26bb2fffff012f54cbb170d9093770b Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Mon, 27 Oct 2025 12:27:12 -0500 Subject: [PATCH 4/5] Enable CI to specify environment more sparsely --- .../FluentPostgresDriverTests.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift index 0af223c..5117b3b 100644 --- a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift +++ b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift @@ -322,11 +322,11 @@ extension DatabaseConfigurationFactory { decodingContext: PostgresDecodingContext = .default ) -> Self { let baseSubconfig = SQLPostgresConfiguration( - hostname: ProcessInfo.processInfo.environment["POSTGRES_HOSTNAME_\(subconfig)"] ?? "localhost", - port: ProcessInfo.processInfo.environment["POSTGRES_PORT_\(subconfig)"].flatMap(Int.init) ?? SQLPostgresConfiguration.ianaPortNumber, - username: ProcessInfo.processInfo.environment["POSTGRES_USER_\(subconfig)"] ?? "test_username", - password: ProcessInfo.processInfo.environment["POSTGRES_PASSWORD_\(subconfig)"] ?? "test_password", - database: ProcessInfo.processInfo.environment["POSTGRES_DB_\(subconfig)"] ?? "test_database", + hostname: env("POSTGRES_HOSTNAME_\(subconfig)") ?? env("POSTGRES_HOSTNAME_A") ?? env("POSTGRES_HOSTNAME") ?? "localhost", + port: (env("POSTGRES_PORT_\(subconfig)") ?? env("POSTGRES_PORT_A") ?? env("POSTGRES_PORT")).flatMap(Int.init) ?? SQLPostgresConfiguration.ianaPortNumber, + username: env("POSTGRES_USER_\(subconfig)") ?? env("POSTGRES_USER_A") ?? env("POSTGRES_USER") ?? "test_username", + password: env("POSTGRES_PASSWORD_\(subconfig)") ?? env("POSTGRES_PASSWORD_A") ?? env("POSTGRES_PASSWORD") ?? "test_password", + database: env("POSTGRES_DB_\(subconfig)") ?? env("POSTGRES_DB_A") ?? env("POSTGRES_DB") ?? "test_database", tls: try! .prefer(.init(configuration: .makeClientConfiguration())) ) @@ -445,8 +445,12 @@ struct EnumAddMultipleCasesMigration: AsyncMigration { } } +func env(_ e: String) -> String? { + ProcessInfo.processInfo.environment[e] +} + let isLoggingConfigured: Bool = { - LoggingSystem.bootstrap { QuickLogHandler(label: $0, level: ProcessInfo.processInfo.environment["LOG_LEVEL"].flatMap { .init(rawValue: $0) } ?? .info) } + LoggingSystem.bootstrap { QuickLogHandler(label: $0, level: env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? .info) } return true }() From 3958961ec73413877f00147f9b40659ec8365fab Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Mon, 27 Oct 2025 12:33:53 -0500 Subject: [PATCH 5/5] Fix test --- .../FluentPostgresDriverTests/FluentPostgresDriverTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift index 5117b3b..97ee68d 100644 --- a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift +++ b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift @@ -118,8 +118,8 @@ struct FluentPostgresDriverTests { try await sql2.create(table: "foo").column("name", type: .text, .unique).run() try await sql2.insert(into: "foo").columns("name").values("bar").run() let error2 = await #expect(throws: (any Error).self) { try await sql2.insert(into: "foo").columns("name").values("bar").run() } - #expect((error2 as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: error2))") - #expect(!((error2 as? any DatabaseError)?.isConstraintFailure ?? true), "\(String(reflecting: error2))") + #expect(!((error2 as? any DatabaseError)?.isSyntaxError ?? true), "\(String(reflecting: error2))") + #expect((error2 as? any DatabaseError)?.isConstraintFailure ?? false, "\(String(reflecting: error2))") #expect(!((error2 as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error2))") }