diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc184d7..05a15ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,9 +46,9 @@ jobs: - postgres:16 - postgres:14 swift-image: - - swift:6.0-noble - swift:6.1-noble - swift:6.2-noble + - swift:6.3-noble include: - postgres-image: postgres:18 postgres-auth: scram-sha-256 @@ -75,14 +75,14 @@ jobs: - name: Run local tests run: swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable - name: Upload coverage data - uses: vapor/swift-codecov-action@v0.3 + uses: vapor/swift-codecov-action@2f478f2fd22e06ca363967d0fb6c6593a8548cee # v0.3.5 with: codecov_token: ${{ secrets.CODECOV_TOKEN || '' }} linux-integration: if: ${{ !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest - container: swift:6.2-noble + container: swift:6.3-noble services: *postgres_host_a: image: postgres:18 @@ -128,7 +128,7 @@ jobs: POSTGRES_DB: postgres steps: - name: Select latest available Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: ${{ matrix.xcode-version }} - name: Install Postgres, setup DB and auth, and wait for server start @@ -144,18 +144,26 @@ jobs: - name: Run local tests run: swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable - name: Upload coverage data - uses: vapor/swift-codecov-action@v0.3 + uses: vapor/swift-codecov-action@2f478f2fd22e06ca363967d0fb6c6593a8548cee # v0.3.5 with: codecov_token: ${{ secrets.CODECOV_TOKEN || '' }} musl: runs-on: ubuntu-latest - container: swift:6.2-noble + container: swift:6.3-noble timeout-minutes: 30 steps: - name: Check out code uses: actions/checkout@v7 + - name: Install linux dependencies + run: apt-get update && apt-get install -y curl jq - name: Install SDK - 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 + run: | + version_num="$(swiftc --version | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)" + tag="swift-${version_num}-RELEASE" + sdk_info="$(curl -s https://www.swift.org/api/v1/install/releases.json | jq -r --arg tag "$tag" '.[] | select(.tag == $tag).platforms[] | select(.platform == "static-sdk") | [.version, .checksum] | @tsv')" + version="$(echo "$sdk_info" | cut -f1)" + checksum="$(echo "$sdk_info" | cut -f2)" + swift sdk install "https://download.swift.org/$(echo $tag | tr [A-Z] [a-z])/static-sdk/$tag/${tag}_static-linux-${version}.artifactbundle.tar.gz" --checksum "$checksum" - name: Build run: swift build --swift-sdk x86_64-swift-linux-musl diff --git a/Package.swift b/Package.swift index 4eb6987..5731a85 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.1 import PackageDescription let package = Package( @@ -13,8 +13,8 @@ let package = Package( .library(name: "PostgresKit", targets: ["PostgresKit"]), ], dependencies: [ - .package(url: "https://github.com/vapor/postgres-nio.git", from: "1.27.0"), - .package(url: "https://github.com/vapor/sql-kit.git", from: "3.33.2"), + .package(url: "https://github.com/vapor/postgres-nio.git", from: "1.30.1"), + .package(url: "https://github.com/vapor/sql-kit.git", from: "3.34.0"), .package(url: "https://github.com/vapor/async-kit.git", from: "1.21.0"), ], targets: [ diff --git a/README.md b/README.md index 70796a9..be6d3bd 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ -

+

PostgresKit
-
-Documentation -Team Chat -MIT License -Continuous Integration -Code Coverage -Swift 6.0+ -

+ +[![Documentation](https://design.vapor.codes/images/readthedocs.svg)](https://docs.vapor.codes/4.0/) +[![Team Chat](https://design.vapor.codes/images/discordchat.svg)](https://discord.gg/vapor) +[![MIT License](https://design.vapor.codes/images/mitlicense.svg)](./LICENSE) +[![Continuous Integration](https://img.shields.io/github/actions/workflow/status/vapor/postgres-kit/test.yml?event=push&style=plastic&logo=github&label=tests&logoColor=ccc)](https://github.com/vapor/postgres-kit/actions/workflows/test.yml) +[![Code Coverage](https://img.shields.io/codecov/c/github/vapor/postgres-kit?style=plastic&logo=codecov&label=codecov)](https://codecov.io/github/vapor/postgres-kit) +[![Swift 6.1+](https://design.vapor.codes/images/swift61up.svg)](https://swift.org) + +

diff --git a/Sources/PostgresKit/PostgresDataTranslation.swift b/Sources/PostgresKit/PostgresDataTranslation.swift index 66339f4..8d866f0 100644 --- a/Sources/PostgresKit/PostgresDataTranslation.swift +++ b/Sources/PostgresKit/PostgresDataTranslation.swift @@ -1,18 +1,10 @@ import Foundation import PostgresNIO - -/// Quick and dirty `CodingKey`, borrowed from FluentKit. If `CodingKeyRepresentable` wasn't broken by design -/// (specifically, it can't be back-deployed before macOS 12.3 etc., even though it was introduced in Swift 5.6), -/// we'd use that instead. -private struct SomeCodingKey: CodingKey, Hashable { - let stringValue: String, intValue: Int? - init(stringValue: String) { (self.stringValue, self.intValue) = (stringValue, Int(stringValue)) } - init(intValue: Int) { (self.stringValue, self.intValue) = ("\(intValue)", intValue) } -} +import SQLKit extension PostgresCell { fileprivate var codingKey: any CodingKey { - PostgresKit.SomeCodingKey(stringValue: !self.columnName.isEmpty ? "\(self.columnName) (\(self.columnIndex))" : "\(self.columnIndex)") + SomeCodingKey(stringValue: !self.columnName.isEmpty ? "\(self.columnName) (\(self.columnIndex))" : "\(self.columnIndex)") } } @@ -196,8 +188,12 @@ struct PostgresDataTranslation { file: String = #fileID, line: Int = #line ) throws { - /// Preferred modern fast-path: Direct conformance to `PostgresEncodable` - if let fastPathValue = value as? any PostgresEncodable { + /// Nil bypass-path: Skip the entire machinery for nil optionals. + if (value as Optional) == nil { + bindings.appendNull() + } + /// Preferred modern fast-path: Direct conformance to the `PostgresEncodable` family. + else if let fastPathValue = value as? any PostgresThrowingDynamicTypeEncodable { try bindings.append(fastPathValue, context: context) } /// Legacy "fast"-path: Direct conformance to `PostgresDataConvertible`; use is deprecated. @@ -240,8 +236,8 @@ struct PostgresDataTranslation { case .invalid: throw ArrayAwareBoxWrappingPostgresEncoder.FallbackSentinel() 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 \(T.self)/\(type(of: value)) was encoded as a heterogenous array; this is unsupported.") + let elementType = (ref.contents.first)??.type ?? .jsonb + assert(ref.contents.allSatisfy { $0.map { $0.type == elementType } ?? true }, "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 { @@ -343,7 +339,7 @@ private final class ArrayAwareBoxWrappingPostgresEncoder final class ArrayRef { var contents: [T] = [] } case invalid - case indexed(ArrayRef) + case indexed(ArrayRef) case scalar(PostgresData) var isValid: Bool { if case .invalid = self { return false }; return true } @@ -366,7 +362,7 @@ private final class ArrayAwareBoxWrappingPostgresEncoder else { preconditionFailure("Internal error in encoder (requested indexed count from non-indexed state)") } } - mutating func store(indexedScalar: PostgresData) { + mutating func store(indexedScalar: PostgresData?) { if case .indexed(let ref) = self { ref.contents.append(indexedScalar) } else { preconditionFailure("Internal error in encoder (attempted store to indexed in non-indexed state)") } } @@ -406,7 +402,7 @@ private final class ArrayAwareBoxWrappingPostgresEncoder let encoder: ArrayAwareBoxWrappingPostgresEncoder var codingPath: [any CodingKey] { self.encoder.codingPath } var count: Int { self.encoder.value.indexedCount } - mutating func encodeNil() throws { self.encoder.value.store(indexedScalar: .null) } + mutating func encodeNil() throws { self.encoder.value.store(indexedScalar: nil) } mutating func encode(_ value: T) throws { self.encoder.value.store(indexedScalar: try PostgresDataTranslation.encode( codingPath: self.codingPath + [PostgresKit.SomeCodingKey(intValue: self.count)], userInfo: self.encoder.userInfo, diff --git a/Tests/PostgresKitTests/PostgresKitTests.swift b/Tests/PostgresKitTests/PostgresKitTests.swift index e5ea327..13ae668 100644 --- a/Tests/PostgresKitTests/PostgresKitTests.swift +++ b/Tests/PostgresKitTests/PostgresKitTests.swift @@ -226,13 +226,10 @@ 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. + /// 1. The Swift type (i.e. `Foo`) is mentioned in the error's debug description. /// 2. The underlying error is included. - #if swift(>=6.1) @Test func errorHandlingWhenDecodingNestedDictionary() throws { struct Foo: Codable { @@ -250,7 +247,47 @@ struct PostgresKitTests { 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 + + @Test + func encodingArraysContainingNilValues() async throws { + let encoded1 = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: [-1, nil, nil, nil] as [Int?], in: .default, file: #fileID, line: #line) + #expect(encoded1.type == .int8Array) + #expect(encoded1.array?.count == 4) + #expect(encoded1.array?.dropFirst(0).first?.type == .int8) + #expect(encoded1.array?.dropFirst(0).first?.int == -1) + #expect(encoded1.array?.dropFirst(1).first?.type == .int8) + #expect(encoded1.array?.dropFirst(1).first?.value == nil) + #expect(encoded1.array?.dropFirst(2).first?.type == .int8) + #expect(encoded1.array?.dropFirst(2).first?.value == nil) + #expect(encoded1.array?.dropFirst(3).first?.type == .int8) + #expect(encoded1.array?.dropFirst(3).first?.value == nil) + let encoded2 = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: [nil, nil, nil, nil] as [Int?], in: .default, file: #fileID, line: #line) + #expect(encoded2.type == .int8Array) + #expect(encoded2.array?.count == 4) + #expect(encoded2.array?.dropFirst(0).first?.type == .int8) + #expect(encoded2.array?.dropFirst(0).first?.value == nil) + #expect(encoded2.array?.dropFirst(1).first?.type == .int8) + #expect(encoded2.array?.dropFirst(1).first?.value == nil) + #expect(encoded2.array?.dropFirst(2).first?.type == .int8) + #expect(encoded2.array?.dropFirst(2).first?.value == nil) + #expect(encoded2.array?.dropFirst(3).first?.type == .int8) + #expect(encoded2.array?.dropFirst(3).first?.value == nil) + + let connection = try await PostgresConnection.test(on: self.eventLoop) + + await #expect(throws: Never.self) { + let sql = connection.sql() + _ = try await sql.raw("DROP TABLE IF EXISTS \(ident: "foo")").run() + try await sql.withSession { db in + _ = try await db.create(table: "foo").column("bar", type: .custom(SQLRaw("bigint[]")), .notNull).run() + _ = try await db.insert(into: "foo").columns("bar").values(SQLBind([-1, nil, nil, nil] as [Int?])).values(SQLBind([nil, nil, nil, nil] as [Int?])).run() + let rows = try await db.select().column("bar").from("foo").all(decodingColumn: "bar", as: [Int?].self) + #expect(rows.dropFirst(0).first == [-1, nil, nil, nil]) + #expect(rows.dropFirst(1).first == [nil, nil, nil, nil]) + } + } + try await connection.close() + } var eventLoop: any EventLoop { MultiThreadedEventLoopGroup.singleton.any() diff --git a/Tests/PostgresKitTests/Utilities.swift b/Tests/PostgresKitTests/Utilities.swift index ec2ad58..bc38b9f 100644 --- a/Tests/PostgresKitTests/Utilities.swift +++ b/Tests/PostgresKitTests/Utilities.swift @@ -41,8 +41,8 @@ struct QuickLogHandler: LogHandler { 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)") + func log(event: LogEvent) { + print("\(self.timestamp()) \(event.level) \(self.label):\(self.prettify(event.metadata ?? [:]).map { " \($0)" } ?? "") [\(event.source)] \(event.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: " ")