diff --git a/Sources/FluentPostgresDriver/FluentPostgresClientConfiguration.swift b/Sources/FluentPostgresDriver/FluentPostgresClientConfiguration.swift index ce622a8..d4de8cc 100644 --- a/Sources/FluentPostgresDriver/FluentPostgresClientConfiguration.swift +++ b/Sources/FluentPostgresDriver/FluentPostgresClientConfiguration.swift @@ -28,7 +28,41 @@ extension DatabaseConfigurationFactory { ) -> Self { .init { FluentPostgresClientConfiguration( - configuration: configuration, + client: .configuration(configuration), + encodingContext: encodingContext, + decodingContext: decodingContext, + sqlLogLevel: sqlLogLevel, + logger: logger + ) + } + } + + /// Create a PostgreSQL database configuration from a PostgresClient. + /// + /// This is different to the other configuration factories as it uses PostgresNIO's + /// modern PostgresClient connection pool under the hood instead of the AsyncKit pool. + /// The client's lifecycle is expected to be managed by the caller. + /// + /// > Warning: The database that's returned using this configuration is not castable + /// > to a `PostgresDatabase`, and `TransactionControlDatabase` is to be used inside + /// > `withConnection`. + /// + /// - Parameters: + /// - client: A ``PostgresClient``. + /// - encodingContext: Encoding context to use for serializing data. + /// - decodingContext: Decoding context to use for deserializing data. + /// - sqlLogLevel: Level at which to log SQL queries. + /// - logger: Logger to use in the client. + public static func postgres( + client: PostgresClient, + encodingContext: PostgresEncodingContext = .default, + decodingContext: PostgresDecodingContext = .default, + sqlLogLevel: Logger.Level = .debug, + logger: Logger + ) -> Self { + .init { + FluentPostgresClientConfiguration( + client: .client(client), encodingContext: encodingContext, decodingContext: decodingContext, sqlLogLevel: sqlLogLevel, @@ -39,9 +73,15 @@ extension DatabaseConfigurationFactory { } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -struct FluentPostgresClientConfiguration: DatabaseConfiguration { +private struct FluentPostgresClientConfiguration: DatabaseConfiguration { + enum Client { + case client(PostgresClient) + case configuration(PostgresClient.Configuration) + } + var middleware: [any AnyModelMiddleware] = [] - fileprivate let configuration: PostgresClient.Configuration + + let client: Client let encodingContext: PostgresEncodingContext let decodingContext: PostgresDecodingContext @@ -49,13 +89,25 @@ struct FluentPostgresClientConfiguration any DatabaseDriver { - _FluentPostgresClientDriver( - client: PostgresClient(configuration: self.configuration, eventLoopGroup: databases.eventLoopGroup, backgroundLogger: logger), - eventLoopGroup: databases.eventLoopGroup, - encodingContext: self.encodingContext, - decodingContext: self.decodingContext, - sqlLogLevel: sqlLogLevel, - logger: logger - ) + switch client { + case .client(let client): + _FluentPostgresClientDriver( + client: client, + encodingContext: self.encodingContext, + decodingContext: self.decodingContext, + sqlLogLevel: sqlLogLevel, + logger: logger + ) + + case .configuration(let config): + _FluentPostgresClientDriver( + configuration: config, + eventLoopGroup: databases.eventLoopGroup, + encodingContext: self.encodingContext, + decodingContext: self.decodingContext, + sqlLogLevel: sqlLogLevel, + logger: logger + ) + } } } diff --git a/Sources/FluentPostgresDriver/FluentPostgresClientDriver.swift b/Sources/FluentPostgresDriver/FluentPostgresClientDriver.swift index 1a742b4..6395509 100644 --- a/Sources/FluentPostgresDriver/FluentPostgresClientDriver.swift +++ b/Sources/FluentPostgresDriver/FluentPostgresClientDriver.swift @@ -5,11 +5,15 @@ import ServiceLifecycle @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) final class _FluentPostgresClientDriver: DatabaseDriver { + enum Lifecycle { + /// The driver created the client and runs it in a private ServiceGroup. + case owned(group: ServiceGroup, runFinished: EventLoopFuture) + /// The user runs the client elsewhere; the driver only borrows it. + case external + } + let client: PostgresClient - - let group: ServiceGroup - // To track service group shutdown - let runFinished: EventLoopFuture + let lifecycle: Lifecycle let encodingContext: PostgresEncodingContext let decodingContext: PostgresDecodingContext @@ -17,19 +21,34 @@ final class _FluentPostgresClientDriver, + decodingContext: PostgresDecodingContext, + sqlLogLevel: Logger.Level, + logger: Logger + ) { + self.client = client + self.lifecycle = .external + self.encodingContext = encodingContext + self.decodingContext = decodingContext + self.sqlLogLevel = sqlLogLevel + } + + init( + configuration: PostgresClient.Configuration, eventLoopGroup: some EventLoopGroup, encodingContext: PostgresEncodingContext, decodingContext: PostgresDecodingContext, sqlLogLevel: Logger.Level, logger: Logger ) { + let client = PostgresClient(configuration: configuration) var config = ServiceGroupConfiguration(services: [client], logger: logger) config.maximumGracefulShutdownDuration = .seconds(10) let group = ServiceGroup(configuration: config) let finished = eventLoopGroup.any().makePromise(of: Void.self) - self.group = group - self.runFinished = finished.futureResult + self.lifecycle = .owned(group: group, runFinished: finished.futureResult) + self.client = client self.encodingContext = encodingContext self.decodingContext = decodingContext @@ -58,12 +77,14 @@ final class _FluentPostgresClientDriver Void) async throws { + for driver in TestDriver.allCases { + try await withDbs(driver) { dbs, _ in try body(FluentBenchmarker(databases: dbs)) } + } } - func testAggregate() throws { try self.benchmarker.testAggregate() } - func testArray() throws { try self.benchmarker.testArray() } - func testBatch() throws { try self.benchmarker.testBatch() } - func testChild() throws { try self.benchmarker.testChildren() } - func testChildren() throws { try self.benchmarker.testChildren() } - func testChunk() throws { try self.benchmarker.testChunk() } - func testCodable() throws { try self.benchmarker.testCodable() } - func testCompositeID() throws { try self.benchmarker.testCompositeID() } - func testCRUD() throws { try self.benchmarker.testCRUD() } - func testEagerLoad() throws { try self.benchmarker.testEagerLoad() } - func testEnum() throws { try self.benchmarker.testEnum() } - func testFilter() throws { try self.benchmarker.testFilter() } - func testGroup() throws { try self.benchmarker.testGroup() } - func testID() throws { try self.benchmarker.testID() } - func testJoin() throws { try self.benchmarker.testJoin() } - func testMiddleware() throws { try self.benchmarker.testMiddleware() } - func testMigrator() throws { try self.benchmarker.testMigrator() } - func testModel() throws { try self.benchmarker.testModel() } - func testOptionalParent() throws { try self.benchmarker.testOptionalParent() } - func testPagination() throws { try self.benchmarker.testPagination() } - func testParent() throws { try self.benchmarker.testParent() } - func testPerformance() throws { try self.benchmarker.testPerformance() } - func testRange() throws { try self.benchmarker.testRange() } - func testSchema() throws { try self.benchmarker.testSchema() } - func testSet() throws { try self.benchmarker.testSet() } - func testSiblings() throws { try self.benchmarker.testSiblings() } - func testSoftDelete() throws { try self.benchmarker.testSoftDelete() } - func testSort() throws { try self.benchmarker.testSort() } - func testSQL() throws { try self.benchmarker.testSQL() } - func testTimestamp() throws { try self.benchmarker.testTimestamp() } - func testTransaction() throws { try self.benchmarker.testTransaction() } - func testUnique() throws { try self.benchmarker.testUnique() } + func testAggregate() async throws { try await self.forEachDriver { try $0.testAggregate() } } + func testArray() async throws { try await self.forEachDriver { try $0.testArray() } } + func testBatch() async throws { try await self.forEachDriver { try $0.testBatch() } } + func testChild() async throws { try await self.forEachDriver { try $0.testChildren() } } + func testChildren() async throws { try await self.forEachDriver { try $0.testChildren() } } + func testChunk() async throws { try await self.forEachDriver { try $0.testChunk() } } + func testCodable() async throws { try await self.forEachDriver { try $0.testCodable() } } + func testCompositeID() async throws { try await self.forEachDriver { try $0.testCompositeID() } } + func testCRUD() async throws { try await self.forEachDriver { try $0.testCRUD() } } + func testEagerLoad() async throws { try await self.forEachDriver { try $0.testEagerLoad() } } + func testEnum() async throws { try await self.forEachDriver { try $0.testEnum() } } + func testFilter() async throws { try await self.forEachDriver { try $0.testFilter() } } + func testGroup() async throws { try await self.forEachDriver { try $0.testGroup() } } + func testID() async throws { try await self.forEachDriver { try $0.testID() } } + func testJoin() async throws { try await self.forEachDriver { try $0.testJoin() } } + func testMiddleware() async throws { try await self.forEachDriver { try $0.testMiddleware() } } + func testMigrator() async throws { try await self.forEachDriver { try $0.testMigrator() } } + func testModel() async throws { try await self.forEachDriver { try $0.testModel() } } + func testOptionalParent() async throws { try await self.forEachDriver { try $0.testOptionalParent() } } + func testPagination() async throws { try await self.forEachDriver { try $0.testPagination() } } + func testParent() async throws { try await self.forEachDriver { try $0.testParent() } } + func testPerformance() async throws { try await self.forEachDriver { try $0.testPerformance() } } + func testRange() async throws { try await self.forEachDriver { try $0.testRange() } } + func testSchema() async throws { try await self.forEachDriver { try $0.testSchema() } } + func testSet() async throws { try await self.forEachDriver { try $0.testSet() } } + func testSiblings() async throws { try await self.forEachDriver { try $0.testSiblings() } } + func testSoftDelete() async throws { try await self.forEachDriver { try $0.testSoftDelete() } } + func testSort() async throws { try await self.forEachDriver { try $0.testSort() } } + func testSQL() async throws { try await self.forEachDriver { try $0.testSQL() } } + func testTimestamp() async throws { try await self.forEachDriver { try $0.testTimestamp() } } + func testTransaction() async throws { try await self.forEachDriver { try $0.testTransaction() } } + func testUnique() async throws { try await self.forEachDriver { try $0.testUnique() } } } diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresClientDriverTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresClientDriverTests.swift new file mode 100644 index 0000000..1bd1c8e --- /dev/null +++ b/Tests/FluentPostgresDriverTests/FluentPostgresClientDriverTests.swift @@ -0,0 +1,70 @@ +import Testing +import FluentPostgresDriver +import InMemoryLogging +import ServiceLifecycle + +extension AllSuites { + +@Suite +struct FluentPostgresClientDriverTests { + @Test + func externalClientSurvivesDatabasesShutdown() async throws { + let client = PostgresClient(configuration: .test(subconfig: "A"), eventLoopGroup: MultiThreadedEventLoopGroup.singleton, backgroundLogger: .init(label: "x")) + let group = ServiceGroup(services: [client], logger: .init(label: "x")) + let run = Task { try await group.run() } + + let dbs = Databases(threadPool: .singleton, on: MultiThreadedEventLoopGroup.singleton) + dbs.use(.postgres(client: client, logger: .init(label: "x")), as: .a) + let db = dbs.database(.a, logger: .init(label: "x"), on: dbs.eventLoopGroup.any())! + try await (db as! any SQLDatabase).raw("SELECT 1").run() + await dbs.shutdownAsync() + + // Fluent is gone, the client is still ours + _ = try await client.query("SELECT 1").collect() + + await group.triggerGracefulShutdown() + _ = try? await run.value + } + + @Test(arguments: TestDriver.clientDrivers) + func transactionControlRequiresConnection(_ driver: TestDriver) async throws { + try await withDbs(driver) { _, db in + let error = await #expect(throws: FluentPostgresError.self) { + try await (db as! any TransactionControlDatabase).beginTransaction().get() + } + #expect(error == .transactionControlRequiresConnection) + } + } + + @Test(arguments: TestDriver.clientDrivers) + func nestedTransactionInsideWithConnectionIsNoOp(_ driver: TestDriver) async throws { + try await withDbs(driver) { dbs, _ in + let logs = InMemoryLogHandler.capturingAllLevels() + let db = dbs.database(.a, logger: Logger(label: "nested") { _ in logs }, on: dbs.eventLoopGroup.any())! + try await db.withConnection { conn in + try await conn.transaction { outer in + try await outer.transaction { inner in + try await (inner as! any SQLDatabase).raw("SELECT 1").run() + } + } + } + #expect(logs.loggedQueries.filter { $0 == "BEGIN" }.count == 1) + #expect(logs.loggedQueries.filter { $0 == "COMMIT" }.count == 1) + } + } + + @Test(arguments: TestDriver.clientDrivers) + func withSessionPinsOneConnection(_ driver: TestDriver) async throws { + try await withDbs(driver) { _, db in + let pids = try await (db as! any SQLDatabase).withSession { sql in + try await ( + sql.raw("SELECT pg_backend_pid() AS pid").first(decodingColumn: "pid", as: Int.self), + sql.raw("SELECT pg_backend_pid() AS pid").first(decodingColumn: "pid", as: Int.self) + ) + } + #expect(pids.0 == pids.1) + } + } +} + +} diff --git a/Tests/FluentPostgresDriverTests/Utilities.swift b/Tests/FluentPostgresDriverTests/Utilities.swift index 4c99a16..a68b6ad 100644 --- a/Tests/FluentPostgresDriverTests/Utilities.swift +++ b/Tests/FluentPostgresDriverTests/Utilities.swift @@ -1,4 +1,3 @@ -import FluentBenchmark import FluentKit import FluentPostgresDriver import FluentSQL @@ -6,31 +5,64 @@ import Logging import PostgresKit import SQLKit import PostgresNIO +import ServiceLifecycle enum TestDriver: Sendable, CaseIterable { case asyncKit case postgresClient + case externalPostgresClient static var allCases: [TestDriver] { if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { - [.asyncKit, .postgresClient] + [.asyncKit, .postgresClient, .externalPostgresClient] } else { [.asyncKit] } } + + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + static var clientDrivers: [TestDriver] { + [.postgresClient, .externalPostgresClient] + } } func withDbs(_ driver: TestDriver, _ closure: @escaping @Sendable (_ dbs: Databases, _ db: any Database) async throws -> Void) async throws { let databases = Databases(threadPool: .singleton, on: MultiThreadedEventLoopGroup.singleton) + var externalCleanup: @Sendable () async -> Void = {} + switch driver { - case .asyncKit: + case .asyncKit: databases.use(.testPostgres(subconfig: "A"), as: .a) databases.use(.testPostgres(subconfig: "B"), as: .b) case .postgresClient: databases.use(.testPostgres(clientSubconfig: "A", logger: .init(label: "test.fluent.a")), as: .a) databases.use(.testPostgres(clientSubconfig: "B", logger: .init(label: "test.fluent.b")), as: .b) - } + case .externalPostgresClient: + guard #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) else { + preconditionFailure("TestDriver.allCases must not include .externalPostgresClient on this platform") + } + let clientA = PostgresClient( + configuration: .test(subconfig: "A"), + eventLoopGroup: databases.eventLoopGroup, + backgroundLogger: .init(label: "test.postgres.client.a") + ) + let clientB = PostgresClient( + configuration: .test(subconfig: "B"), + eventLoopGroup: databases.eventLoopGroup, + backgroundLogger: .init(label: "test.postgres.client.b") + ) + + let serviceGroup = ServiceGroup(services: [clientA, clientB], logger: .init(label: "test.postgres.service-group")) + let runTask = Task { try await serviceGroup.run() } + externalCleanup = { + await serviceGroup.triggerGracefulShutdown() + _ = try? await runTask.value + } + + databases.use(.postgres(client: clientA, logger: .init(label: "test.fluent.a")), as: .a) + databases.use(.postgres(client: clientB, logger: .init(label: "test.fluent.b")), as: .b) + } do { let a = databases.database(.a, logger: .init(label: "test.fluent.a"), on: databases.eventLoopGroup.any())! @@ -43,9 +75,11 @@ func withDbs(_ driver: TestDriver, _ closure: @escaping @Sendable (_ dbs: Databa try await closure(databases, a) await databases.shutdownAsync() + await externalCleanup() } catch { print(String(reflecting: error)) await databases.shutdownAsync() + await externalCleanup() throw error } } @@ -99,3 +133,17 @@ extension DatabaseConfigurationFactory { ) } } + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +extension PostgresClient.Configuration { + static func test(subconfig: String) -> Self { + .init( + host: 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_\(subconfig.lowercased())", + tls: .prefer(.makeClientConfiguration()) + ) + } +}