diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3baf612..168abe2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -156,6 +156,6 @@ jobs: - name: Check out code uses: actions/checkout@v6 - 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 + run: swift sdk install https://download.swift.org/swift-6.2.3-release/static-sdk/swift-6.2.3-RELEASE/swift-6.2.3-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum f30ec724d824ef43b5546e02ca06a8682dafab4b26a99fbb0e858c347e507a2c - name: Build run: swift build --swift-sdk x86_64-swift-linux-musl diff --git a/README.md b/README.md index d53e3d4..b975e16 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ guard let configuration = SQLPostgresConfiguration(url: "postgres://...") else { To connect via unix-domain sockets, use ``SQLPostgresConfiguration/init(unixDomainSocketPath:username:password:database:)`` instead of ``SQLPostgresConfiguration/init(hostname:port:username:password:database:tls:)``. ```swift -let configuration = PostgresConfiguration( +let configuration = SQLPostgresConfiguration( unixDomainSocketPath: "/path/to/socket", username: "vapor_username", password: "vapor_password", @@ -69,7 +69,64 @@ let configuration = PostgresConfiguration( ) ``` -### Connection Pool +### Connection Pool (Modern PostgresNIO) + +You don't need a ``SQLPostgresConfiguration`` to create a `PostgresClient`, an instance of PostgresNIO's modern connection pool. Instead, use `PostgresClient`'s native configuration type: + +```swift +let configuration = PostgresClient.Configuration( + host: "localhost", + username: "vapor_username", + password: "vapor_password", + database: "vapor_database", + tls: .prefer(.makeClientConfiguration()) +) +let psqlClient = PostgresClient(configuration: configuration) + +// Start a Task to run the client: +let clientTask = Task { await client.run() } +// Or, if you're using ServiceLifecycle, add the client to a ServiceGroup: +await serviceGroup.addServiceUnlessShutdown(client) +``` + +You can then lease a `PostgresConnection` from the client: + +```swift +try await client.withConnection { conn in + print(conn) // PostgresConnection managed by PostgresClient's connection pool +} +``` + +> [!NOTE] +> `PostgresClient.Configuration` does not support URL-based configuration. If you want to handle URLs, you can create an instance of `SQLPostgresConfiguration` and translate it into a `PostgresClient.Configuration`: +> +> ```swift +> extension PostgresClient.Configuration { +> init(from configuration: PostgresConnection.Configuration) { +> let tls: PostgresClient.Configuration.TLS = switch (configuration.tls.isEnforced, configuration.tls.isAllowed) { +> case (true, _): .require(configuration.tls.sslContext!.configuration) +> case (_, true): .prefer(configuration.tls.sslContext!.configuration) +> default: .disable +> } +> +> if let host = configuration.host, let port = configuration.port { +> self.init(host: host, port: port, username: configuration.username, password: configuration.password, database: configuration.database, tls: tls) +> } else if let socket = configuration.unixSocketPath { +> self.init(unixSocketPath: socket, username: configuration.username, password: configuration.password, database: configuration.database) +> } else { +> fatalError("Preconfigured channels not supported") +> } +> } +> } +> +> guard let sqlConfiguration = SQLPostgresConfiguration(url: "...") else { ... } +> let clientConfiguration = PostgresClient.Configuration(configuration: sqlConfiguration.coreConfiguration) +> ``` + +### Connection Pool (Legacy AsyncKit) + +> [!WARNING] +> AsyncKit is deprecated; using it is strongly discouraged. You should not use this setup unless you are also working with FluentKit, which at the time of this writing is not compatible with `PostgresClient`. Once you have a ``SQLPostgresConfiguration``, you can use it to create a connection source and pool. @@ -91,7 +148,7 @@ Next, use the connection source to create an `EventLoopGroupConnectionPool`. You `EventLoopGroupConnectionPool` is a collection of pools for each event loop. When using `EventLoopGroupConnectionPool` directly, random event loops will be chosen as needed. ```swift -pools.withConnection { conn +pools.withConnection { conn in print(conn) // PostgresConnection on randomly chosen event loop } ``` @@ -102,7 +159,7 @@ To get a pool for a specific event loop, use `pool(for:)`. This returns an `Even let eventLoop: EventLoop = ... let pool = pools.pool(for: eventLoop) -pool.withConnection { conn +pool.withConnection { conn in print(conn) // PostgresConnection on eventLoop } ``` @@ -113,7 +170,7 @@ Both `EventLoopGroupConnectionPool` and `EventLoopConnectionPool` can be used to ```swift let postgres = pool.database(logger: ...) // PostgresDatabase -let rows = try postgres.simpleQuery("SELECT version();").wait() +let rows = try await postgres.simpleQuery("SELECT version()") ``` Visit [PostgresNIO's docs] for more information on using `PostgresDatabase`. @@ -124,7 +181,7 @@ A `PostgresDatabase` can be used to create an instance of `SQLDatabase`. ```swift let sql = postgres.sql() // SQLDatabase -let planets = try sql.select().column("*").from("planets").all().wait() +let planets = try await sql.select().column("*").from("planets").all() ``` Visit [SQLKit's docs] for more information on using `SQLDatabase`. diff --git a/Sources/PostgresKit/Docs.docc/PostgresKit.md b/Sources/PostgresKit/Docs.docc/PostgresKit.md index 47bef7f..0795d78 100644 --- a/Sources/PostgresKit/Docs.docc/PostgresKit.md +++ b/Sources/PostgresKit/Docs.docc/PostgresKit.md @@ -53,7 +53,7 @@ guard let configuration = SQLPostgresConfiguration(url: "postgres://...") else { To connect via unix-domain sockets, use ``SQLPostgresConfiguration/init(unixDomainSocketPath:username:password:database:)`` instead of ``SQLPostgresConfiguration/init(hostname:port:username:password:database:tls:)``. ```swift -let configuration = PostgresConfiguration( +let configuration = SQLPostgresConfiguration( unixDomainSocketPath: "/path/to/socket", username: "vapor_username", password: "vapor_password", @@ -61,7 +61,62 @@ let configuration = PostgresConfiguration( ) ``` -### Connection Pool +### Connection Pool (Modern PostgresNIO) + +You don't need a ``SQLPostgresConfiguration`` to create a `PostgresClient`, an instance of PostgresNIO's modern connection pool. Instead, use `PostgresClient`'s native configuration type: + +```swift +let configuration = PostgresClient.Configuration( + host: "localhost", + username: "vapor_username", + password: "vapor_password", + database: "vapor_database", + tls: .prefer(.makeClientConfiguration()) +) +let psqlClient = PostgresClient(configuration: configuration) + +// Start a Task to run the client; be sure you cancel this task before exiting: +let clientTask = Task { await psqlClient.run() } +// Or, if you're using ServiceLifecycle, add the client to a ServiceGroup: +await serviceGroup.addServiceUnlessShutdown(psqlClient) +``` + +You can then lease a `PostgresConnection` from the client: + +```swift +try await client.withConnection { conn in + print(conn) // PostgresConnection managed by PostgresClient's connection pool +} +``` + +> Note: `PostgresClient.Configuration` does not support URL-based configuration. If you want to handle URLs, you can create an instance of `SQLPostgresConfiguration` and translate it into a `PostgresClient.Configuration`: +> +> ```swift +> extension PostgresClient.Configuration { +> init(from configuration: PostgresConnection.Configuration) { +> let tls: PostgresClient.Configuration.TLS = switch (configuration.tls.isEnforced, configuration.tls.isAllowed) { +> case (true, _): .require(configuration.tls.sslContext!.configuration) +> case (_, true): .prefer(configuration.tls.sslContext!.configuration) +> default: .disable +> } +> +> if let host = configuration.host, let port = configuration.port { +> self.init(host: host, port: port, username: configuration.username, password: configuration.password, database: configuration.database, tls: tls) +> } else if let socket = configuration.unixSocketPath { +> self.init(unixSocketPath: socket, username: configuration.username, password: configuration.password, database: configuration.database) +> } else { +> fatalError("Preconfigured channels not supported") +> } +> } +> } +> +> guard let sqlConfiguration = SQLPostgresConfiguration(url: "...") else { ... } +> let clientConfiguration = PostgresClient.Configuration(configuration: sqlConfiguration.coreConfiguration) +> ``` + +### Connection Pool (Legacy AsyncKit) + +> Warning: AsyncKit is deprecated; using it is strongly discouraged. You should not use this setup unless you are also working with FluentKit, which at the time of this writing is not compatible with `PostgresClient`. Once you have a ``SQLPostgresConfiguration``, you can use it to create a connection source and pool. @@ -83,7 +138,7 @@ Next, use the connection source to create an `EventLoopGroupConnectionPool`. You `EventLoopGroupConnectionPool` is a collection of pools for each event loop. When using `EventLoopGroupConnectionPool` directly, random event loops will be chosen as needed. ```swift -pools.withConnection { conn +pools.withConnection { conn in print(conn) // PostgresConnection on randomly chosen event loop } ``` @@ -94,7 +149,7 @@ To get a pool for a specific event loop, use `pool(for:)`. This returns an `Even let eventLoop: EventLoop = ... let pool = pools.pool(for: eventLoop) -pool.withConnection { conn +pool.withConnection { conn in print(conn) // PostgresConnection on eventLoop } ``` @@ -105,7 +160,7 @@ Both `EventLoopGroupConnectionPool` and `EventLoopConnectionPool` can be used to ```swift let postgres = pool.database(logger: ...) // PostgresDatabase -let rows = try postgres.simpleQuery("SELECT version();").wait() +let rows = try await postgres.simpleQuery("SELECT version()") ``` Visit [PostgresNIO's docs] for more information on using `PostgresDatabase`. @@ -116,7 +171,7 @@ A `PostgresDatabase` can be used to create an instance of `SQLDatabase`. ```swift let sql = postgres.sql() // SQLDatabase -let planets = try sql.select().column("*").from("planets").all().wait() +let planets = try await sql.select().column("*").from("planets").all() ``` Visit [SQLKit's docs] for more information on using `SQLDatabase`. diff --git a/Sources/PostgresKit/PostgresDataTranslation.swift b/Sources/PostgresKit/PostgresDataTranslation.swift index 6fb9346..66339f4 100644 --- a/Sources/PostgresKit/PostgresDataTranslation.swift +++ b/Sources/PostgresKit/PostgresDataTranslation.swift @@ -156,16 +156,17 @@ struct PostgresDataTranslation { context: context, file: file, line: line )) - } catch DecodingError.dataCorrupted { + } catch DecodingError.dataCorrupted(let errContext) { /// Glacial path: Attempt to decode as plain JSON. guard cell.dataType == .json || cell.dataType == .jsonb else { throw DecodingError.dataCorrupted(.init( codingPath: codingPath, - debugDescription: "Unable to interpret value of PSQL type \(cell.dataType): \(cell.bytes.map { "\($0)" } ?? "null")" + debugDescription: "Unable to interpret value of PSQL type \(cell.dataType) as Swift type \(T.self): \(cell.bytes.map { "\($0)" } ?? "null")", + underlyingError: DecodingError.dataCorrupted(errContext) )) } if cell.dataType == .jsonb, cell.format == .binary, let buffer = cell.bytes { - // TODO: Un-hardcode this magic knowledge of the JSONB encoding + // Account for the leading JSONB version byte return try context.jsonDecoder.decode(T.self, from: buffer.getSlice(at: buffer.readerIndex + 1, length: buffer.readableBytes - 1) ?? .init()) } else { return try context.jsonDecoder.decode(T.self, from: cell.bytes ?? .init()) @@ -202,7 +203,7 @@ struct PostgresDataTranslation { /// Legacy "fast"-path: Direct conformance to `PostgresDataConvertible`; use is deprecated. else if let legacyPathValue = value as? any PostgresDataTranslation.PostgresLegacyDataConvertible { guard let legacyData = legacyPathValue.postgresData else { - throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Couldn't get PSQL encoding from value '\(value)'")) + throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Couldn't get PSQL encoding from value '\(value)' of Swift type \(T.self)/\(type(of: value))")) } bindings.append(legacyData) } @@ -227,7 +228,7 @@ struct PostgresDataTranslation { return PostgresData(type: type(of: fastPathValue).psqlType, typeModifier: nil, formatCode: type(of: fastPathValue).psqlFormat, value: buffer) } else if let legacyPathValue = value as? any PostgresDataTranslation.PostgresLegacyDataConvertible { guard let legacyData = legacyPathValue.postgresData else { - throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Couldn't get PSQL encoding from value '\(value)'")) + throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Couldn't get PSQL encoding from value '\(value)' of Swift type \(T.self)/\(type(of: value))")) } return legacyData } @@ -240,7 +241,7 @@ struct PostgresDataTranslation { case .scalar(let scalar): return scalar case .indexed(let ref): let elementType = ref.contents.first?.type ?? .jsonb - assert(ref.contents.allSatisfy { $0.type == elementType }, "Type \(type(of: value)) was encoded as a heterogenous array; this is unsupported.") + assert(ref.contents.allSatisfy { $0.type == elementType }, "Type \(T.self)/\(type(of: value)) was encoded as a heterogenous array; this is unsupported.") return PostgresData(array: ref.contents, elementType: elementType) } } catch is ArrayAwareBoxWrappingPostgresEncoder.FallbackSentinel { diff --git a/Tests/PostgresKitTests/PostgresKitTests.swift b/Tests/PostgresKitTests/PostgresKitTests.swift index 1b16b9f..e5ea327 100644 --- a/Tests/PostgresKitTests/PostgresKitTests.swift +++ b/Tests/PostgresKitTests/PostgresKitTests.swift @@ -226,6 +226,32 @@ struct PostgresKitTests { #expect(try PostgresDataTranslation.decode(URL.self, from: .init(with: encodedBroken), in: .default) == url) } + /// This test is painful to write before Swift 6.1 due to #expect(throws:) not returning the thrown error. + /// + /// This test cares that: + /// + /// 1. The Swift type (i.e. `Foo`) is metnioned in the error's debug description. + /// 2. The underlying error is included. + #if swift(>=6.1) + @Test + func errorHandlingWhenDecodingNestedDictionary() throws { + struct Foo: Codable { + struct Bar: Codable { let id: Int } + let bar: Bar + } + + let error = try #require(throws: DecodingError.self) { + _ = try PostgresDataTranslation.decode(Foo.self, from: .init(bytes: .init(integer: 0), dataType: .int8, format: .binary, columnName: "", columnIndex: 0), in: .default) + } + + let context = try #require({ if case .dataCorrupted(let context) = error { context } else { nil } }()) + #expect(context.debugDescription == "Unable to interpret value of PSQL type BIGINT as Swift type Foo: [0000000000000000](8 bytes)") + + let underContext = try #require({ if case .dataCorrupted(let context2) = context.underlyingError as? DecodingError { context2 } else { nil } }()) + #expect(underContext.debugDescription == "Dictionary containers must be JSON-encoded") + } + #endif + var eventLoop: any EventLoop { MultiThreadedEventLoopGroup.singleton.any() }