diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..6ff9614b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +* @gwynne +/.github/CONTRIBUTING.md @gwynne @0xTim +/.github/workflows/*.yml @gwynne @0xTim +/.github/workflows/test.yml @gwynne +/.spi.yml @gwynne @0xTim +/.gitignore @gwynne @0xTim +/LICENSE @gwynne @0xTim +/README.md @gwynne @0xTim diff --git a/.github/contributing.md b/.github/contributing.md deleted file mode 100644 index 6f9ef50c..00000000 --- a/.github/contributing.md +++ /dev/null @@ -1,33 +0,0 @@ -# Contributing to PostgresKit - -👋 Welcome to the Vapor team! - -## Docker - -This package includes a `docker-compose` file you can use for spinning up test databases with test credentials. - -```sh -$ docker-compose up psql-11 -``` - -## Testing - -Once in Xcode, select the `postgres-kit` scheme and use `CMD+U` to run the tests. - -You can also test via the CLI using `swift test`. - -If you are fixing a single GitHub issue in particular, you can add a test named `testGH` to ensure -that your fix is working. This will also help prevent regression. - -## SemVer - -Vapor follows [SemVer](https://semver.org). This means that any changes to the source code that can cause -existing code to stop compiling _must_ wait until the next major version to be included. - -Code that is only additive and will not break any existing code can be included in the next minor release. - ----------- - -Join us on Discord if you have any questions: [discord.gg/vapor](https://discord.gg/vapor). - -— Thanks! 🙌 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..998a0ebe --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + groups: + dependencies: + patterns: + - "*" diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index d521498e..0fea43d3 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -1,18 +1,14 @@ name: deploy-api-docs on: - push: - branches: - - main + push: + branches: + - main jobs: - deploy: - name: api.vapor.codes - runs-on: ubuntu-latest - steps: - - name: Deploy api-docs - uses: appleboy/ssh-action@master - with: - host: vapor.codes - username: vapor - key: ${{ secrets.VAPOR_CODES_SSH_KEY }} - script: ./github-actions/deploy-api-docs.sh + build-and-deploy: + uses: vapor/api-docs/.github/workflows/build-and-deploy-docs-workflow.yml@main + secrets: inherit + with: + package_name: postgres-kit + modules: PostgresKit + pathsToInvalidate: /postgreskit/* diff --git a/.github/workflows/main-codecov.yml b/.github/workflows/main-codecov.yml deleted file mode 100644 index 15d97333..00000000 --- a/.github/workflows/main-codecov.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Update code coverage baselines -on: - push: { branches: [ main ] } -jobs: - update-main-codecov: - strategy: - matrix: { dbimage: ['postgres:14'], dbauth: ['scram-sha-256'] } - runs-on: ubuntu-latest - container: swift:5.6-focal - env: - LOG_LEVEL: debug - POSTGRES_HOSTNAME: 'psql' - POSTGRES_DB: 'test_database' - POSTGRES_USER: 'test_username' - POSTGRES_PASSWORD: 'test_password' - services: - psql: - image: ${{ matrix.dbimage }} - env: - POSTGRES_USER: 'test_username' - POSTGRES_DB: 'test_database' - POSTGRES_PASSWORD: 'test_password' - POSTGRES_HOST_AUTH_METHOD: ${{ matrix.dbauth }} - POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.dbauth }} - steps: - - name: Save Postgres version and method to env - run: echo POSTGRES_INFO='${{ toJSON(matrix) }}' >> $GITHUB_ENV - - name: Check out package - uses: actions/checkout@v3 - - name: Run local tests with coverage - run: swift test --enable-code-coverage - - name: Submit coverage report to Codecov.io - uses: vapor/swift-codecov-action@v0.2 - with: - cc_flags: 'unittests' - cc_env_vars: 'SWIFT_VERSION,SWIFT_PLATFORM,RUNNER_OS,RUNNER_ARCH,POSTGRES_INFO' - cc_fail_ci_if_error: true - cc_verbose: true - cc_dry_run: false - diff --git a/.github/workflows/projectboard.yml b/.github/workflows/projectboard.yml deleted file mode 100644 index 0e4e66f6..00000000 --- a/.github/workflows/projectboard.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: issue-to-project-board-workflow -on: - # Trigger when an issue gets labeled or deleted - issues: - types: [reopened, closed, labeled, unlabeled, assigned, unassigned] - -jobs: - setup_matrix_input: - runs-on: ubuntu-latest - - steps: - - id: set-matrix - run: | - output=$(curl ${{ github.event.issue.url }}/labels | jq '.[] | .name') || output="" - - echo '======================' - echo 'Process incoming data' - echo '======================' - json=$(echo $output | sed 's/"\s"/","/g') - echo $json - echo "::set-output name=matrix::$(echo $json)" - outputs: - issueTags: ${{ steps.set-matrix.outputs.matrix }} - - Manage_project_issues: - needs: setup_matrix_input - uses: vapor/ci/.github/workflows/issues-to-project-board.yml@main - with: - labelsJson: ${{ needs.setup_matrix_input.outputs.issueTags }} - secrets: - PROJECT_BOARD_AUTOMATION_PAT: "${{ secrets.PROJECT_BOARD_AUTOMATION_PAT }}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 65ba7f9f..3f0c3b31 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,14 @@ name: test -on: [ 'pull_request' ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +on: + pull_request: { types: [opened, reopened, synchronize, ready_for_review] } + push: { branches: [ main ] } + env: - LOG_LEVEL: debug + LOG_LEVEL: info + SWIFT_DETERMINISTIC_HASHING: 1 POSTGRES_HOSTNAME: 'psql-a' POSTGRES_HOSTNAME_A: 'psql-a' POSTGRES_HOSTNAME_B: 'psql-b' @@ -16,160 +23,137 @@ env: POSTGRES_PASSWORD_B: 'test_password' jobs: - # Baseline test run for code coverage stats - codecov: - strategy: - matrix: { dbimage: ['postgres:14'], dbauth: ['scram-sha-256'] } - runs-on: ubuntu-latest - container: swift:5.6-focal - services: - psql-a: - image: ${{ matrix.dbimage }} - env: - POSTGRES_USER: 'test_username' - POSTGRES_DB: 'test_database' - POSTGRES_PASSWORD: 'test_password' - POSTGRES_HOST_AUTH_METHOD: ${{ matrix.dbauth }} - POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.dbauth }} - steps: - - name: Save Postgres version and method to env - run: | - echo POSTGRES_VERSION='${{ matrix.dbimage }}' >> $GITHUB_ENV - echo POSTGRES_AUTH_METHOD='${{ matrix.dbauth }}' >> $GITHUB_ENV - - name: Check out package - uses: actions/checkout@v3 - - name: Run local tests with coverage - run: swift test --enable-code-coverage - - name: Submit coverage report to Codecov.io - uses: vapor/swift-codecov-action@v0.2 - with: - cc_flags: 'unittests' - cc_env_vars: 'SWIFT_VERSION,SWIFT_PLATFORM,RUNNER_OS,RUNNER_ARCH,POSTGRES_VERSION,POSTGRES_AUTH_METHOD' - cc_fail_ci_if_error: true - cc_verbose: true - cc_dry_run: false - - # Check for API breakage versus main api-breakage: + if: ${{ github.event_name == 'pull_request' && !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest - container: swift:5.6-focal + container: swift:noble steps: - - name: Check out package - uses: actions/checkout@v3 - with: - fetch-depth: 0 - # https://github.com/actions/checkout/issues/766 - - name: Mark the workspace as safe - run: git config --global --add safe.directory ${GITHUB_WORKSPACE} - - name: Check for API breaking changes - run: swift package diagnose-api-breaking-changes origin/main + - name: Checkout + uses: actions/checkout@v4 + with: { 'fetch-depth': 0 } + - name: API breaking changes + run: | + git config --global --add safe.directory "${GITHUB_WORKSPACE}" + swift package diagnose-api-breaking-changes origin/main - # Run Linux unit tests against various configurations linux-unit: + if: ${{ !(github.event.pull_request.draft || false) }} strategy: fail-fast: false matrix: - dbimage: ['postgres:14', 'postgres:13', 'postgres:11'] - swiftver: ['swift:5.4', 'swift:5.5', 'swift:5.6', 'swiftlang/swift:nightly-main'] - swiftos: ['focal'] - include: [ - {dbimage: 'postgres:14', dbauth: 'scram-sha-256'}, - {dbimage: 'postgres:13', dbauth: 'md5'}, - {dbimage: 'postgres:11', dbauth: 'trust'} - ] - container: ${{ format('{0}-{1}', matrix.swiftver, matrix.swiftos) }} + postgres-image: + - postgres:17 + - postgres:15 + - postgres:13 + swift-image: + - swift:5.9-jammy + - swift:5.10-noble + - swift:6.0-noble + include: + - postgres-image: postgres:17 + postgres-auth: scram-sha-256 + - postgres-image: postgres:15 + postgres-auth: md5 + - postgres-image: postgres:13 + postgres-auth: trust runs-on: ubuntu-latest + container: ${{ matrix.swift-image }} services: psql-a: - image: ${{ matrix.dbimage }} - env: - POSTGRES_USER: 'test_username' - POSTGRES_DB: 'test_database' - POSTGRES_PASSWORD: 'test_password' - POSTGRES_HOST_AUTH_METHOD: ${{ matrix.dbauth }} - POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.dbauth }} - psql-b: - image: ${{ matrix.dbimage }} + image: ${{ matrix.postgres-image }} env: - POSTGRES_USER: 'test_username' - POSTGRES_DB: 'test_database' - POSTGRES_PASSWORD: 'test_password' - POSTGRES_HOST_AUTH_METHOD: ${{ matrix.dbauth }} - POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.dbauth }} + POSTGRES_USER: test_username + POSTGRES_DB: test_database + POSTGRES_PASSWORD: test_password + POSTGRES_HOST_AUTH_METHOD: ${{ matrix.postgres-auth }} + POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.postgres-auth }} steps: - name: Check out package - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Run local tests - run: swift test + run: swift test --sanitize=thread --enable-code-coverage + - name: Upload coverage data + uses: vapor/swift-codecov-action@v0.3 + with: + codecov_token: ${{ secrets.CODECOV_TOKEN || '' }} - # Test integration with dependent package on Linux linux-integration: - strategy: - fail-fast: false - matrix: - dbimage: ['postgres:14'] - dbauth: ['scram-sha-256'] - swiftver: ['swift:5.4', 'swift:5.5', 'swift:5.6', 'swiftlang/swift:nightly-main'] - swiftos: ['focal'] - container: ${{ format('{0}-{1}', matrix.swiftver, matrix.swiftos) }} + if: ${{ !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest + container: swift:6.0-noble services: psql-a: - image: ${{ matrix.dbimage }} + image: postgres:17 env: - POSTGRES_USER: 'test_username' - POSTGRES_DB: 'test_database' - POSTGRES_PASSWORD: 'test_password' - POSTGRES_HOST_AUTH_METHOD: ${{ matrix.dbauth }} - POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.dbauth }} + POSTGRES_USER: test_username + POSTGRES_DB: test_database + POSTGRES_PASSWORD: test_password + POSTGRES_HOST_AUTH_METHOD: scram-sha-256 + POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 psql-b: - image: ${{ matrix.dbimage }} + image: postgres:15 env: - POSTGRES_USER: 'test_username' - POSTGRES_DB: 'test_database' - POSTGRES_PASSWORD: 'test_password' - POSTGRES_HOST_AUTH_METHOD: ${{ matrix.dbauth }} - POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.dbauth }} + POSTGRES_USER: test_username + POSTGRES_DB: test_database + POSTGRES_PASSWORD: test_password + POSTGRES_HOST_AUTH_METHOD: scram-sha-256 + POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 steps: - name: Check out package - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: { path: 'postgres-kit' } - name: Check out fluent-postgres-driver dependent - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: { repository: 'vapor/fluent-postgres-driver', path: 'fluent-postgres-driver' } - name: Use local package run: swift package --package-path fluent-postgres-driver edit postgres-kit --path postgres-kit - name: Run fluent-postgres-kit tests - run: swift test --package-path fluent-postgres-driver + run: swift test --package-path fluent-postgres-driver --sanitize=thread - # Run macOS unit tests against various configurations macos-unit: + if: ${{ !(github.event.pull_request.draft || false) }} strategy: fail-fast: false matrix: - dbimage: ['postgresql'] - dbauth: ['scram-sha-256'] - macos: ['macos-11', 'macos-12'] - xcode: ['latest-stable', 'latest'] - exclude: [{ macos: 'macos-11', xcode: 'latest' }] - runs-on: ${{ matrix.macos }} + include: + - macos-version: macos-14 + xcode-version: latest-stable + - macos-version: macos-15 + xcode-version: latest-stable + runs-on: ${{ matrix.macos-version }} env: POSTGRES_HOSTNAME: 127.0.0.1 POSTGRES_DB: postgres - POSTGRES_HOST_AUTH_METHOD: ${{ matrix.dbauth }} steps: - name: Select latest available Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: ${{ matrix.xcode }} + xcode-version: ${{ matrix.xcode-version }} - name: Install Postgres, setup DB and auth, and wait for server start run: | - export PATH="$(brew --prefix)/opt/${{ matrix.formula }}/bin:$PATH" PGDATA=/tmp/vapor-postgres-test - (brew unlink postgresql || true) && brew install ${{ matrix.dbimage }} && brew link --force ${{ matrix.dbimage }} - initdb --locale=C --auth-host ${{ matrix.dbauth }} -U $POSTGRES_USER --pwfile=<(echo $POSTGRES_PASSWORD) + brew upgrade || true + export PATH="$(brew --prefix)/opt/postgresql@13/bin:$PATH" PGDATA=/tmp/vapor-postgres-test + (brew unlink postgresql@14 || true) && brew install "postgresql@13" && brew link --force "postgresql@13" + initdb --locale=C --auth-host "scram-sha-256" -U "${POSTGRES_USER}" --pwfile=<(echo "${POSTGRES_PASSWORD}") pg_ctl start --wait - timeout-minutes: 2 + timeout-minutes: 15 - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Run local tests - run: swift test + run: swift test --sanitize=thread --enable-code-coverage + - name: Upload coverage data + uses: vapor/swift-codecov-action@v0.3 + with: + codecov_token: ${{ secrets.CODECOV_TOKEN || '' }} + + musl: + runs-on: ubuntu-latest + container: swift:6.0-noble + timeout-minutes: 30 + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Install SDK + run: swift sdk install https://download.swift.org/swift-6.0.3-release/static-sdk/swift-6.0.3-RELEASE/swift-6.0.3-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum 67f765e0030e661a7450f7e4877cfe008db4f57f177d5a08a6e26fd661cdd0bd + - name: Build + run: swift build --swift-sdk x86_64-swift-linux-musl diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 00000000..7c8587cf --- /dev/null +++ b/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +metadata: + authors: "Maintained by the Vapor Core Team with hundreds of contributions from the Vapor Community." +external_links: + documentation: "https://api.vapor.codes/postgreskit/documentation/postgreskit/" diff --git a/Package.swift b/Package.swift index 143c2fc5..b982c5cb 100644 --- a/Package.swift +++ b/Package.swift @@ -1,28 +1,47 @@ -// swift-tools-version:5.4 +// swift-tools-version:5.9 import PackageDescription let package = Package( name: "postgres-kit", platforms: [ - .macOS(.v10_15) + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), ], products: [ .library(name: "PostgresKit", targets: ["PostgresKit"]), ], dependencies: [ - .package(url: "https://github.com/vapor/postgres-nio.git", from: "1.11.0"), - .package(url: "https://github.com/vapor/sql-kit.git", from: "3.16.0"), - .package(url: "https://github.com/vapor/async-kit.git", from: "1.0.0"), + .package(url: "https://github.com/vapor/postgres-nio.git", from: "1.21.1"), + .package(url: "https://github.com/vapor/sql-kit.git", from: "3.29.3"), + .package(url: "https://github.com/vapor/async-kit.git", from: "1.19.0"), ], targets: [ - .target(name: "PostgresKit", dependencies: [ - .product(name: "AsyncKit", package: "async-kit"), - .product(name: "PostgresNIO", package: "postgres-nio"), - .product(name: "SQLKit", package: "sql-kit"), - ]), - .testTarget(name: "PostgresKitTests", dependencies: [ - .target(name: "PostgresKit"), - .product(name: "SQLKitBenchmark", package: "sql-kit"), - ]), + .target( + name: "PostgresKit", + dependencies: [ + .product(name: "AsyncKit", package: "async-kit"), + .product(name: "PostgresNIO", package: "postgres-nio"), + .product(name: "SQLKit", package: "sql-kit"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "PostgresKitTests", + dependencies: [ + .target(name: "PostgresKit"), + .product(name: "SQLKitBenchmark", package: "sql-kit"), + ], + swiftSettings: swiftSettings + ), ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableExperimentalFeature("StrictConcurrency=complete"), +] } diff --git a/README.md b/README.md index c103a680..46b29a48 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,49 @@ -PostgresKit +

+ + + + PostgresKit +
- - Documentation - - - Team Chat - - - MIT License - - - Continuous Integration - - - Swift 5.2 -
+Documentation +Team Chat +MIT License +Continuous Integration + +Swift 5.9+ +

+
🐘 Non-blocking, event-driven Swift client for PostgreSQL. -### Major Releases - -The table below shows a list of PostgresKit major releases alongside their compatible NIO and Swift versions. - -|Version|NIO|Swift|SPM| -|-|-|-|-| -|2.0|2.0|5.2+|`from: "2.0.0"`| -|1.0|1.0|4.0+|`from: "1.0.0"`| +### Usage Use the SPM string to easily include the dependendency in your `Package.swift` file. ```swift -.package(url: "https://github.com/vapor/postgres-kit.git", from: ...) +.package(url: "https://github.com/vapor/postgres-kit.git", from: "2.0.0") ``` ### Supported Platforms PostgresKit supports the following platforms: -- Ubuntu 16.04+ +- Ubuntu 20.04+ - macOS 10.15+ ## Overview -PostgresKit is a PostgreSQL client library built on [SQLKit](https://github.com/vapor/sql-kit). It supports building and serializing Postgres-dialect SQL queries. PostgresKit uses [PostgresNIO](https://github.com/vapor/postgres-nio) to connect and communicate with the database server asynchronously. [AsyncKit](https://github.com/vapor/async-kit) is used to provide connection pooling. +PostgresKit is an [SQLKit] driver for PostgreSQL clients. It supports building and serializing Postgres-dialect SQL queries. PostgresKit uses [PostgresNIO] to connect and communicate with the database server asynchronously. [AsyncKit](https://github.com/vapor/async-kit) is used to provide connection pooling. + +> [!IMPORTANT] +> It is strongly recommended that users who leverage PostgresKit directly (e.g. absent the Fluent ORM layer) take advantage of PostgresNIO's [PostgresClient] API for connection management rather than relying upon the legacy AsyncKit API. + +[SQLKit]: https://github.com/vapor/sql-kit +[PostgresNIO]: https://github.com/vapor/postgres-nio +[AsyncKit]: https://github.com/vapor/async-kit +[PostgresClient]: https://api.vapor.codes/postgresnio/documentation/postgresnio/postgresclient ### Configuration diff --git a/Sources/PostgresKit/ConnectionPool+Postgres.swift b/Sources/PostgresKit/ConnectionPool+Postgres.swift index 4832a789..080666fd 100644 --- a/Sources/PostgresKit/ConnectionPool+Postgres.swift +++ b/Sources/PostgresKit/ConnectionPool+Postgres.swift @@ -1,23 +1,30 @@ +@preconcurrency import AsyncKit +import Logging +import NIOCore +import PostgresNIO + extension EventLoopGroupConnectionPool where Source == PostgresConnectionSource { - public func database(logger: Logger) -> PostgresDatabase { + public func database(logger: Logger) -> any PostgresDatabase { _EventLoopGroupConnectionPoolPostgresDatabase(pool: self, logger: logger) } } -// MARK: Private +extension EventLoopConnectionPool where Source == PostgresConnectionSource { + public func database(logger: Logger) -> any PostgresDatabase { + _EventLoopConnectionPoolPostgresDatabase(pool: self, logger: logger) + } +} -private struct _EventLoopGroupConnectionPoolPostgresDatabase { +private struct _EventLoopGroupConnectionPoolPostgresDatabase: PostgresDatabase { let pool: EventLoopGroupConnectionPool let logger: Logger -} -extension _EventLoopGroupConnectionPoolPostgresDatabase: PostgresDatabase { - var eventLoop: EventLoop { self.pool.eventLoopGroup.next() } + var eventLoop: any EventLoop { + self.pool.eventLoopGroup.any() + } - func send(_ request: PostgresRequest, logger: Logger) -> EventLoopFuture { - self.pool.withConnection(logger: logger) { - $0.send(request, logger: logger) - } + func send(_ request: any PostgresRequest, logger: Logger) -> EventLoopFuture { + self.pool.withConnection(logger: logger) { $0.send(request, logger: logger) } } func withConnection(_ closure: @escaping (PostgresConnection) -> EventLoopFuture) -> EventLoopFuture { @@ -25,26 +32,18 @@ extension _EventLoopGroupConnectionPoolPostgresDatabase: PostgresDatabase { } } -extension EventLoopConnectionPool where Source == PostgresConnectionSource { - public func database(logger: Logger) -> PostgresDatabase { - _EventLoopConnectionPoolPostgresDatabase(pool: self, logger: logger) - } -} - -private struct _EventLoopConnectionPoolPostgresDatabase { +private struct _EventLoopConnectionPoolPostgresDatabase: PostgresDatabase { let pool: EventLoopConnectionPool let logger: Logger -} -extension _EventLoopConnectionPoolPostgresDatabase: PostgresDatabase { - var eventLoop: EventLoop { self.pool.eventLoop } - - func send(_ request: PostgresRequest, logger: Logger) -> EventLoopFuture { - self.pool.withConnection(logger: logger) { - $0.send(request, logger: logger) - } + var eventLoop: any EventLoop { + self.pool.eventLoop + } + + func send(_ request: any PostgresRequest, logger: Logger) -> EventLoopFuture { + self.pool.withConnection(logger: logger) { $0.send(request, logger: logger) } } - + func withConnection(_ closure: @escaping (PostgresConnection) -> EventLoopFuture) -> EventLoopFuture { self.pool.withConnection(logger: self.logger, closure) } diff --git a/Sources/PostgresKit/Deprecations/PostgresColumnType.swift b/Sources/PostgresKit/Deprecations/PostgresColumnType.swift new file mode 100644 index 00000000..19f6f67d --- /dev/null +++ b/Sources/PostgresKit/Deprecations/PostgresColumnType.swift @@ -0,0 +1,307 @@ +import SQLKit + +/// Postgres-specific column types. +@available(*, deprecated, message: "Use `PostgresDataType` instead.") +public struct PostgresColumnType: SQLExpression, Hashable { + public static var blob: PostgresColumnType { .varbit } + + /// signed eight-byte integer + public static var int8: PostgresColumnType { .bigint } + + /// signed eight-byte integer + public static var bigint: PostgresColumnType { .init(.bigint) } + + /// autoincrementing eight-byte integer + public static var serial8: PostgresColumnType { .bigserial } + + /// autoincrementing eight-byte integer + public static var bigserial: PostgresColumnType { .init(.bigserial) } + + /// fixed-length bit string + public static var bit: PostgresColumnType { .init(.bit(nil)) } + + /// fixed-length bit string + public static func bit(_ n: Int) -> PostgresColumnType { .init(.bit(n)) } + + /// variable-length bit string + public static var varbit: PostgresColumnType { .init(.varbit(nil)) } + + /// variable-length bit string + public static func varbit(_ n: Int) -> PostgresColumnType { .init(.varbit(n)) } + + /// logical Boolean (true/false) + public static var bool: PostgresColumnType { .boolean } + + /// logical Boolean (true/false) + public static var boolean: PostgresColumnType { .init(.boolean) } + + /// rectangular box on a plane + public static var box: PostgresColumnType { .init(.box) } + + /// binary data (“byte array”) + public static var bytea: PostgresColumnType { .init(.bytea) } + + /// fixed-length character string + public static var char: PostgresColumnType { .init(.char(nil)) } + + /// fixed-length character string + public static func char(_ n: Int) -> PostgresColumnType { .init(.char(n)) } + + /// variable-length character string + public static var varchar: PostgresColumnType { .init(.varchar(nil)) } + + /// variable-length character string + public static func varchar(_ n: Int) -> PostgresColumnType { .init(.varchar(n)) } + + /// IPv4 or IPv6 network address + public static var cidr: PostgresColumnType { .init(.cidr) } + + /// circle on a plane + public static var circle: PostgresColumnType { .init(.circle) } + + /// calendar date (year, month, day) + public static var date: PostgresColumnType { .init(.date) } + + /// floating-point number (8 bytes) + public static var float8: PostgresColumnType { .doublePrecision } + + /// floating-point number (8 bytes) + public static var doublePrecision: PostgresColumnType { .init(.doublePrecision) } + + /// IPv4 or IPv6 host address + public static var inet: PostgresColumnType { .init(.inet) } + + /// signed four-byte integer + public static var int: PostgresColumnType { .integer } + + /// signed four-byte integer + public static var int4: PostgresColumnType { .integer } + + /// signed four-byte integer + public static var integer: PostgresColumnType { .init(.integer) } + + /// time span + public static var interval: PostgresColumnType { .init(.interval) } + + /// textual JSON data + public static var json: PostgresColumnType { .init(.json) } + + /// binary JSON data, decomposed + public static var jsonb: PostgresColumnType { .init(.jsonb) } + + /// infinite line on a plane + public static var line: PostgresColumnType { .init(.line) } + + /// line segment on a plane + public static var lseg: PostgresColumnType { .init(.lseg) } + + /// MAC (Media Access Control) address + public static var macaddr: PostgresColumnType { .init(.macaddr) } + + /// MAC (Media Access Control) address (EUI-64 format) + public static var macaddr8: PostgresColumnType { .init(.macaddr8) } + + /// currency amount + public static var money: PostgresColumnType { .init(.money) } + + /// exact numeric of selectable precision + public static var decimal: PostgresColumnType { .init(.numeric(nil, nil)) } + + /// exact numeric of selectable precision + public static func decimal(_ p: Int, _ s: Int) -> PostgresColumnType { .init(.numeric(p, s)) } + + /// exact numeric of selectable precision + public static func numeric(_ p: Int, _ s: Int) -> PostgresColumnType { .init(.numeric(p, s)) } + + /// exact numeric of selectable precision + public static var numeric: PostgresColumnType { .init(.numeric(nil, nil)) } + + /// geometric path on a plane + public static var path: PostgresColumnType { .init(.path) } + + /// PostgreSQL Log Sequence Number + public static var pgLSN: PostgresColumnType { .init(.pgLSN) } + + /// geometric point on a plane + public static var point: PostgresColumnType { .init(.point) } + + /// closed geometric path on a plane + public static var polygon: PostgresColumnType { .init(.polygon) } + + /// single precision floating-point number (4 bytes) + public static var float4: PostgresColumnType { .real } + + /// single precision floating-point number (4 bytes) + public static var real: PostgresColumnType { .init(.real) } + + /// signed two-byte integer + public static var int2: PostgresColumnType { .smallint } + + /// signed two-byte integer + public static var smallint: PostgresColumnType { .init(.smallint) } + + /// autoincrementing two-byte integer + public static var serial2: PostgresColumnType { .smallserial } + + /// autoincrementing two-byte integer + public static var smallserial: PostgresColumnType { .init(.smallserial) } + + /// autoincrementing four-byte integer + public static var serial4: PostgresColumnType { .serial } + + /// autoincrementing four-byte integer + public static var serial: PostgresColumnType { .init(.serial) } + + /// variable-length character string + public static var text: PostgresColumnType { .init(.text) } + + /// time of day (no time zone) + public static var time: PostgresColumnType { .init(.time(nil)) } + + /// time of day (no time zone) + public static func time(_ n: Int) -> PostgresColumnType { .init(.time(n)) } + + /// time of day, including time zone + public static var timetz: PostgresColumnType { .init(.timetz(nil)) } + + /// time of day, including time zone + public static func timetz(_ n: Int) -> PostgresColumnType { .init(.timetz(n)) } + + /// date and time (no time zone) + public static var timestamp: PostgresColumnType { .init(.timestamp(nil)) } + + /// date and time (no time zone) + public static func timestamp(_ n: Int) -> PostgresColumnType { .init(.timestamp(n)) } + + /// date and time, including time zone + public static var timestamptz: PostgresColumnType { .init(.timestamptz(nil)) } + + /// date and time, including time zone + public static func timestamptz(_ n: Int) -> PostgresColumnType { .init(.timestamptz(n)) } + + /// text search query + public static var tsquery: PostgresColumnType { .init(.tsquery) } + + /// text search document + public static var tsvector: PostgresColumnType { .init(.tsvector) } + + /// user-level transaction ID snapshot + public static var txidSnapshot: PostgresColumnType { .init(.txidSnapshot) } + + /// universally unique identifier + public static var uuid: PostgresColumnType { .init(.uuid) } + + /// XML data + public static var xml: PostgresColumnType { .init(.xml) } + + /// User-defined type + public static func custom(_ name: String) -> PostgresColumnType { .init(.custom(name)) } + + /// Creates an array type from a `PostgreSQLDataType`. + public static func array(_ type: PostgresColumnType) -> PostgresColumnType { .init(.array(of: type.primitive)) } + + private let primitive: Primitive + private init(_ primitive: Primitive) { self.primitive = primitive } + + enum Primitive: CustomStringConvertible, Hashable, Sendable { + case bigint /// signed eight-byte integer + case bigserial /// autoincrementing eight-byte integer + case bit(Int?) /// fixed-length bit string + case varbit(Int?) /// variable-length bit string + case boolean /// logical Boolean (true/false) + case box /// rectangular box on a plane + case bytea /// binary data (“byte array”) + case char(Int?) /// fixed-length character string + case varchar(Int?) /// variable-length character string + case cidr /// IPv4 or IPv6 network address + case circle /// circle on a plane + case date /// calendar date (year, month, day) + case doublePrecision /// floating-point number (8 bytes) + case inet /// IPv4 or IPv6 host address + case integer /// signed four-byte integer + case interval /// time span + case json /// textual JSON data + case jsonb /// binary JSON data, decomposed + case line /// infinite line on a plane + case lseg /// line segment on a plane + case macaddr /// MAC (Media Access Control) address + case macaddr8 /// MAC (Media Access Control) address (EUI-64 format) + case money /// currency amount + case numeric(Int?, Int?) /// exact numeric of selectable precision + case path /// geometric path on a plane + case pgLSN /// PostgreSQL Log Sequence Number + case point /// geometric point on a plane + case polygon /// closed geometric path on a plane + case real /// single precision floating-point number (4 bytes) + case smallint /// signed two-byte integer + case smallserial /// autoincrementing two-byte integer + case serial /// autoincrementing four-byte integer + case text /// variable-length character string + case time(Int?) /// time of day (no time zone) + case timetz(Int?) /// time of day, including time zone + case timestamp(Int?) /// date and time (no time zone) + case timestamptz(Int?) /// date and time, including time zone + case tsquery /// text search query + case tsvector /// text search document + case txidSnapshot /// user-level transaction ID snapshot + case uuid /// universally unique identifier + case xml /// XML data + case custom(String) /// User-defined type + indirect case array(of: Primitive) /// Array + + /// See `CustomStringConvertible.description`. + var description: String { + switch self { + case .bigint: return "BIGINT" + case .bigserial: return "BIGSERIAL" + case .varbit(let n): return n.map { "VARBIT(\($0))" } ?? "VARBIT" + case .varchar(let n): return n.map { "VARCHAR(\($0))" } ?? "VARCHAR" + case .bit(let n): return n.map { "BIT(\($0))" } ?? "BIT" + case .boolean: return "BOOLEAN" + case .box: return "BOX" + case .bytea: return "BYTEA" + case .char(let n): return n.map { "CHAR(\($0))" } ?? "CHAR" + case .cidr: return "CIDR" + case .circle: return "CIRCLE" + case .date: return "DATE" + case .doublePrecision: return "DOUBLE PRECISION" + case .inet: return "INET" + case .integer: return "INTEGER" + case .interval: return "INTERVAL" + case .json: return "JSON" + case .jsonb: return "JSONB" + case .line: return "LINE" + case .lseg: return "LSEG" + case .macaddr: return "MACADDR" + case .macaddr8: return "MACADDER8" + case .money: return "MONEY" + case .numeric(let s, let p): return strictMap(s, p) { "NUMERIC(\($0), \($1))" } ?? "NUMERIC" + case .path: return "PATH" + case .pgLSN: return "PG_LSN" + case .point: return "POINT" + case .polygon: return "POLYGON" + case .real: return "REAL" + case .smallint: return "SMALLINT" + case .smallserial: return "SMALLSERIAL" + case .serial: return "SERIAL" + case .text: return "TEXT" + case .time(let p): return p.map { "TIME(\($0))" } ?? "TIME" + case .timetz(let p): return p.map { "TIMETZ(\($0))" } ?? "TIMETZ" + case .timestamp(let p): return p.map { "TIMESTAMP(\($0))" } ?? "TIMESTAMP" + case .timestamptz(let p): return p.map { "TIMESTAMPTZ(\($0))" } ?? "TIMESTAMPTZ" + case .tsquery: return "TSQUERY" + case .tsvector: return "TSVECTOR" + case .txidSnapshot: return "TXID_SNAPSHOT" + case .uuid: return "UUID" + case .xml: return "XML" + case .custom(let custom): return custom + case .array(let element): return "\(element)[]" + } + } + } + + // See `SQLExpression.serialize(to:)`. + public func serialize(to serializer: inout SQLSerializer) { + serializer.write(self.primitive.description) + } +} diff --git a/Sources/PostgresKit/PostgresConfiguration.swift b/Sources/PostgresKit/Deprecations/PostgresConfiguration.swift similarity index 74% rename from Sources/PostgresKit/PostgresConfiguration.swift rename to Sources/PostgresKit/Deprecations/PostgresConfiguration.swift index 96735e50..98446ec1 100644 --- a/Sources/PostgresKit/PostgresConfiguration.swift +++ b/Sources/PostgresKit/Deprecations/PostgresConfiguration.swift @@ -1,5 +1,8 @@ -@_exported import struct Foundation.URL +import Foundation +import NIOCore +import NIOSSL +@available(*, deprecated, message: "Use `SQLPostgresConfiguration` instead.") public struct PostgresConfiguration { public var address: () throws -> SocketAddress public var username: String @@ -31,32 +34,22 @@ public struct PostgresConfiguration { } public init?(url: URL) { - guard url.scheme?.hasPrefix("postgres") == true else { + guard let comp = URLComponents(url: url, resolvingAgainstBaseURL: true), + comp.scheme?.hasPrefix("postgres") ?? false, + let hostname = comp.host, let username = comp.user + else { return nil } - guard let username = url.user else { - return nil - } - let password = url.password - guard let hostname = url.host else { - return nil - } - let port = url.port ?? Self.ianaPortNumber - - let tlsConfiguration: TLSConfiguration? - if url.query?.contains("ssl=true") == true || url.query?.contains("sslmode=require") == true { - tlsConfiguration = TLSConfiguration.makeClientConfiguration() - } else { - tlsConfiguration = nil - } + let password = comp.password, port = comp.port ?? Self.ianaPortNumber + let wantTLS = (comp.queryItems ?? []).contains { ["ssl=true", "sslmode=require"].contains($0.description) } self.init( hostname: hostname, port: port, username: username, password: password, - database: url.path.split(separator: "/").last.flatMap(String.init), - tlsConfiguration: tlsConfiguration + database: url.lastPathComponent, + tlsConfiguration: wantTLS ? .makeClientConfiguration() : nil ) } diff --git a/Sources/PostgresKit/Deprecations/PostgresConnectionSource+PostgresConfiguration.swift b/Sources/PostgresKit/Deprecations/PostgresConnectionSource+PostgresConfiguration.swift new file mode 100644 index 00000000..3c366689 --- /dev/null +++ b/Sources/PostgresKit/Deprecations/PostgresConnectionSource+PostgresConfiguration.swift @@ -0,0 +1,70 @@ +import Atomics +import Logging +import NIOCore +import NIOSSL +import PostgresNIO + +extension PostgresConnectionSource { + @available(*, deprecated, message: "Use `sqlConfiguration` instead.") + public var configuration: PostgresConfiguration { + if let hostname = self.sqlConfiguration.coreConfiguration.host, + let port = self.sqlConfiguration.coreConfiguration.port + { + var oldConfig = PostgresConfiguration( + hostname: hostname, port: port, + username: self.sqlConfiguration.coreConfiguration.username, password: self.sqlConfiguration.coreConfiguration.password, + database: self.sqlConfiguration.coreConfiguration.database, + tlsConfiguration: self.sqlConfiguration.coreConfiguration.tls.sslContext.map { _ in .makeClientConfiguration() } + ) + oldConfig.requireBackendKeyData = self.sqlConfiguration.coreConfiguration.options.requireBackendKeyData + oldConfig.searchPath = self.sqlConfiguration.searchPath + return oldConfig + } else if let socketPath = self.sqlConfiguration.coreConfiguration.unixSocketPath { + var oldConfig = PostgresConfiguration( + unixDomainSocketPath: socketPath, + username: self.sqlConfiguration.coreConfiguration.username, password: self.sqlConfiguration.coreConfiguration.password, + database: self.sqlConfiguration.coreConfiguration.database + ) + oldConfig.requireBackendKeyData = self.sqlConfiguration.coreConfiguration.options.requireBackendKeyData + oldConfig.searchPath = self.sqlConfiguration.searchPath + return oldConfig + } else { + return .init(hostname: "", port: 0, username: "", password: nil, database: nil, tlsConfiguration: nil) + } + } + + @available(*, deprecated, message: "Use `sqlConfiguration` instead.") + public var sslContext: Result { .success(self.sqlConfiguration.coreConfiguration.tls.sslContext) } + + @available(*, deprecated, message: "Use `init(sqlConfiguration:)` instead.") + public init(configuration: PostgresConfiguration) { + self.init(sqlConfiguration: .init(legacyConfiguration: configuration)) + } +} + +extension SQLPostgresConfiguration { + // N.B.: This is public only for the sake of deprecated support in FluentPostgresDriver. Don't use it. + @available(*, deprecated, message: "This initializer is not intended for public use. Stop using `PostgresConfigration`.") + public init(legacyConfiguration configuration: PostgresConfiguration) { + if let hostname = configuration._hostname, let port = configuration._port { + self.init( + hostname: hostname, port: port, + username: configuration.username, password: configuration.password, + database: configuration.database, + tls: configuration.tlsConfiguration.flatMap { try? .require(.init(configuration: $0)) } ?? .disable + ) + self.coreConfiguration.options.requireBackendKeyData = configuration.requireBackendKeyData + self.searchPath = configuration.searchPath + } else if let address = try? configuration.address(), let socketPath = address.pathname { + self.init( + unixDomainSocketPath: socketPath, + username: configuration.username, password: configuration.password, + database: configuration.database + ) + self.coreConfiguration.options.requireBackendKeyData = configuration.requireBackendKeyData + self.searchPath = configuration.searchPath + } else { + fatalError("Nonsensical legacy configuration format") + } + } +} diff --git a/Sources/PostgresKit/Deprecations/PostgresDataDecoder+JSONDecoder.swift b/Sources/PostgresKit/Deprecations/PostgresDataDecoder+JSONDecoder.swift deleted file mode 100644 index e1e9babb..00000000 --- a/Sources/PostgresKit/Deprecations/PostgresDataDecoder+JSONDecoder.swift +++ /dev/null @@ -1,8 +0,0 @@ -import class Foundation.JSONDecoder - -extension PostgresDataDecoder { - @available(*, deprecated, renamed: "json") - public var jsonDecoder: JSONDecoder { - return self.json as! JSONDecoder - } -} diff --git a/Sources/PostgresKit/PostgresDataDecoder.swift b/Sources/PostgresKit/Deprecations/PostgresDataDecoder.swift similarity index 80% rename from Sources/PostgresKit/PostgresDataDecoder.swift rename to Sources/PostgresKit/Deprecations/PostgresDataDecoder.swift index a4b238d3..a98623b9 100644 --- a/Sources/PostgresKit/PostgresDataDecoder.swift +++ b/Sources/PostgresKit/Deprecations/PostgresDataDecoder.swift @@ -1,23 +1,23 @@ import Foundation -import protocol PostgresNIO.PostgresJSONDecoder -import var PostgresNIO._defaultJSONDecoder +import PostgresNIO +@available(*, deprecated, message: "Use `PostgresDecodingContext` instead.") public final class PostgresDataDecoder { - public let json: PostgresNIO.PostgresJSONDecoder + public let json: any PostgresJSONDecoder - public init(json: PostgresNIO.PostgresJSONDecoder = PostgresNIO._defaultJSONDecoder) { + public init(json: any PostgresJSONDecoder = PostgresNIO._defaultJSONDecoder) { self.json = json } - public func decode(_ type: T.Type, from data: PostgresData) throws -> T + public func decode(_: T.Type, from data: PostgresData) throws -> T where T: Decodable { // If `T` can be converted directly, just do so. - if let convertible = T.self as? PostgresDataConvertible.Type { + if let convertible = T.self as? any PostgresDataConvertible.Type { guard let value = convertible.init(postgresData: data) else { throw DecodingError.typeMismatch(T.self, .init( codingPath: [], - debugDescription: "Could not convert PostgreSQL data to \(T.self): \(data)" + debugDescription: "Could not convert PostgreSQL data to \(T.self): \(data as Any)" )) } return value as! T @@ -53,7 +53,7 @@ public final class PostgresDataDecoder { } private final class GiftBoxUnwrapDecoder: Decoder, SingleValueDecodingContainer { - var codingPath: [CodingKey] { [] } + var codingPath: [any CodingKey] { [] } var userInfo: [CodingUserInfoKey : Any] { [:] } let dataDecoder: PostgresDataDecoder @@ -68,7 +68,7 @@ public final class PostgresDataDecoder { throw DecodingError.dataCorruptedError(in: self, debugDescription: "Dictionary containers must be JSON-encoded") } - func unkeyedContainer() throws -> UnkeyedDecodingContainer { + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { guard let array = self.data.array else { throw DecodingError.dataCorruptedError(in: self, debugDescription: "Non-natively typed arrays must be JSON-encoded") } @@ -78,7 +78,7 @@ public final class PostgresDataDecoder { struct ArrayContainer: UnkeyedDecodingContainer { let data: [PostgresData] let dataDecoder: PostgresDataDecoder - var codingPath: [CodingKey] { [] } + var codingPath: [any CodingKey] { [] } var count: Int? { self.data.count } var isAtEnd: Bool { self.currentIndex >= self.data.count } var currentIndex: Int = 0 @@ -92,7 +92,7 @@ public final class PostgresDataDecoder { return false } - mutating func decode(_ type: T.Type) throws -> T where T : Decodable { + mutating func decode(_: T.Type) throws -> T where T: Decodable { // Do _not_ shorten this using `defer`, otherwise `currentIndex` is incorrectly incremented. let result = try self.dataDecoder.decode(T.self, from: self.data[self.currentIndex]) self.currentIndex += 1 @@ -103,16 +103,16 @@ public final class PostgresDataDecoder { throw DecodingError.dataCorruptedError(in: self, debugDescription: "Data nesting is not supported") } - mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { + mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { throw DecodingError.dataCorruptedError(in: self, debugDescription: "Data nesting is not supported") } - mutating func superDecoder() throws -> Decoder { + mutating func superDecoder() throws -> any Decoder { throw DecodingError.dataCorruptedError(in: self, debugDescription: "Data nesting is not supported") } } - func singleValueContainer() throws -> SingleValueDecodingContainer { + func singleValueContainer() throws -> any SingleValueDecodingContainer { return self } @@ -120,9 +120,14 @@ public final class PostgresDataDecoder { self.data.value == nil } - func decode(_ type: T.Type) throws -> T where T : Decodable { + func decode(_: T.Type) throws -> T where T: Decodable { // Recurse back into the data decoder, don't repeat its logic here. return try self.dataDecoder.decode(T.self, from: self.data) } } + + @available(*, deprecated, renamed: "json") + public var jsonDecoder: JSONDecoder { + return self.json as! JSONDecoder + } } diff --git a/Sources/PostgresKit/Deprecations/PostgresDataEncoder.swift b/Sources/PostgresKit/Deprecations/PostgresDataEncoder.swift new file mode 100644 index 00000000..057b545f --- /dev/null +++ b/Sources/PostgresKit/Deprecations/PostgresDataEncoder.swift @@ -0,0 +1,125 @@ +import Foundation +import PostgresNIO + +@available(*, deprecated, message: "Use `PostgresJSONEncoder` and `PostgresEncodable` instead.") +public final class PostgresDataEncoder { + public let json: any PostgresJSONEncoder + + public init(json: any PostgresJSONEncoder = PostgresNIO._defaultJSONEncoder) { + self.json = json + } + + public func encode(_ value: any Encodable) throws -> PostgresData { + if let custom = value as? any PostgresDataConvertible, let data = custom.postgresData { + return data + } else { + let encoder = _Encoder(parent: self) + do { + try value.encode(to: encoder) + switch encoder.value { + case .invalid: throw _Encoder.AssociativeValueSentinel() // this is usually "nothing was encoded at all", not an associative value, but the desired action is the same + case .scalar(let scalar): return scalar + case .indexed(let indexed): + let elementType = indexed.contents.first?.type ?? .jsonb + assert(indexed.contents.allSatisfy { $0.type == elementType }, "Type \(type(of: value)) was encoded as a heterogenous array; this is unsupported.") + return PostgresData(array: indexed.contents, elementType: elementType) + } + } catch is _Encoder.AssociativeValueSentinel { + return try PostgresData(jsonb: self.json.encode(value)) + } + } + } + + private final class _Encoder: Encoder { + struct AssociativeValueSentinel: Error {} + enum Value { + final class RefArray { var contents: [T] = [] } + case invalid, indexed(RefArray), scalar(PostgresData) + + var isValid: Bool { if case .invalid = self { return false }; return true } + mutating func requestIndexed(for encoder: _Encoder) { + switch self { + case .scalar(_): preconditionFailure("Invalid request for both single-value and unkeyed containers from the same encoder.") + case .invalid: self = .indexed(.init()) // no existing value, make new array + case .indexed(_): break // existing array, adopt it for appending (support for superEncoder()) + } + } + mutating func storeScalar(_ scalar: PostgresData) { + switch self { + case .indexed(_), .scalar(_): preconditionFailure("Invalid request for multiple containers from the same encoder.") + case .invalid: self = .scalar(scalar) // no existing value, store the incoming + } + } + var indexedCount: Int { + switch self { + case .invalid, .scalar(_): preconditionFailure("Internal error in encoder (requested indexed count from non-indexed state)") + case .indexed(let ref): return ref.contents.count + } + } + mutating func addToIndexed(_ scalar: PostgresData) { + switch self { + case .invalid, .scalar(_): preconditionFailure("Internal error in encoder (attempted store to indexed in non-indexed state)") + case .indexed(let ref): ref.contents.append(scalar) + } + } + } + + var userInfo: [CodingUserInfoKey : Any] { [:] }; var codingPath: [any CodingKey] { [] } + var parent: PostgresDataEncoder, value: Value + + init(parent: PostgresDataEncoder, value: Value = .invalid) { (self.parent, self.value) = (parent, value) } + func container(keyedBy: K.Type) -> KeyedEncodingContainer { + precondition(!self.value.isValid, "Requested multiple containers from the same encoder.") + return .init(_FailingKeyedContainer()) + } + func unkeyedContainer() -> any UnkeyedEncodingContainer { + self.value.requestIndexed(for: self) + return _UnkeyedValueContainer(encoder: self) + } + func singleValueContainer() -> any SingleValueEncodingContainer { + precondition(!self.value.isValid, "Requested multiple containers from the same encoder.") + return _SingleValueContainer(encoder: self) + } + + struct _UnkeyedValueContainer: UnkeyedEncodingContainer { + let encoder: _Encoder; var codingPath: [any CodingKey] { self.encoder.codingPath } + var count: Int { self.encoder.value.indexedCount } + mutating func encodeNil() throws { self.encoder.value.addToIndexed(.null) } + mutating func encode(_ value: T) throws { self.encoder.value.addToIndexed(try self.encoder.parent.encode(value)) } + mutating func nestedContainer(keyedBy: K.Type) -> KeyedEncodingContainer { self.superEncoder().container(keyedBy: K.self) } + mutating func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { self.superEncoder().unkeyedContainer() } + mutating func superEncoder() -> any Encoder { _Encoder(parent: self.encoder.parent, value: self.encoder.value) } // NOT the same as self.encoder + } + + struct _SingleValueContainer: SingleValueEncodingContainer { + let encoder: _Encoder; var codingPath: [any CodingKey] { self.encoder.codingPath } + func encodeNil() throws { self.encoder.value.storeScalar(.null) } + func encode(_ value: T) throws { self.encoder.value.storeScalar(try self.encoder.parent.encode(value)) } + } + + /// This pair of types is only necessary because we can't directly throw an error from various Encoder and + /// encoding container methods. We define duplicate types rather than the old implementation's use of a + /// no-action keyed container because it can save a significant amount of time otherwise spent uselessly calling + /// nested methods in some cases. + struct _TaintedEncoder: Encoder, UnkeyedEncodingContainer, SingleValueEncodingContainer { + var userInfo: [CodingUserInfoKey : Any] { [:] }; var codingPath: [any CodingKey] { [] }; var count: Int { 0 } + func container(keyedBy: K.Type) -> KeyedEncodingContainer { .init(_FailingKeyedContainer()) } + func nestedContainer(keyedBy: K.Type) -> KeyedEncodingContainer { .init(_FailingKeyedContainer()) } + func unkeyedContainer() -> any UnkeyedEncodingContainer { self } + func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { self } + func singleValueContainer() -> any SingleValueEncodingContainer { self } + func superEncoder() -> any Encoder { self } + func encodeNil() throws { throw AssociativeValueSentinel() } + func encode(_: T) throws { throw AssociativeValueSentinel() } + } + struct _FailingKeyedContainer: KeyedEncodingContainerProtocol { + var codingPath: [any CodingKey] { [] } + func encodeNil(forKey: K) throws { throw AssociativeValueSentinel() } + func encode(_: T, forKey: K) throws { throw AssociativeValueSentinel() } + func nestedContainer(keyedBy: NK.Type, forKey: K) -> KeyedEncodingContainer { .init(_FailingKeyedContainer()) } + func nestedUnkeyedContainer(forKey: K) -> any UnkeyedEncodingContainer { _TaintedEncoder() } + func superEncoder() -> any Encoder { _TaintedEncoder() } + func superEncoder(forKey: K) -> any Encoder { _TaintedEncoder() } + } + } +} diff --git a/Sources/PostgresKit/Deprecations/PostgresDatabase+SQL+Deprecated.swift b/Sources/PostgresKit/Deprecations/PostgresDatabase+SQL+Deprecated.swift new file mode 100644 index 00000000..ccc813b1 --- /dev/null +++ b/Sources/PostgresKit/Deprecations/PostgresDatabase+SQL+Deprecated.swift @@ -0,0 +1,39 @@ +import Foundation +import PostgresNIO +import SQLKit + +@available(*, deprecated, message: "Use `.sql(jsonEncoder:jsonDecoder:)` instead.") +extension PostgresDatabase { + @inlinable public func sql(encoder: PostgresDataEncoder) -> any SQLDatabase { self.sql(encoder: encoder, decoder: .init()) } + @inlinable public func sql(decoder: PostgresDataDecoder) -> any SQLDatabase { self.sql(encoder: .init(), decoder: decoder) } + @inlinable public func sql(encoder: PostgresDataEncoder, decoder: PostgresDataDecoder) -> any SQLDatabase { + self.sql( + encodingContext: .init(jsonEncoder: TypeErasedPostgresJSONEncoder(json: encoder.json)), + decodingContext: .init(jsonDecoder: TypeErasedPostgresJSONDecoder(json: decoder.json)) + ) + } +} + +extension PostgresRow { + @available(*, deprecated, message: "Use `.sql(jsonDecoder:)` instead.") + @inlinable public func sql(decoder: PostgresDataDecoder) -> any SQLRow { + self.sql(decodingContext: .init(jsonDecoder: TypeErasedPostgresJSONDecoder(json: decoder.json))) + } +} + +@usableFromInline +struct TypeErasedPostgresJSONDecoder: PostgresJSONDecoder { + let json: any PostgresJSONDecoder + @usableFromInline init(json: any PostgresJSONDecoder) { self.json = json } + @usableFromInline func decode(_: T.Type, from data: Data) throws -> T { try self.json.decode(T.self, from: data) } + @usableFromInline func decode(_: T.Type, from buffer: ByteBuffer) throws -> T { try self.json.decode(T.self, from: buffer) } +} + +@usableFromInline +struct TypeErasedPostgresJSONEncoder: PostgresJSONEncoder { + let json: any PostgresJSONEncoder + @usableFromInline init(json: any PostgresJSONEncoder) { self.json = json } + @usableFromInline func encode(_ value: T) throws -> Data { try self.json.encode(value) } + @usableFromInline func encode(_ value: T, into buffer: inout ByteBuffer) throws { try self.json.encode(value, into: &buffer) } +} + diff --git a/Sources/PostgresKit/Docs.docc/PostgresKit.md b/Sources/PostgresKit/Docs.docc/PostgresKit.md new file mode 100644 index 00000000..4b86941e --- /dev/null +++ b/Sources/PostgresKit/Docs.docc/PostgresKit.md @@ -0,0 +1,32 @@ +# ``PostgresKit`` + +@Metadata { + @TitleHeading(Package) +} + +PostgresKit is a library providing an SQLKit driver for PostgresNIO. + +## Overview + +This package provides the "foundational" level of support for using [Fluent] with PostgreSQL by implementing the requirements of an [SQLKit] driver. It is responsible for: + +- Managing the underlying PostgreSQL library ([PostgresNIO]), +- Providing a two-way bridge between PostgresNIO and SQLKit's generic data and metadata formats, and +- Presenting an interface for establishing, managing, and interacting with database connections via [AsyncKit]. + +> Important: It is strongly recommended that users who leverage PostgresKit directly (e.g. absent the Fluent ORM layer) take advantage of PostgresNIO's [PostgresClient] API for connection management rather than relying upon the legacy AsyncKit-based support. + +> Tip: A FluentKit driver for PostgreSQL is provided by the [FluentPostgresDriver] package. + +## Version Support + +This package uses [PostgresNIO] for all underlying database interactions. It is compatible with all versions of PostgreSQL and all platforms supported by that package. + +> Caution: There is one exception to the above at the time of this writing: This package requires Swift 5.8 or newer, whereas PostgresNIO continues to support Swift 5.6. + +[SQLKit]: https://swiftpackageindex.com/vapor/sql-kit +[PostgresNIO]: https://swiftpackageindex.com/vapor/postgres-nio +[Fluent]: https://swiftpackageindex.com/vapor/fluent-kit +[FluentPostgresDriver]: https://swiftpackageindex.com/vapor/fluent-postgres-driver +[AsyncKit]: https://swiftpackageindex.com/vapor/async-kit +[PostgresClient]: https://api.vapor.codes/postgresnio/documentation/postgresnio/postgresclient diff --git a/Sources/PostgresKit/Docs.docc/Resources/vapor-postgreskit-logo.svg b/Sources/PostgresKit/Docs.docc/Resources/vapor-postgreskit-logo.svg new file mode 100644 index 00000000..577997a4 --- /dev/null +++ b/Sources/PostgresKit/Docs.docc/Resources/vapor-postgreskit-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/PostgresKit/Docs.docc/theme-settings.json b/Sources/PostgresKit/Docs.docc/theme-settings.json new file mode 100644 index 00000000..d78a16fd --- /dev/null +++ b/Sources/PostgresKit/Docs.docc/theme-settings.json @@ -0,0 +1,24 @@ +{ + "theme": { + "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, + "border-radius": "0", + "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "color": { + "psqlkit": "#336791", + "documentation-intro-fill": "radial-gradient(circle at top, var(--color-psqlkit) 30%, #000 100%)", + "documentation-intro-accent": "var(--color-psqlkit)", + "documentation-intro-eyebrow": "white", + "documentation-intro-figure": "white", + "documentation-intro-title": "white", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "fill": { "dark": "#000", "light": "#fff" } + }, + "icons": { "technology": "/postgreskit/images/vapor-postgreskit-logo.svg" } + }, + "features": { + "quickNavigation": { "enable": true }, + "i18n": { "enable": true } + } +} diff --git a/Sources/PostgresKit/Exports.swift b/Sources/PostgresKit/Exports.swift index 9e5c3e88..baa0374f 100644 --- a/Sources/PostgresKit/Exports.swift +++ b/Sources/PostgresKit/Exports.swift @@ -1,3 +1,4 @@ -@_exported import AsyncKit -@_exported import PostgresNIO -@_exported import SQLKit +@_documentation(visibility: internal) @_exported import AsyncKit +@_documentation(visibility: internal) @_exported import PostgresNIO +@_documentation(visibility: internal) @_exported import SQLKit +@_documentation(visibility: internal) @_exported import struct Foundation.URL diff --git a/Sources/PostgresKit/PostgresClient+SQL.swift b/Sources/PostgresKit/PostgresClient+SQL.swift deleted file mode 100644 index f5271f7f..00000000 --- a/Sources/PostgresKit/PostgresClient+SQL.swift +++ /dev/null @@ -1,47 +0,0 @@ -import PostgresNIO -import Foundation -import SQLKit - -extension PostgresDatabase { - public func sql( - encoder: PostgresDataEncoder = PostgresDataEncoder(), - decoder: PostgresDataDecoder = PostgresDataDecoder() - ) -> SQLDatabase { - _PostgresSQLDatabase(database: self, encoder: encoder, decoder: decoder) - } -} - -// MARK: Private - -private struct _PostgresSQLDatabase { - let database: PostgresDatabase - let encoder: PostgresDataEncoder - let decoder: PostgresDataDecoder -} - -extension _PostgresSQLDatabase: SQLDatabase { - var logger: Logger { - self.database.logger - } - - var eventLoop: EventLoop { - self.database.eventLoop - } - - var dialect: SQLDialect { - PostgresDialect() - } - - func execute(sql query: SQLExpression, _ onRow: @escaping (SQLRow) -> ()) -> EventLoopFuture { - let (sql, binds) = self.serialize(query) - do { - return try self.database.query(sql, binds.map { encodable in - return try self.encoder.encode(encodable) - }) { row in - onRow(row.sql(decoder: self.decoder)) - } - } catch { - return self.eventLoop.makeFailedFuture(error) - } - } -} diff --git a/Sources/PostgresKit/PostgresColumnType.swift b/Sources/PostgresKit/PostgresColumnType.swift deleted file mode 100644 index 5dbe6def..00000000 --- a/Sources/PostgresKit/PostgresColumnType.swift +++ /dev/null @@ -1,582 +0,0 @@ -import SQLKit - -/// Postgres-specific column types. -public struct PostgresColumnType: SQLExpression, Equatable { - public static var blob: PostgresColumnType { - return .varbit - } - - /// See `Equatable`. - public static func == (lhs: PostgresColumnType, rhs: PostgresColumnType) -> Bool { - return lhs.primitive == rhs.primitive && lhs.isArray == rhs.isArray - } - - /// signed eight-byte integer - public static var int8: PostgresColumnType { - return .bigint - } - - /// signed eight-byte integer - public static var bigint: PostgresColumnType { - return .init(.bigint) - } - - /// autoincrementing eight-byte integer - public static var serial8: PostgresColumnType { - return .bigserial - } - - /// autoincrementing eight-byte integer - public static var bigserial: PostgresColumnType { - return .init(.bigserial) - } - - /// fixed-length bit string - public static var bit: PostgresColumnType { - return .init(.bit(nil)) - } - - /// fixed-length bit string - public static func bit(_ n: Int) -> PostgresColumnType { - return .init(.bit(n)) - } - - /// variable-length bit string - public static var varbit: PostgresColumnType { - return .init(.varbit(nil)) - } - - /// variable-length bit string - public static func varbit(_ n: Int) -> PostgresColumnType { - return .init(.varbit(n)) - } - - /// logical Boolean (true/false) - public static var bool: PostgresColumnType { - return .boolean - } - - /// logical Boolean (true/false) - public static var boolean: PostgresColumnType { - return .init(.boolean) - } - - /// rectangular box on a plane - public static var box: PostgresColumnType { - return .init(.box) - } - - /// binary data (“byte array”) - public static var bytea: PostgresColumnType { - return .init(.bytea) - } - - /// fixed-length character string - public static var char: PostgresColumnType { - return .init(.char(nil)) - } - - /// fixed-length character string - public static func char(_ n: Int) -> PostgresColumnType { - return .init(.char(n)) - } - - /// variable-length character string - public static var varchar: PostgresColumnType { - return .init(.varchar(nil)) - } - - /// variable-length character string - public static func varchar(_ n: Int) -> PostgresColumnType { - return .init(.varchar(n)) - } - - /// IPv4 or IPv6 network address - public static var cidr: PostgresColumnType { - return .init(.cidr) - } - - /// circle on a plane - public static var circle: PostgresColumnType { - return .init(.circle) - } - - /// calendar date (year, month, day) - public static var date: PostgresColumnType { - return .init(.date) - } - - /// floating-point number (8 bytes) - public static var float8: PostgresColumnType { - return .doublePrecision - } - - /// floating-point number (8 bytes) - public static var doublePrecision: PostgresColumnType { - return .init(.doublePrecision) - } - - /// IPv4 or IPv6 host address - public static var inet: PostgresColumnType { - return .init(.inet) - } - - /// signed four-byte integer - public static var int: PostgresColumnType { - return .integer - } - - /// signed four-byte integer - public static var int4: PostgresColumnType { - return .integer - } - - /// signed four-byte integer - public static var integer: PostgresColumnType { - return .init(.integer) - } - - /// time span - public static var interval: PostgresColumnType { - return .init(.interval) - } - - /// textual JSON data - public static var json: PostgresColumnType { - return .init(.json) - } - - /// binary JSON data, decomposed - public static var jsonb: PostgresColumnType { - return .init(.jsonb) - } - - /// infinite line on a plane - public static var line: PostgresColumnType { - return .init(.line) - } - - /// line segment on a plane - public static var lseg: PostgresColumnType { - return .init(.lseg) - } - - /// MAC (Media Access Control) address - public static var macaddr: PostgresColumnType { - return .init(.macaddr) - } - - /// MAC (Media Access Control) address (EUI-64 format) - public static var macaddr8: PostgresColumnType { - return .init(.macaddr8) - } - - /// currency amount - public static var money: PostgresColumnType { - return .init(.money) - } - - /// exact numeric of selectable precision - public static var decimal: PostgresColumnType { - return .init(.numeric(nil, nil)) - } - - /// exact numeric of selectable precision - public static func decimal(_ p: Int, _ s: Int) -> PostgresColumnType { - return .init(.numeric(p, s)) - } - - /// exact numeric of selectable precision - public static func numeric(_ p: Int, _ s: Int) -> PostgresColumnType { - return .init(.numeric(p, s)) - } - - /// exact numeric of selectable precision - public static var numeric: PostgresColumnType { - return .init(.numeric(nil, nil)) - } - - /// geometric path on a plane - public static var path: PostgresColumnType { - return .init(.path) - } - - /// PostgreSQL Log Sequence Number - public static var pgLSN: PostgresColumnType { - return .init(.pgLSN) - } - - /// geometric point on a plane - public static var point: PostgresColumnType { - return .init(.point) - } - - /// closed geometric path on a plane - public static var polygon: PostgresColumnType { - return .init(.polygon) - } - - /// single precision floating-point number (4 bytes) - public static var float4: PostgresColumnType { - return .real - } - - /// single precision floating-point number (4 bytes) - public static var real: PostgresColumnType { - return .init(.real) - } - - /// signed two-byte integer - public static var int2: PostgresColumnType { - return .smallint - } - - /// signed two-byte integer - public static var smallint: PostgresColumnType { - return .init(.smallint) } - - /// autoincrementing two-byte integer - public static var serial2: PostgresColumnType { - return .smallserial - } - - /// autoincrementing two-byte integer - public static var smallserial: PostgresColumnType { - return .init(.smallserial) - } - - /// autoincrementing four-byte integer - public static var serial4: PostgresColumnType { - return .serial - } - - /// autoincrementing four-byte integer - public static var serial: PostgresColumnType { - return .init(.serial) - } - - /// variable-length character string - public static var text: PostgresColumnType { - return .init(.text) - } - - /// time of day (no time zone) - public static var time: PostgresColumnType { - return .init(.time(nil)) - } - - /// time of day (no time zone) - public static func time(_ n: Int) -> PostgresColumnType { - return .init(.time(n)) - } - - /// time of day, including time zone - public static var timetz: PostgresColumnType { - return .init(.timetz(nil)) - } - - /// time of day, including time zone - public static func timetz(_ n: Int) -> PostgresColumnType { - return .init(.timetz(n)) - } - - /// date and time (no time zone) - public static var timestamp: PostgresColumnType { - return .init(.timestamp(nil)) - } - - /// date and time (no time zone) - public static func timestamp(_ n: Int) -> PostgresColumnType { - return .init(.timestamp(n)) - } - - /// date and time, including time zone - public static var timestamptz: PostgresColumnType { - return .init(.timestamptz(nil)) - } - - /// date and time, including time zone - public static func timestamptz(_ n: Int) -> PostgresColumnType { - return .init(.timestamptz(n)) - } - - /// text search query - public static var tsquery: PostgresColumnType { - return .init(.tsquery) - } - - /// text search document - public static var tsvector: PostgresColumnType { - return .init(.tsvector) - } - - /// user-level transaction ID snapshot - public static var txidSnapshot: PostgresColumnType { - return .init(.txidSnapshot) - } - - /// universally unique identifier - public static var uuid: PostgresColumnType { - return .init(.uuid) - } - - /// XML data - public static var xml: PostgresColumnType { - return .init(.xml) - } - - /// User-defined type - public static func custom(_ name: String) -> PostgresColumnType { - return .init(.custom(name)) - } - - /// Creates an array type from a `PostgreSQLDataType`. - public static func array(_ dataType: PostgresColumnType) -> PostgresColumnType { - return .init(dataType.primitive, isArray: true) - } - - let primitive: Primitive - let isArray: Bool - - private init(_ primitive: Primitive, isArray: Bool = false) { - self.primitive = primitive - self.isArray = isArray - } - - enum Primitive: Equatable { - /// signed eight-byte integer - case bigint - - /// autoincrementing eight-byte integer - case bigserial - - /// fixed-length bit string - case bit(Int?) - - /// variable-length bit string - case varbit(Int?) - - /// logical Boolean (true/false) - case boolean - - /// rectangular box on a plane - case box - - /// binary data (“byte array”) - case bytea - - /// fixed-length character string - case char(Int?) - - /// variable-length character string - case varchar(Int?) - - /// IPv4 or IPv6 network address - case cidr - - /// circle on a plane - case circle - - /// calendar date (year, month, day) - case date - - /// floating-point number (8 bytes) - case doublePrecision - - /// IPv4 or IPv6 host address - case inet - - /// signed four-byte integer - case integer - - /// time span - case interval - - /// textual JSON data - case json - - /// binary JSON data, decomposed - case jsonb - - /// infinite line on a plane - case line - - /// line segment on a plane - case lseg - - /// MAC (Media Access Control) address - case macaddr - - /// MAC (Media Access Control) address (EUI-64 format) - case macaddr8 - - /// currency amount - case money - - /// exact numeric of selectable precision - case numeric(Int?, Int?) - - /// geometric path on a plane - case path - - /// PostgreSQL Log Sequence Number - case pgLSN - - /// geometric point on a plane - case point - - /// closed geometric path on a plane - case polygon - - /// single precision floating-point number (4 bytes) - case real - - /// signed two-byte integer - case smallint - - /// autoincrementing two-byte integer - case smallserial - - /// autoincrementing four-byte integer - case serial - - /// variable-length character string - case text - - /// time of day (no time zone) - case time(Int?) - - /// time of day, including time zone - case timetz(Int?) - - /// date and time (no time zone) - case timestamp(Int?) - - /// date and time, including time zone - case timestamptz(Int?) - - /// text search query - case tsquery - - /// text search document - case tsvector - - /// user-level transaction ID snapshot - case txidSnapshot - - /// universally unique identifier - case uuid - - /// XML data - case xml - - /// User-defined type - case custom(String) - - public func serialize(to serializer: inout SQLSerializer) { - serializer.write(self.string) - } - - /// See `SQLSerializable`. - private var string: String { - switch self { - case .bigint: return "BIGINT" - case .bigserial: return "BIGSERIAL" - case .varbit(let n): - if let n = n { - return "VARBIT(" + n.description + ")" - } else { - return "VARBIT" - } - case .varchar(let n): - if let n = n { - return "VARCHAR(" + n.description + ")" - } else { - return "VARCHAR" - } - case .bit(let n): - if let n = n { - return "BIT(" + n.description + ")" - } else { - return "BIT" - } - case .boolean: return "BOOLEAN" - case .box: return "BOX" - case .bytea: return "BYTEA" - case .char(let n): - if let n = n { - return "CHAR(" + n.description + ")" - } else { - return "CHAR" - } - case .cidr: return "CIDR" - case .circle: return "CIRCLE" - case .date: return "DATE" - case .doublePrecision: return "DOUBLE PRECISION" - case .inet: return "INET" - case .integer: return "INTEGER" - case .interval: return "INTERVAL" - case .json: return "JSON" - case .jsonb: return "JSONB" - case .line: return "LINE" - case .lseg: return "LSEG" - case .macaddr: return "MACADDR" - case .macaddr8: return "MACADDER8" - case .money: return "MONEY" - case .numeric(let s, let p): - if let s = s, let p = p { - return "NUMERIC(" + s.description + ", " + p.description + ")" - } else { - return "NUMERIC" - } - case .path: return "PATH" - case .pgLSN: return "PG_LSN" - case .point: return "POINT" - case .polygon: return "POLYGON" - case .real: return "REAL" - case .smallint: return "SMALLINT" - case .smallserial: return "SMALLSERIAL" - case .serial: return "SERIAL" - case .text: return "TEXT" - case .time(let p): - if let p = p { - return "TIME(" + p.description + ")" - } else { - return "TIME" - } - case .timetz(let p): - if let p = p { - return "TIMETZ(" + p.description + ")" - } else { - return "TIMETZ" - } - case .timestamp(let p): - if let p = p { - return "TIMESTAMP(" + p.description + ")" - } else { - return "TIMESTAMP" - } - case .timestamptz(let p): - if let p = p { - return "TIMESTAMPTZ(" + p.description + ")" - } else { - return "TIMESTAMPTZ" - } - case .tsquery: return "TSQUERY" - case .tsvector: return "TSVECTOR" - case .txidSnapshot: return "TXID_SNAPSHOT" - case .uuid: return "UUID" - case .xml: return "XML" - case .custom(let custom): return custom - } - } - } - - /// See `SQLSerializable`. - public func serialize(to serializer: inout SQLSerializer) { - self.primitive.serialize(to: &serializer) - if self.isArray { - serializer.write("[]") - } - } -} diff --git a/Sources/PostgresKit/PostgresConnectionSource.swift b/Sources/PostgresKit/PostgresConnectionSource.swift index 57e2e279..37f70541 100644 --- a/Sources/PostgresKit/PostgresConnectionSource.swift +++ b/Sources/PostgresKit/PostgresConnectionSource.swift @@ -1,86 +1,45 @@ +import AsyncKit +import Logging import NIOConcurrencyHelpers +import NIOCore import NIOSSL +import PostgresNIO +import SQLKit public struct PostgresConnectionSource: ConnectionPoolSource { - public let configuration: PostgresConfiguration - public let sslContext: Result - private static let idGenerator = NIOAtomic.makeAtomic(value: 0) + public let sqlConfiguration: SQLPostgresConfiguration - public init(configuration: PostgresConfiguration) { - self.configuration = configuration - // TODO: Figure out a way to throw errors from this initializer sensibly, or to lazily init the NIOSSLContext only once in makeConnection() - self.sslContext = .init(catching: { try configuration._hostname.flatMap { _ in try configuration.tlsConfiguration.map { try .init(configuration: $0) } } }) + private static let idGenerator = NIOLockedValueBox(0) + + public init(sqlConfiguration: SQLPostgresConfiguration) { + self.sqlConfiguration = sqlConfiguration } public func makeConnection( logger: Logger, - on eventLoop: EventLoop + on eventLoop: any EventLoop ) -> EventLoopFuture { - if let hostname = self.configuration._hostname { - let tlsMode: PostgresConnection.Configuration.TLS - switch self.sslContext { - case let .success(sslContext): tlsMode = sslContext.map { .require($0) } ?? .disable - case let .failure(error): return eventLoop.makeFailedFuture(error) - } - - var connection: PostgresConnection.Configuration.Connection - connection = .init(host: hostname, port: self.configuration._port ?? PostgresConfiguration.ianaPortNumber) - connection.requireBackendKeyData = configuration.requireBackendKeyData - - let future = PostgresConnection.connect( - on: eventLoop, - configuration: .init( - connection: connection, - authentication: .init(username: self.configuration.username, database: self.configuration.database, password: self.configuration.password), - tls: tlsMode - ), - id: Self.idGenerator.add(1), - logger: logger - ) - - if let searchPath = self.configuration.searchPath { - return future.flatMap { conn in - let string = searchPath.map { #""\#($0)""# }.joined(separator: ", ") - return conn.simpleQuery("SET search_path = \(string)").map { _ in conn } - } - } else { - return future - } - } else { - let address: SocketAddress - do { - address = try self.configuration.address() - } catch { - return eventLoop.makeFailedFuture(error) - } + let connectionFuture = PostgresConnection.connect( + on: eventLoop, + configuration: self.sqlConfiguration.coreConfiguration, + id: Self.idGenerator.withLockedValue { + $0 += 1 + return $0 + }, + logger: logger + ) - // Legacy code path until PostgresNIO regains support for connecting directly to a SocketAddress. - return PostgresConnection.connect( - to: address, - tlsConfiguration: self.configuration.tlsConfiguration, - serverHostname: self.configuration._hostname, - logger: logger, - on: eventLoop - ).flatMap { conn in - return conn.authenticate( - username: self.configuration.username, - database: self.configuration.database, - password: self.configuration.password, - logger: logger - ).flatMap { - if let searchPath = self.configuration.searchPath { - let string = searchPath.map { "\"" + $0 + "\"" }.joined(separator: ", ") - return conn.simpleQuery("SET search_path = \(string)").map { _ in conn } - } else { - return eventLoop.makeSucceededFuture(conn) - } - }.flatMapErrorThrowing { error in - _ = conn.close() - throw error - } + if let searchPath = self.sqlConfiguration.searchPath { + return connectionFuture.flatMap { conn in + conn.sql(queryLogLevel: nil) + .raw("SET search_path TO \(idents: searchPath, joinedBy: ",")") + .run() + .map { _ in conn } } + } else { + return connectionFuture } } } -extension PostgresConnection: ConnectionPoolItem { } +extension PostgresNIO.PostgresConnection: AsyncKit.ConnectionPoolItem {} diff --git a/Sources/PostgresKit/PostgresDataEncoder.swift b/Sources/PostgresKit/PostgresDataEncoder.swift deleted file mode 100644 index e60d7483..00000000 --- a/Sources/PostgresKit/PostgresDataEncoder.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Foundation -import protocol PostgresNIO.PostgresJSONEncoder -import var PostgresNIO._defaultJSONEncoder - -public final class PostgresDataEncoder { - public let json: PostgresJSONEncoder - - public init(json: PostgresJSONEncoder = PostgresNIO._defaultJSONEncoder) { - self.json = json - } - - public func encode(_ value: Encodable) throws -> PostgresData { - if let custom = value as? PostgresDataConvertible, let data = custom.postgresData { - return data - } else { - let context = _Context() - try value.encode(to: _Encoder(context: context)) - if let value = context.value { - return value - } else if let array = context.array { - let elementType = array.first?.type ?? .jsonb - assert(array.filter { $0.type != elementType }.isEmpty, "Array does not contain all: \(elementType)") - return PostgresData(array: array, elementType: elementType) - } else { - return try PostgresData(jsonb: self.json.encode(_Wrapper(value))) - } - } - } - - final class _Context { - var value: PostgresData? - var array: [PostgresData]? - - init() { } - } - - struct _Encoder: Encoder { - var userInfo: [CodingUserInfoKey : Any] { - [:] - } - var codingPath: [CodingKey] { - [] - } - let context: _Context - - func container(keyedBy type: Key.Type) -> KeyedEncodingContainer - where Key : CodingKey - { - .init(_KeyedEncoder()) - } - - func unkeyedContainer() -> UnkeyedEncodingContainer { - self.context.array = [] - return _UnkeyedEncoder(context: self.context) - } - - func singleValueContainer() -> SingleValueEncodingContainer { - _ValueEncoder(context: self.context) - } - } - - struct _UnkeyedEncoder: UnkeyedEncodingContainer { - var codingPath: [CodingKey] { - [] - } - var count: Int { - 0 - } - - var context: _Context - - func encodeNil() throws { - self.context.array!.append(.null) - } - - func encode(_ value: T) throws where T : Encodable { - try self.context.array!.append(PostgresDataEncoder().encode(value)) - } - - func nestedContainer( - keyedBy keyType: NestedKey.Type - ) -> KeyedEncodingContainer - where NestedKey : CodingKey - { - fatalError() - } - - func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { - fatalError() - } - - func superEncoder() -> Encoder { - fatalError() - } - } - - struct _KeyedEncoder: KeyedEncodingContainerProtocol - where Key: CodingKey - { - var codingPath: [CodingKey] { - [] - } - - func encodeNil(forKey key: Key) throws { - // do nothing - } - - func encode(_ value: T, forKey key: Key) throws where T : Encodable { - // do nothing - } - - func nestedContainer( - keyedBy keyType: NestedKey.Type, - forKey key: Key - ) -> KeyedEncodingContainer - where NestedKey : CodingKey - { - fatalError() - } - - func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { - - fatalError() - } - - func superEncoder() -> Encoder { - fatalError() - } - - func superEncoder(forKey key: Key) -> Encoder { - fatalError() - } - } - - - struct _ValueEncoder: SingleValueEncodingContainer { - var codingPath: [CodingKey] { - [] - } - let context: _Context - - func encodeNil() throws { - self.context.value = .null - } - - func encode(_ value: T) throws where T : Encodable { - self.context.value = try PostgresDataEncoder().encode(value) - } - } - - struct _Wrapper: Encodable { - let encodable: Encodable - init(_ encodable: Encodable) { - self.encodable = encodable - } - func encode(to encoder: Encoder) throws { - try self.encodable.encode(to: encoder) - } - } -} diff --git a/Sources/PostgresKit/PostgresDataTranslation.swift b/Sources/PostgresKit/PostgresDataTranslation.swift new file mode 100644 index 00000000..3e04d790 --- /dev/null +++ b/Sources/PostgresKit/PostgresDataTranslation.swift @@ -0,0 +1,444 @@ +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. +fileprivate 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) } +} + +private extension PostgresCell { + var codingKey: any CodingKey { + PostgresKit.SomeCodingKey(stringValue: !self.columnName.isEmpty ? "\(self.columnName) (\(self.columnIndex))" : "\(self.columnIndex)") + } +} + +/// Sidestep problems with URL coding behavior by making it conform directly to Postgres coding. +extension Foundation.URL: PostgresNIO.PostgresNonThrowingEncodable, PostgresNIO.PostgresDecodable { + public static var psqlType: PostgresDataType { + String.psqlType + } + + public static var psqlFormat: PostgresFormat { + String.psqlFormat + } + + @inlinable + public func encode( + into byteBuffer: inout ByteBuffer, + context: PostgresEncodingContext + ) { + self.absoluteString.encode(into: &byteBuffer, context: context) + } + + @inlinable + public init( + from buffer: inout ByteBuffer, + type: PostgresDataType, + format: PostgresFormat, + context: PostgresDecodingContext + ) throws { + let string = try String(from: &buffer, type: type, format: format, context: context) + + if let url = URL(string: string) { + self = url + } + // Also support the broken encoding we were emitting for awhile there. + else if string.hasPrefix("\""), string.hasSuffix("\""), let url = URL(string: String(string.dropFirst().dropLast())) { + self = url + } else { + throw PostgresDecodingError.Code.failure + } + } +} + +struct PostgresDataTranslation { + /// This typealias serves to limit the deprecation noise caused by ``PostgresDataConvertible`` to a single + /// warning, down from what would otherwise be a minimum of two. It has no other purpose. + fileprivate typealias PostgresLegacyDataConvertible = PostgresDataConvertible + + static func decode( + _: T.Type = T.self, + from cell: PostgresCell, + in context: PostgresDecodingContext, + file: String = #fileID, line: Int = #line + ) throws -> T { + try self.decode( + codingPath: [cell.codingKey], + userInfo: [:], + T.self, + from: cell, + in: context, + file: file, line: line + ) + } + + fileprivate static func decode( + codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], + _: T.Type = T.self, + from cell: PostgresCell, + in context: PostgresDecodingContext, + file: String, line: Int + ) throws -> T { + /// Preferred modern fast-path: Direct conformance to ``PostgresDecodable``, let the cell decode. + if let fastPathType = T.self as? any PostgresDecodable.Type { + let cellToDecode: PostgresCell + + if cell.dataType.isUserDefined && (T.self is String.Type || T.self is String?.Type) { + /// Workaround for Fluent's enum "support": + /// + /// If we're trying to decode a string and the real cell's data type is in the user-defined range, + /// assume we're dealing with a Fluent enum and pretend that the cell has a string data type instead. + cellToDecode = .init( + bytes: cell.bytes, + dataType: .name, + format: cell.format, + columnName: cell.columnName, + columnIndex: cell.columnIndex + ) + } else if cell.format == .binary && [.char, .varchar, .text].contains(cell.dataType) && T.self is Decimal.Type { + /// Workaround for Fluent's assumption that Decimal strings work: + /// + /// If the cell's data type is a binary-format string-like, and we're trying to decode a `Decimal`, + /// reinterpret the cell as a text-format numeric value so that the `PostgresCodable` conformance of + /// `Decimal` will work as written. + cellToDecode = .init( + bytes: cell.bytes, + dataType: .numeric, + format: .text, + columnName: cell.columnName, + columnIndex: cell.columnIndex + ) + } else if cell.format == .binary && cell.dataType == .numeric && T.self is Double.Type { + /// Workaround for Fluent's expectation that Postgres's `numeric/decimal` can be decoded as `Double`: + /// + /// If the cell is a binary-format numeric value and we're trying to decode a `Double`, use + /// `PostgresData` to manually interpret the cell as a `PostgresNumeric` and use that result to convert + /// to `Double`. + guard let value = PostgresData(type: cell.dataType, formatCode: cell.format, value: cell.bytes).numeric?.double else { + throw DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: "Invalid numeric value encoding")) + } + return value as! T + } else { + /// No workarounds needed, use the cell as-is. + cellToDecode = cell + } + return try cellToDecode.decode(fastPathType, context: context, file: file, line: line) as! T + + /// Legacy "fast"-path: Direct conformance to ``PostgresDataConvertible``; use is deprecated. + } else if let legacyPathType = T.self as? any PostgresLegacyDataConvertible.Type { + let legacyData = PostgresData(type: cell.dataType, typeModifier: nil, formatCode: cell.format, value: cell.bytes) + + guard let result = legacyPathType.init(postgresData: legacyData) else { + throw DecodingError.typeMismatch(T.self, .init(codingPath: codingPath, + debugDescription: "Couldn't get '\(T.self)' from PSQL type \(cell.dataType): \(legacyData as Any)" + )) + } + return result as! T + } + + /// Slow path: Descend through the ``Decodable`` machinery until we fail or find something we can convert. + else { + do { + return try T.init(from: ArrayAwareBoxUwrappingDecoder( + codingPath: codingPath, + userInfo: userInfo, + cell: cell, + context: context, + file: file, line: line + )) + } catch DecodingError.dataCorrupted { + /// 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")" + )) + } + if cell.dataType == .jsonb, cell.format == .binary, let buffer = cell.bytes { + // TODO: Un-hardcode this magic knowledge of the JSONB encoding + 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()) + } + } catch let error as PostgresDecodingError { + /// We effectively transform PostgresDecodingErrors into plain DecodingErrors here, mostly so the full + /// coding path, which gives us the original type(s) involved, is preserved. + let context = DecodingError.Context( + codingPath: codingPath, + debugDescription: "\(String(reflecting: error))", + underlyingError: error + ) + + switch error.code { + case .typeMismatch: throw DecodingError.typeMismatch(T.self, context) + case .missingData: throw DecodingError.valueNotFound(T.self, context) + default: throw DecodingError.dataCorrupted(context) + } + } + } + } + + static func encode( + value: T, + in context: PostgresEncodingContext, + to bindings: inout PostgresBindings, + file: String = #fileID, line: Int = #line + ) throws { + /// Preferred modern fast-path: Direct conformance to ``PostgresEncodable`` + if let fastPathValue = value as? any PostgresEncodable { + try bindings.append(fastPathValue, context: context) + } + /// 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)'")) + } + bindings.append(legacyData) + } + /// Slow path: Descend through the ``Encodable`` machinery until we fail or find something we can convert. + else { + try bindings.append(self.encode(codingPath: [], userInfo: [:], value: value, in: context, file: file, line: line)) + } + } + + internal /*fileprivate*/ static func encode( + codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], + value: T, + in context: PostgresEncodingContext, + file: String, line: Int + ) throws -> PostgresData { + // TODO: Avoid repeating the conformance checks here, or at the very least only repeat them after a second level of nesting... + if let fastPathValue = value as? any PostgresEncodable { + var buffer = ByteBuffer() + try fastPathValue.encode(into: &buffer, context: context) + 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)'")) + } + return legacyData + } + // TODO: Make all of this work without relying on the legacy PostgresData array machinery + do { + let encoder = ArrayAwareBoxWrappingPostgresEncoder(codingPath: codingPath, userInfo: userInfo, context: context, file: file, line: line) + try value.encode(to: encoder) + switch encoder.value { + 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 \(type(of: value)) was encoded as a heterogenous array; this is unsupported.") + return PostgresData(array: ref.contents, elementType: elementType) + } + } catch is ArrayAwareBoxWrappingPostgresEncoder.FallbackSentinel { + /// Glacial path: Fall back to encoding directly to JSON. + return try PostgresData(jsonb: context.jsonEncoder.encode(value)) + } + } +} + +private final class ArrayAwareBoxUwrappingDecoder: Decoder, SingleValueDecodingContainer { + let codingPath: [any CodingKey] + let userInfo: [CodingUserInfoKey: Any] + let cell: PostgresCell + let context: PostgresDecodingContext + let file: String, line: Int + + init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], cell: PostgresCell, context: PostgresDecodingContext, file: String, line: Int) { + self.codingPath = codingPath + self.cell = cell + self.context = context + self.file = file + self.line = line + self.userInfo = userInfo + } + + struct ArrayContainer: UnkeyedDecodingContainer { + let data: [PostgresData] + let decoder: ArrayAwareBoxUwrappingDecoder + + var codingPath: [any CodingKey] { + self.decoder.codingPath + } + + var count: Int? { + self.data.count + } + + var isAtEnd: Bool { + self.currentIndex >= self.data.count + } + + var currentIndex = 0 + + mutating func decodeNil() throws -> Bool { + guard self.data[self.currentIndex].value == nil else { return false } + self.currentIndex += 1 + return true + } + + mutating func decode(_: T.Type) throws -> T { + // TODO: Don't fake a cell. + let data = self.data[self.currentIndex], cell = PostgresCell( + bytes: data.value, dataType: data.type, format: data.formatCode, + columnName: self.decoder.cell.columnName, columnIndex: self.decoder.cell.columnIndex + ) + + let result = try PostgresDataTranslation.decode( + codingPath: self.codingPath + [PostgresKit.SomeCodingKey(intValue: self.currentIndex)], + userInfo: self.decoder.userInfo, + T.self, from: cell, in: self.decoder.context, + file: self.decoder.file, line: self.decoder.line + ) + self.currentIndex += 1 + return result + } + + private var rejectNestingError: DecodingError { .dataCorruptedError(in: self, debugDescription: "Data nesting is not supported") } + mutating func nestedContainer(keyedBy: K.Type) throws -> KeyedDecodingContainer { throw self.rejectNestingError } + mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { throw self.rejectNestingError } + mutating func superDecoder() throws -> any Decoder { throw self.rejectNestingError } + } + + func container(keyedBy: Key.Type) throws -> KeyedDecodingContainer { + throw DecodingError.dataCorrupted(.init(codingPath: self.codingPath, debugDescription: "Dictionary containers must be JSON-encoded")) + } + + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + // TODO: Find a better way to figure out arrays + guard let array = PostgresData(type: self.cell.dataType, typeModifier: nil, formatCode: self.cell.format, value: self.cell.bytes).array else { + throw DecodingError.dataCorrupted(.init(codingPath: self.codingPath, debugDescription: "Non-natively typed arrays must be JSON-encoded")) + } + return ArrayContainer(data: array, decoder: self) + } + + func singleValueContainer() throws -> any SingleValueDecodingContainer { self } + + func decodeNil() -> Bool { self.cell.bytes == nil } + + func decode(_: T.Type) throws -> T { + try PostgresDataTranslation.decode( + codingPath: self.codingPath + [PostgresKit.SomeCodingKey(stringValue: "(Unwrapping(\(T0.self)))")], userInfo: self.userInfo, + T.self, from: self.cell, in: self.context, file: self.file, line: self.line + ) + } +} + +private final class ArrayAwareBoxWrappingPostgresEncoder: Encoder, SingleValueEncodingContainer { + enum Value { + final class ArrayRef { var contents: [T] = [] } + + case invalid + case indexed(ArrayRef) + case scalar(PostgresData) + + var isValid: Bool { if case .invalid = self { return false }; return true } + + mutating func store(scalar: PostgresData) { + if case .invalid = self { self = .scalar(scalar) } // no existing value, store the incoming + else { preconditionFailure("Invalid request for multiple containers from the same encoder.") } + } + + mutating func requestIndexed() { + switch self { + case .scalar(_): preconditionFailure("Invalid request for both single-value and unkeyed containers from the same encoder.") + case .invalid: self = .indexed(.init()) // no existing value, make new array + case .indexed(_): break // existing array, adopt it for appending (support for superEncoder()) + } + } + + var indexedCount: Int { + if case .indexed(let ref) = self { return ref.contents.count } + else { preconditionFailure("Internal error in encoder (requested indexed count from non-indexed state)") } + } + + 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)") } + } + } + + var codingPath: [any CodingKey] + let userInfo: [CodingUserInfoKey: Any] + let context: PostgresEncodingContext + let file: String, line: Int + var value: Value + + init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], context: PostgresEncodingContext, file: String, line: Int, value: Value = .invalid) { + self.codingPath = codingPath + self.userInfo = userInfo + self.context = context + self.file = file + self.line = line + self.value = value + } + + func container(keyedBy: K.Type) -> KeyedEncodingContainer { + precondition(!self.value.isValid, "Requested multiple containers from the same encoder.") + return .init(FailureEncoder()) + } + + func unkeyedContainer() -> any UnkeyedEncodingContainer { + self.value.requestIndexed() + return ArrayContainer(encoder: self) + } + + func singleValueContainer() -> any SingleValueEncodingContainer { + precondition(!self.value.isValid, "Requested multiple containers from the same encoder.") + return self + } + + struct ArrayContainer: UnkeyedEncodingContainer { + 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 encode(_ value: T) throws { + self.encoder.value.store(indexedScalar: try PostgresDataTranslation.encode( + codingPath: self.codingPath + [PostgresKit.SomeCodingKey(intValue: self.count)], userInfo: self.encoder.userInfo, + value: value, in: self.encoder.context, + file: self.encoder.file, line: self.encoder.line + )) + } + mutating func nestedContainer(keyedBy: K.Type) -> KeyedEncodingContainer { self.superEncoder().container(keyedBy: K.self) } + mutating func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { self.superEncoder().unkeyedContainer() } + mutating func superEncoder() -> any Encoder { ArrayAwareBoxWrappingPostgresEncoder( + codingPath: self.codingPath + [PostgresKit.SomeCodingKey(intValue: self.count)], userInfo: self.encoder.userInfo, + context: self.encoder.context, + file: self.encoder.file, line: self.encoder.line, + value: self.encoder.value + ) } // NOT the same as self.encoder + } + + func encodeNil() throws { self.value.store(scalar: .null) } + func encode(_ value: T) throws { + self.value.store(scalar: try PostgresDataTranslation.encode( + codingPath: self.codingPath, userInfo: self.userInfo, value: value, in: self.context, file: self.file, line: self.line + )) + } + + struct FallbackSentinel: Error {} + + /// This is a workaround for the inability of encoders to throw errors in various places. It's still better than fatalError()ing. + struct FailureEncoder: Encoder, KeyedEncodingContainerProtocol, UnkeyedEncodingContainer, SingleValueEncodingContainer { + let codingPath = [any CodingKey](), userInfo = [CodingUserInfoKey: Any](), count = 0 + init() {}; init() where K == PostgresKit.SomeCodingKey {} + func encodeNil() throws { throw FallbackSentinel() } + func encodeNil(forKey: K) throws { throw FallbackSentinel() } + func encode(_: T) throws { throw FallbackSentinel() } + func encode(_: T, forKey: K) throws { throw FallbackSentinel() } + func nestedContainer(keyedBy: N.Type) -> KeyedEncodingContainer { .init(FailureEncoder()) } + func nestedContainer(keyedBy: N.Type, forKey: K) -> KeyedEncodingContainer { .init(FailureEncoder()) } + func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { self } + func nestedUnkeyedContainer(forKey: K) -> any UnkeyedEncodingContainer { self } + func superEncoder() -> any Encoder { self } + func superEncoder(forKey: K) -> any Encoder { self } + func container(keyedBy: N.Type) -> KeyedEncodingContainer { .init(FailureEncoder()) } + func unkeyedContainer() -> any UnkeyedEncodingContainer { self } + func singleValueContainer() -> any SingleValueEncodingContainer { self } + } +} diff --git a/Sources/PostgresKit/PostgresDatabase+SQL.swift b/Sources/PostgresKit/PostgresDatabase+SQL.swift new file mode 100644 index 00000000..95d38c72 --- /dev/null +++ b/Sources/PostgresKit/PostgresDatabase+SQL.swift @@ -0,0 +1,109 @@ +import Logging +import PostgresNIO +import SQLKit + +extension PostgresDatabase { + @inlinable + public func sql(queryLogLevel: Logger.Level? = .debug) -> some SQLDatabase { + self.sql(encodingContext: .default, decodingContext: .default, queryLogLevel: queryLogLevel) + } + + public func sql( + encodingContext: PostgresEncodingContext, + decodingContext: PostgresDecodingContext, + queryLogLevel: Logger.Level? = .debug + ) -> some SQLDatabase { + PostgresSQLDatabase(database: self, encodingContext: encodingContext, decodingContext: decodingContext, queryLogLevel: queryLogLevel) + } +} + +private struct PostgresSQLDatabase { + let database: PDatabase + let encodingContext: PostgresEncodingContext + let decodingContext: PostgresDecodingContext + let queryLogLevel: Logger.Level? +} + +extension PostgresSQLDatabase: SQLDatabase, PostgresDatabase { + var logger: Logger { + self.database.logger + } + + var eventLoop: any EventLoop { + self.database.eventLoop + } + + var version: (any SQLDatabaseReportedVersion)? { + nil // PSQL doesn't send version in wire protocol, must use SQL to read it + } + + var dialect: any SQLDialect { + PostgresDialect() + } + + func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> ()) -> EventLoopFuture { + let (sql, binds) = self.serialize(query) + + if let queryLogLevel = self.queryLogLevel { + self.logger.log(level: queryLogLevel, "Executing query", metadata: ["sql": .string(sql), "binds": .array(binds.map { .string("\($0)") })]) + } + return self.eventLoop.makeCompletedFuture { + var bindings = PostgresBindings(capacity: binds.count) + for bind in binds { + try PostgresDataTranslation.encode(value: bind, in: self.encodingContext, to: &bindings) + } + return bindings + }.flatMap { bindings in self.database.withConnection { + $0.query( + .init(unsafeSQL: sql, binds: bindings), + logger: $0.logger, + { onRow($0.sql(decodingContext: self.decodingContext)) } + ) + } }.map { _ in } + } + + func execute( + sql query: any SQLExpression, + _ onRow: @escaping @Sendable (any SQLRow) -> () + ) async throws { + let (sql, binds) = self.serialize(query) + + if let queryLogLevel = self.queryLogLevel { + self.logger.log(level: queryLogLevel, "Executing query", metadata: ["sql": .string(sql), "binds": .array(binds.map { .string("\($0)") })]) + } + + var bindings = PostgresBindings(capacity: binds.count) + for bind in binds { + try PostgresDataTranslation.encode(value: bind, in: self.encodingContext, to: &bindings) + } + + _ = try await self.database.withConnection { + $0.query( + .init(unsafeSQL: sql, binds: bindings), + logger: $0.logger, + { onRow($0.sql(decodingContext: self.decodingContext)) } + ) + }.get() + } + + + func send(_ request: any PostgresRequest, logger: Logger) -> EventLoopFuture { + self.database.send(request, logger: logger) + } + + func withConnection(_ closure: @escaping (PostgresConnection) -> EventLoopFuture) -> EventLoopFuture { + self.database.withConnection(closure) + } + + func withSession(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> R) async throws -> R { + try await self.withConnection { c in + c.eventLoop.makeFutureWithTask { + try await closure(c.sql( + encodingContext: self.encodingContext, + decodingContext: self.decodingContext, + queryLogLevel: self.queryLogLevel + )) + } + }.get() + } +} diff --git a/Sources/PostgresKit/PostgresDialect.swift b/Sources/PostgresKit/PostgresDialect.swift index efa73792..8502ed6f 100644 --- a/Sources/PostgresKit/PostgresDialect.swift +++ b/Sources/PostgresKit/PostgresDialect.swift @@ -1,36 +1,45 @@ +import SQLKit + public struct PostgresDialect: SQLDialect { - public init() { } + public init() {} public var name: String { "postgresql" } - public var identifierQuote: SQLExpression { - return SQLRaw("\"") + public var identifierQuote: any SQLExpression { + SQLRaw(#"""#) } - public func bindPlaceholder(at position: Int) -> SQLExpression { - return SQLRaw("$" + position.description) + public var literalStringQuote: any SQLExpression { + SQLRaw("'") } - public func literalBoolean(_ value: Bool) -> SQLExpression { - switch value { - case false: - return SQLRaw("false") - case true: - return SQLRaw("true") - } + public var supportsAutoIncrement: Bool { + true } - public var autoIncrementClause: SQLExpression { - return SQLRaw("GENERATED BY DEFAULT AS IDENTITY") + public var autoIncrementClause: any SQLExpression { + SQLRaw("GENERATED BY DEFAULT AS IDENTITY") } - public var supportsAutoIncrement: Bool { - true + public var autoIncrementFunction: (any SQLExpression)? { + nil } - public var supportsReturning: Bool { + public func bindPlaceholder(at position: Int) -> any SQLExpression { + SQLRaw("$\(position)") + } + + public func literalBoolean(_ value: Bool) -> any SQLExpression { + SQLRaw("\(value)") + } + + public var literalDefault: any SQLExpression { + SQLRaw("DEFAULT") + } + + public var supportsIfExists: Bool { true } @@ -38,8 +47,16 @@ public struct PostgresDialect: SQLDialect { .typeName } + public var supportsDropBehavior: Bool { + true + } + + public var supportsReturning: Bool { + true + } + public var triggerSyntax: SQLTriggerSyntax { - return .init( + .init( create: [.supportsForEach, .postgreSQLChecks, .supportsCondition, .conditionRequiresParentheses, .supportsConstraints], drop: [.supportsCascade, .supportsTableName] ) @@ -51,20 +68,51 @@ public struct PostgresDialect: SQLDialect { alterColumnDefinitionTypeKeyword: SQLRaw("SET DATA TYPE") ) } - + + public func customDataType(for dataType: SQLDataType) -> (any SQLExpression)? { + if case let .custom(expr) = dataType, (expr as? SQLRaw)?.sql == "TIMESTAMP" { + return SQLRaw("TIMESTAMPTZ") + } else if case .blob = dataType { + return SQLRaw("BYTEA") + } else { + return nil + } + } + public var upsertSyntax: SQLUpsertSyntax { .standard } - + public var unionFeatures: SQLUnionFeatures { - [.union, .unionAll, .intersect, .intersectAll, .except, .exceptAll, .explicitDistinct, .parenthesizedSubqueries] + [ + .union, .unionAll, + .intersect, .intersectAll, + .except, .exceptAll, + .explicitDistinct, + .parenthesizedSubqueries + ] } - public var sharedSelectLockExpression: SQLExpression? { + public var sharedSelectLockExpression: (any SQLExpression)? { SQLRaw("FOR SHARE") } - public var exclusiveSelectLockExpression: SQLExpression? { + public var exclusiveSelectLockExpression: (any SQLExpression)? { SQLRaw("FOR UPDATE") } + + public func nestedSubpathExpression(in column: any SQLExpression, for path: [String]) -> (any SQLExpression)? { + guard !path.isEmpty else { return nil } + + let descender = SQLList( + [column] + path.dropLast().map(SQLLiteral.string(_:)), + separator: SQLRaw("->") + ) + let accessor = SQLList( + [descender, SQLLiteral.string(path.last!)], + separator: SQLRaw("->>") + ) + + return SQLGroupExpression(accessor) + } } diff --git a/Sources/PostgresKit/PostgresRow+SQL.swift b/Sources/PostgresKit/PostgresRow+SQL.swift index 6cdd47a5..ab945ac4 100644 --- a/Sources/PostgresKit/PostgresRow+SQL.swift +++ b/Sources/PostgresKit/PostgresRow+SQL.swift @@ -1,24 +1,28 @@ +import Foundation +import PostgresNIO +import SQLKit + extension PostgresRow { - public func sql(decoder: PostgresDataDecoder = .init()) -> SQLRow { - return _PostgresSQLRow(row: self.makeRandomAccess(), decoder: decoder) + @inlinable + public func sql() -> some SQLRow { + self.sql(decodingContext: .default) } -} -// MARK: Private + public func sql(decodingContext: PostgresDecodingContext) -> some SQLRow { + _PostgresSQLRow(randomAccessView: self.makeRandomAccess(), decodingContext: decodingContext) + } +} -private struct _PostgresSQLRow: SQLRow { +private struct _PostgresSQLRow { let randomAccessView: PostgresRandomAccessRow - let decoder: PostgresDataDecoder + let decodingContext: PostgresDecodingContext enum _Error: Error { case missingColumn(String) } - - init(row: PostgresRandomAccessRow, decoder: PostgresDataDecoder) { - self.randomAccessView = row - self.decoder = decoder - } +} +extension _PostgresSQLRow: SQLRow { var allColumns: [String] { self.randomAccessView.map { $0.columnName } } @@ -31,10 +35,11 @@ private struct _PostgresSQLRow: SQLRow { !self.randomAccessView.contains(column) || self.randomAccessView[column].bytes == nil } - func decode(column: String, as type: D.Type) throws -> D where D : Decodable { + func decode(column: String, as type: T.Type) throws -> T { guard self.randomAccessView.contains(column) else { throw _Error.missingColumn(column) } - return try self.decoder.decode(D.self, from: self.randomAccessView[data: column]) + + return try PostgresDataTranslation.decode(T.self, from: self.randomAccessView[column], in: self.decodingContext) } } diff --git a/Sources/PostgresKit/SQLPostgresConfiguration.swift b/Sources/PostgresKit/SQLPostgresConfiguration.swift new file mode 100644 index 00000000..e9d9def5 --- /dev/null +++ b/Sources/PostgresKit/SQLPostgresConfiguration.swift @@ -0,0 +1,163 @@ +import Foundation +import NIOCore +import NIOSSL +import PostgresNIO + +/// Provides configuration paramters for establishing PostgreSQL database connections. +public struct SQLPostgresConfiguration: Sendable { + /// IANA-assigned port number for PostgreSQL + /// `UInt16(getservbyname("postgresql", "tcp").pointee.s_port).byteSwapped` + public static var ianaPortNumber: Int { 5432 } + + // See `PostgresNIO.PostgresConnection.Configuration`. + public var coreConfiguration: PostgresConnection.Configuration + + /// Optional `search_path` to set on new connections. + public var searchPath: [String]? + + /// Create a ``SQLPostgresConfiguration`` from a string containing a properly formatted URL. + /// + /// See ``init(url:)`` for details on the allowed format for connection URLs. + public init(url: String) throws { + guard let url = URL(string: url) else { + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLStringErrorKey: url]) + } + try self.init(url: url) + } + + /// Create a ``SQLPostgresConfiguration`` from a properly formatted URL. + /// + /// The supported URL formats are: + /// + /// postgres://username:password@hostname:port/database?tlsmode=mode + /// postgres+tcp://username:password@hostname:port/database?tlsmode=mode + /// postgres+uds://username:password@localhost/path?tlsmode=mode#database + /// + /// The `postgres+tcp` scheme requests a connection over TCP. The `postgres` scheme is an alias + /// for `postgres+tcp`. Only the `hostname` and `username` components are required. + /// + /// The `postgres+uds` scheme requests a connection via a UNIX domain socket. The `username` and + /// `path` components are required. The authority must always be empty or `localhost`, and may not + /// specify a port. + /// + /// The allowed `mode` values for `tlsmode` are: + /// + /// Value|Behavior + /// -|- + /// `disable`|Don't use TLS, even if the server supports it. + /// `prefer`|Use TLS if possible. + /// `require`|Enforce TLS support. + /// + /// If no `tlsmode` is specified, the default mode is `prefer` for TCP connections, or `disable` + /// for UDS connections. If more than one mode is specified, the last one wins. Whenever a TLS + /// connection is made, full certificate verification (both chain of trust and hostname match) + /// is always enforced, regardless of the mode used. + /// + /// For compatibility with `libpq` and previous versions of this package, any of "`sslmode`", + /// "`tls`", or "`ssl`" may be used instead of "`tlsmode`". There are also various aliases for + /// each of the TLS mode names, as follows: + /// + /// - "`disable`": "`false`" + /// - "`prefer`": "`allow`", "`true`" + /// - "`require`": "`verify-ca`", "`verify-full`" + /// + /// The aliases always have the same semantics as the "canonical" modes, despite any differences + /// suggested by their names. + /// + /// Also for compatibility, the URL scheme may also be `postgresql` or `postgresql+uds`. + /// + /// > Note: It is possible to emulate `libpq`'s definitions for `prefer` (TLS if available with + /// > no certificate verification), `require` (TLS enforced, but also without certificate + /// > verification) and `verify-ca` (TLS enforced with no hostname verification) by manually + /// > specifying the TLS configuration instead of using a URL. It is not possible, by design, to + /// > emulate `libpq`'s `allow` mode (TLS only if there is no alternative). It is _strongly_ + /// > recommended for both security and privacy reasons to always leave full certificate + /// > verification enabled whenever possible. See NIOSSL's [`TLSConfiguration`](tlsconfig) for + /// > additional information and recommendations. + /// + /// [tlsconfig]: + /// https://swiftpackageindex.com/apple/swift-nio-ssl/main/documentation/niossl/tlsconfiguration + public init(url: URL) throws { + guard let comp = URLComponents(url: url, resolvingAgainstBaseURL: true), let username = comp.user else { + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + } + + func decideTLSConfig(from queryItems: [URLQueryItem], defaultMode: String) throws -> PostgresConnection.Configuration.TLS { + switch queryItems.last(where: { ["tlsmode", "sslmode", "ssl", "tls"].contains($0.name.lowercased()) })?.value ?? defaultMode { + case "verify-full", "verify-ca", "require": + return try .require(.init(configuration: .makeClientConfiguration())) + case "prefer", "allow", "true": + return try .prefer(.init(configuration: .makeClientConfiguration())) + case "disable", "false": + return .disable + default: + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + } + } + + switch comp.scheme { + case "postgres", "postgres+tcp", "postgresql", "postgresql+tcp": + guard let hostname = comp.host, !hostname.isEmpty else { + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + } + self.init( + hostname: hostname, port: comp.port ?? Self.ianaPortNumber, + username: username, password: comp.password, + database: url.lastPathComponent.isEmpty ? nil : url.lastPathComponent, + tls: try decideTLSConfig(from: comp.queryItems ?? [], defaultMode: "prefer") + ) + case "postgres+uds", "postgresql+uds": + guard (comp.host?.isEmpty ?? true || comp.host == "localhost"), comp.port == nil, !comp.path.isEmpty, comp.path != "/" else { + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + } + var coreConfig = PostgresConnection.Configuration(unixSocketPath: comp.path, username: username, password: comp.password, database: comp.fragment) + coreConfig.tls = try decideTLSConfig(from: comp.queryItems ?? [], defaultMode: "disable") + self.init(coreConfiguration: coreConfig) + default: + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + } + } + + /// Create a ``SQLPostgresConfiguration`` for connecting to a server with a hostname and optional port. + /// + /// This specifies a TCP connection. If you're unsure which kind of connection you want, you almost + /// definitely want this one. + public init( + hostname: String, port: Int = Self.ianaPortNumber, + username: String, password: String? = nil, + database: String? = nil, + tls: PostgresConnection.Configuration.TLS + ) { + self.init(coreConfiguration: .init(host: hostname, port: port, username: username, password: password, database: database, tls: tls)) + } + + /// Create a ``SQLPostgresConfiguration`` for connecting to a server through a UNIX domain socket. + public init( + unixDomainSocketPath: String, + username: String, password: String? = nil, + database: String? = nil + ) { + self.init(coreConfiguration: .init(unixSocketPath: unixDomainSocketPath, username: username, password: password, database: database)) + } + + /// Create a ``SQLPostgresConfiguration`` for establishing a connection to a server over a + /// preestablished `NIOCore/Channel`. + /// + /// This is provided for calling code which wants to manage the underlying connection transport on its + /// own, such as when tunneling a connection through SSH. + public init( + establishedChannel: any Channel, + username: String, password: String? = nil, + database: String? = nil + ) { + self.init(coreConfiguration: .init(establishedChannel: establishedChannel, username: username, password: password, database: database)) + } + + public init( + coreConfiguration: PostgresConnection.Configuration, + searchPath: [String]? = nil + ) { + self.coreConfiguration = coreConfiguration + self.searchPath = searchPath + } +} diff --git a/Tests/PostgresKitTests/PostgresKitTests.swift b/Tests/PostgresKitTests/PostgresKitTests.swift index 5fbae1ad..72536126 100644 --- a/Tests/PostgresKitTests/PostgresKitTests.swift +++ b/Tests/PostgresKitTests/PostgresKitTests.swift @@ -1,22 +1,34 @@ -import PostgresKit +import Foundation +import Logging +import NIOCore +import PostgresNIO import SQLKitBenchmark import XCTest -import Logging +@testable import PostgresKit -class PostgresKitTests: XCTestCase { - func testSQLKitBenchmark() throws { - let conn = try PostgresConnection.test(on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let benchmark = SQLBenchmarker(on: conn.sql()) - try benchmark.run() +final class PostgresKitTests: XCTestCase { + func testSQLKitBenchmark() async throws { + let conn = try await PostgresConnection.test(on: self.eventLoop).get() + do { + let benchmark = SQLBenchmarker(on: conn.sql()) + + try await benchmark.runAllTests() + } catch { + try? await conn.close() + XCTFail("Caught error: \(String(reflecting: error))") + throw error + } + try await conn.close() } - + + // Disable for now, test is of questionable utility + /* func testPerformance() throws { - let db = PostgresConnectionSource(configuration: .test) + let db = PostgresConnectionSource(sqlConfiguration: .test) let pool = EventLoopGroupConnectionPool( source: db, maxConnectionsPerEventLoop: 2, - on: self.eventLoopGroup + on: MultiThreadedEventLoopGroup.singleton ) defer { pool.shutdown() } // Postgres seems to take much longer on initial connections when using SCRAM-SHA-256 auth, @@ -24,17 +36,18 @@ class PostgresKitTests: XCTestCase { // Spin the pool a bit before running the measurement to warm it up. for _ in 1...25 { _ = try pool.withConnection { conn in - return conn.query("SELECT 1;") + conn.query("SELECT 1") }.wait() } self.measure { for _ in 1...100 { _ = try! pool.withConnection { conn in - return conn.query("SELECT 1;") + conn.query("SELECT 1") }.wait() } } } + */ func testLeak() throws { struct Foo: Codable { @@ -53,37 +66,41 @@ class PostgresKitTests: XCTestCase { let db = conn.sql() - try db.raw("DROP TABLE IF EXISTS foos").run().wait() - try db.raw(""" - CREATE TABLE foos ( - id TEXT PRIMARY KEY, - description TEXT, - latitude DOUBLE PRECISION, - longitude DOUBLE PRECISION, - created_by TEXT, - created_at TIMESTAMPTZ, - modified_by TEXT, - modified_at TIMESTAMPTZ - ) - """).run().wait() - defer { - try? db.raw("DROP TABLE IF EXISTS foos").run().wait() - } - - for i in 0..<5_000 { - let zipcode = Foo( - id: UUID().uuidString, - description: "test \(i)", - latitude: Double.random(in: 0...100), - longitude: Double.random(in: 0...100), - created_by: "test", - created_at: Date(), - modified_by: "test", - modified_at: Date() + do { + try db.raw("DROP TABLE IF EXISTS \(ident: "foos")").run().wait() + try db.raw(""" + CREATE TABLE \(ident: "foos") ( + \(ident: "id") TEXT PRIMARY KEY, + \(ident: "description") TEXT, + \(ident: "latitude") DOUBLE PRECISION, + \(ident: "longitude") DOUBLE PRECISION, + \(ident: "created_by") TEXT, + \(ident: "created_at") TIMESTAMPTZ, + \(ident: "modified_by") TEXT, + \(ident: "modified_at") TIMESTAMPTZ ) - try db.insert(into: "foos") - .model(zipcode) - .run().wait() + """).run().wait() + defer { + try? db.raw("DROP TABLE IF EXISTS \(ident: "foos")").run().wait() + } + + for i in 0..<5_000 { + let zipcode = Foo( + id: UUID().uuidString, + description: "test \(i)", + latitude: Double.random(in: 0...100), + longitude: Double.random(in: 0...100), + created_by: "test", + created_at: Date(), + modified_by: "test", + modified_at: Date() + ) + try db.insert(into: "foos") + .model(zipcode) + .run().wait() + } + } catch { + XCTFail("Caught error: \(String(reflecting: error))") } } @@ -95,24 +112,15 @@ class PostgresKitTests: XCTestCase { var bar: Int } let foos: [Foo] = [.init(bar: 1), .init(bar: 2)] - try conn.sql().raw("SELECT \(bind: foos)::JSONB[] as foos") + try conn.sql().raw("SELECT \(bind: foos)::JSONB[] as \(ident: "foos")") .run().wait() } - func testDictionaryEncoding() throws { - let conn = try PostgresConnection.test(on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - struct Foo: Codable { - var bar: Int - } - } - func testDecodeModelWithNil() throws { let conn = try PostgresConnection.test(on: self.eventLoop).wait() defer { try! conn.close().wait() } - let rows = try conn.query("SELECT 'foo'::text as foo, null as bar, 'baz'::text as baz").wait() + let rows = try conn.sql().raw("SELECT \(literal: "foo")::text as \(ident: "foo"), \(SQLLiteral.null) as \(ident: "bar"), \(literal: "baz")::text as \(ident: "baz")").all().wait() let row = rows[0] struct Test: Codable { @@ -121,58 +129,134 @@ class PostgresKitTests: XCTestCase { var baz: String? } - let test = try row.sql().decode(model: Test.self) + let test = try row.decode(model: Test.self) XCTAssertEqual(test.foo, "foo") XCTAssertEqual(test.bar, nil) XCTAssertEqual(test.baz, "baz") } func testEventLoopGroupSQL() throws { - var configuration = PostgresConfiguration.test + var configuration = SQLPostgresConfiguration.test configuration.searchPath = ["foo", "bar", "baz"] - let source = PostgresConnectionSource(configuration: configuration) - let pool = EventLoopGroupConnectionPool(source: source, on: self.eventLoopGroup) + let source = PostgresConnectionSource(sqlConfiguration: configuration) + let pool = EventLoopGroupConnectionPool(source: source, on: MultiThreadedEventLoopGroup.singleton) defer { pool.shutdown() } let db = pool.database(logger: .init(label: "test")).sql() - let rows = try db.raw("SELECT version();").all().wait() - print(rows) + let rows = try db.raw("SELECT version()").all().wait() + XCTAssertEqual(rows.count, 1) } - func testArrayEncoding_json() throws { + func testIntegerArrayEncoding() throws { let connection = try PostgresConnection.test(on: self.eventLoop).wait() defer { try! connection.close().wait() } - _ = try connection.query("DROP TABLE IF EXISTS foo").wait() - _ = try connection.query("CREATE TABLE foo (bar integer[] not null)").wait() + let sql = connection.sql() + _ = try sql.raw("DROP TABLE IF EXISTS \(ident: "foo")").run().wait() + _ = try sql.raw("CREATE TABLE \(ident: "foo") (\(ident: "bar") bigint[] not null)").run().wait() defer { - _ = try! connection.query("DROP TABLE foo").wait() + _ = try! sql.raw("DROP TABLE IF EXISTS \(ident: "foo")").run().wait() } - _ = try connection.query("INSERT INTO foo (bar) VALUES ($1)", [ - PostgresDataEncoder().encode([Bar]()) - ]).wait() - let rows = try connection.query("SELECT * FROM foo").wait() - print(rows) + _ = try sql.raw("INSERT INTO \(ident: "foo") (\(ident: "bar")) VALUES (\(bind: [Bar]()))").run().wait() + let rows = try connection.query("SELECT bar FROM foo", logger: connection.logger).wait() + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.count, 1) + XCTAssertEqual(rows.first?.first?.dataType, Bar.psqlArrayType) + XCTAssertEqual(try rows.first?.first?.decode([Bar].self), [Bar]()) } - - func testEnum() throws { - let connection = try PostgresConnection.test(on: self.eventLoop).wait() - defer { try! connection.close().wait() } - try SQLBenchmarker(on: connection.sql()).testEnum() + + /// Tests dealing with encoding of values whose `encode(to:)` implementation calls one of the `superEncoder()` + /// methods (most notably the implementation of `Codable` for Fluent's `Fields`, which we can't directly test + /// at this layer). + func testValuesThatUseSuperEncoder() throws { + struct UnusualType: Codable { + var prop1: String, prop2: [Bool], prop3: [[Bool]] + + // This is intentionally contrived - Fluent's implementation does Codable this roundabout way as a + // workaround for the interaction of property wrappers with optional properties; it serves no purpose + // here other than to demonstrate that the encoder supports it. + private enum CodingKeys: String, CodingKey { case prop1, prop2, prop3 } + init(prop1: String, prop2: [Bool], prop3: [[Bool]]) { (self.prop1, self.prop2, self.prop3) = (prop1, prop2, prop3) } + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.prop1 = try .init(from: container.superDecoder(forKey: .prop1)) + var acontainer = try container.nestedUnkeyedContainer(forKey: .prop2), ongoing: [Bool] = [] + while !acontainer.isAtEnd { ongoing.append(try Bool.init(from: acontainer.superDecoder())) } + self.prop2 = ongoing + var bcontainer = try container.nestedUnkeyedContainer(forKey: .prop3), bongoing: [[Bool]] = [] + while !bcontainer.isAtEnd { + var ccontainer = try bcontainer.nestedUnkeyedContainer(), congoing: [Bool] = [] + while !ccontainer.isAtEnd { congoing.append(try Bool.init(from: ccontainer.superDecoder())) } + bongoing.append(congoing) + } + self.prop3 = bongoing + } + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try self.prop1.encode(to: container.superEncoder(forKey: .prop1)) + var acontainer = container.nestedUnkeyedContainer(forKey: .prop2) + for val in self.prop2 { try val.encode(to: acontainer.superEncoder()) } + var bcontainer = container.nestedUnkeyedContainer(forKey: .prop3) + for arr in self.prop3 { + var ccontainer = bcontainer.nestedUnkeyedContainer() + for val in arr { try val.encode(to: ccontainer.superEncoder()) } + } + } + } + + let instance = UnusualType(prop1: "hello", prop2: [true, false, false, true], prop3: [[true, true], [false], [true], []]) + let encoded1 = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: instance, in: .default, file: #fileID, line: #line) + let encoded2 = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: [instance, instance], in: .default, file: #fileID, line: #line) + + XCTAssertEqual(encoded1.type, .jsonb) + XCTAssertEqual(encoded2.type, .jsonbArray) + + let decoded1 = try PostgresDataTranslation.decode(UnusualType.self, from: .init(bytes: encoded1.value, dataType: encoded1.type, format: encoded1.formatCode, columnName: "", columnIndex: -1), in: .default) + let decoded2 = try PostgresDataTranslation.decode([UnusualType].self, from: .init(bytes: encoded2.value, dataType: encoded2.type, format: encoded2.formatCode, columnName: "", columnIndex: -1), in: .default) + + XCTAssertEqual(decoded1.prop3, instance.prop3) + XCTAssertEqual(decoded2.count, 2) + } + + func testFluentWorkaroundsDecoding() throws { + // SQLKit benchmarks already test enum handling + + // Text encoding for Decimal + let decimalBuffer = ByteBuffer(string: Decimal(12345.6789).description) + var decimalValue: Decimal? + XCTAssertNoThrow(decimalValue = try PostgresDataTranslation.decode(Decimal.self, from: .init(bytes: decimalBuffer, dataType: .numeric, format: .text, columnName: "", columnIndex: -1), in: .default)) + XCTAssertEqual(decimalValue, Decimal(12345.6789)) + + // Decoding Double from NUMERIC + let numericBuffer = PostgresData(numeric: .init(decimal: 12345.6789)).value + var numericValue: Double? + XCTAssertNoThrow(numericValue = try PostgresDataTranslation.decode(Double.self, from: .init(bytes: numericBuffer, dataType: .numeric, format: .binary, columnName: "", columnIndex: -1), in: .default)) + XCTAssertEqual(numericValue, Double(Decimal(12345.6789).description)) + } + + func testURLWorkaroundDecoding() throws { + let url = URL(string: "https://user:pass@www.example.com:8080/path/to/endpoint?query=value#fragment")! + + let encodedNormal = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: url, in: .default, file: #fileID, line: #line) + XCTAssertEqual(encodedNormal.value?.getString(at: 0, length: encodedNormal.value?.readableBytes ?? 0), url.absoluteString) + + let encodedBroken = try PostgresDataTranslation.encode(codingPath: [], userInfo: [:], value: "\"\(url.absoluteString)\"", in: .default, file: #fileID, line: #line) + + XCTAssertEqual(try PostgresDataTranslation.decode(URL.self, from: .init(with: encodedNormal), in: .default), url) + XCTAssertEqual(try PostgresDataTranslation.decode(URL.self, from: .init(with: encodedBroken), in: .default), url) } - var eventLoop: EventLoop { self.eventLoopGroup.next() } - var eventLoopGroup: EventLoopGroup! + var eventLoop: any EventLoop { + MultiThreadedEventLoopGroup.singleton.any() + } - override func setUpWithError() throws { - try super.setUpWithError() + override class func setUp() { XCTAssertTrue(isLoggingConfigured) - self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 2) } +} - override func tearDownWithError() throws { - try self.eventLoopGroup.syncShutdownGracefully() - self.eventLoopGroup = nil - try super.tearDownWithError() +extension PostgresCell { + fileprivate init(with data: PostgresData) { + self.init(bytes: data.value, dataType: data.type, format: data.formatCode, columnName: "", columnIndex: -1) } } @@ -180,13 +264,19 @@ enum Bar: Int, Codable { case one, two } -extension Bar: PostgresDataConvertible { } - -let isLoggingConfigured: Bool = { - LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .debug - return handler +extension Bar: PostgresNonThrowingEncodable, PostgresArrayEncodable, PostgresDecodable, PostgresArrayDecodable { + func encode(into byteBuffer: inout ByteBuffer, context: PostgresEncodingContext) { + self.rawValue.encode(into: &byteBuffer, context: context) + } + + init(from byteBuffer: inout ByteBuffer, type: PostgresDataType, format: PostgresFormat, context: PostgresDecodingContext) throws { + guard let value = try Self.init(rawValue: Self.RawValue.init(from: &byteBuffer, type: type, format: format, context: context)) else { + throw PostgresDecodingError.Code.failure + } + self = value } - return true -}() + + static var psqlType: PostgresDataType { .int8 } + static var psqlFormat: PostgresFormat { .binary } + static var psqlArrayType: PostgresDataType { .int8Array } +} diff --git a/Tests/PostgresKitTests/SQLPostgresConfigurationTests.swift b/Tests/PostgresKitTests/SQLPostgresConfigurationTests.swift new file mode 100644 index 00000000..82811220 --- /dev/null +++ b/Tests/PostgresKitTests/SQLPostgresConfigurationTests.swift @@ -0,0 +1,75 @@ +import PostgresKit +import XCTest + +final class SQLPostgresConfigurationTests: XCTestCase { + func testURLHandling() throws { + let config1 = try SQLPostgresConfiguration(url: "postgres+tcp://test_username:test_password@test_hostname:9999/test_database?tlsmode=disable") + XCTAssertEqual(config1.coreConfiguration.database, "test_database") + XCTAssertEqual(config1.coreConfiguration.password, "test_password") + XCTAssertEqual(config1.coreConfiguration.username, "test_username") + XCTAssertEqual(config1.coreConfiguration.host, "test_hostname") + XCTAssertEqual(config1.coreConfiguration.port, 9999) + XCTAssertNil(config1.coreConfiguration.unixSocketPath) + XCTAssertFalse(config1.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config1.coreConfiguration.tls.isEnforced) + + let config2 = try SQLPostgresConfiguration(url: "postgres+tcp://test_username@test_hostname") + XCTAssertNil(config2.coreConfiguration.database) + XCTAssertNil(config2.coreConfiguration.password) + XCTAssertEqual(config2.coreConfiguration.username, "test_username") + XCTAssertEqual(config2.coreConfiguration.host, "test_hostname") + XCTAssertEqual(config2.coreConfiguration.port, SQLPostgresConfiguration.ianaPortNumber) + XCTAssertNil(config2.coreConfiguration.unixSocketPath) + XCTAssertTrue(config2.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config2.coreConfiguration.tls.isEnforced) + + let config3 = try SQLPostgresConfiguration(url: "postgres+uds://test_username:test_password@localhost/tmp/postgres.sock?tlsmode=require#test_database") + XCTAssertEqual(config3.coreConfiguration.database, "test_database") + XCTAssertEqual(config3.coreConfiguration.password, "test_password") + XCTAssertEqual(config3.coreConfiguration.username, "test_username") + XCTAssertNil(config3.coreConfiguration.host) + XCTAssertNil(config3.coreConfiguration.port) + XCTAssertEqual(config3.coreConfiguration.unixSocketPath, "/tmp/postgres.sock") + XCTAssertTrue(config3.coreConfiguration.tls.isAllowed) + XCTAssertTrue(config3.coreConfiguration.tls.isEnforced) + + let config4 = try SQLPostgresConfiguration(url: "postgres+uds://test_username@/tmp/postgres.sock") + XCTAssertNil(config4.coreConfiguration.database) + XCTAssertNil(config4.coreConfiguration.password) + XCTAssertEqual(config4.coreConfiguration.username, "test_username") + XCTAssertNil(config4.coreConfiguration.host) + XCTAssertNil(config4.coreConfiguration.port) + XCTAssertEqual(config4.coreConfiguration.unixSocketPath, "/tmp/postgres.sock") + XCTAssertFalse(config4.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config4.coreConfiguration.tls.isEnforced) + + for modestr in ["tlsmode=false", "tlsmode=verify-full&tlsmode=disable"] { + let config = try SQLPostgresConfiguration(url: "postgres://u@h?\(modestr)") + XCTAssertFalse(config.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config.coreConfiguration.tls.isEnforced) + } + + for modestr in ["tlsmode=prefer", "tlsmode=allow", "tlsmode=true"] { + let config = try SQLPostgresConfiguration(url: "postgres://u@h?\(modestr)") + XCTAssertTrue(config.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config.coreConfiguration.tls.isEnforced) + } + + for modestr in ["tlsmode=require", "tlsmode=verify-ca", "tlsmode=verify-full", "tls=verify-full", "ssl=verify-full", "tlsmode=prefer&sslmode=verify-full"] { + let config = try SQLPostgresConfiguration(url: "postgres://u@h?\(modestr)") + XCTAssertTrue(config.coreConfiguration.tls.isAllowed) + XCTAssertTrue(config.coreConfiguration.tls.isEnforced) + } + + XCTAssertNoThrow(try SQLPostgresConfiguration(url: "postgresql://test_username@test_hostname")) + XCTAssertNoThrow(try SQLPostgresConfiguration(url: "postgresql+tcp://test_username@test_hostname")) + XCTAssertNoThrow(try SQLPostgresConfiguration(url: "postgresql+uds://test_username@/tmp/postgres.sock")) + + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+tcp://test_hostname"), "should fail when username missing") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+tcp://test_username@test_hostname?tlsmode=absurd"), "should fail when TLS mode invalid") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+uds://localhost/tmp/postgres.sock?tlsmode=require"), "should fail when username missing") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+uds:///tmp/postgres.sock"), "should fail when authority missing") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+uds://username@localhost/"), "should fail when path missing") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+uds://username@remotehost/tmp"), "should fail when authority not localhost or empty") + } +} diff --git a/Tests/PostgresKitTests/Utilities.swift b/Tests/PostgresKitTests/Utilities.swift index 889ec86f..ed2d1894 100644 --- a/Tests/PostgresKitTests/Utilities.swift +++ b/Tests/PostgresKitTests/Utilities.swift @@ -1,31 +1,41 @@ -import XCTest -import PostgresKit +import Foundation import Logging -#if canImport(Darwin) -import Darwin.C -#else -import Glibc -#endif +import NIOCore +import PostgresKit +import PostgresNIO +import XCTest extension PostgresConnection { - static func test(on eventLoop: EventLoop) -> EventLoopFuture { - return PostgresConnectionSource(configuration: .test).makeConnection(logger: .init(label: "vapor.codes.postgres-kit.test"), on: eventLoop) + static func test(on eventLoop: any EventLoop) -> EventLoopFuture { + PostgresConnectionSource(sqlConfiguration: .test).makeConnection( + logger: .init(label: "vapor.codes.postgres-kit.test"), + on: eventLoop + ) } } -extension PostgresConfiguration { +extension SQLPostgresConfiguration { static var test: Self { .init( hostname: env("POSTGRES_HOSTNAME") ?? "localhost", port: env("POSTGRES_PORT").flatMap(Int.init) ?? Self.ianaPortNumber, - username: env("POSTGRES_USER") ?? "vapor_username", - password: env("POSTGRES_PASSWORD") ?? "vapor_password", - database: env("POSTGRES_DB") ?? "vapor_database", - tlsConfiguration: nil + username: env("POSTGRES_USER") ?? "test_username", + password: env("POSTGRES_PASSWORD") ?? "test_password", + database: env("POSTGRES_DB") ?? "test_database", + tls: .disable ) } } func env(_ name: String) -> String? { - getenv(name).flatMap { String(cString: $0) } + 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 + } + return true +}() diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 362aeb10..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: '3.7' - -x-common: &common - environment: - POSTGRES_USER: vapor_username - POSTGRES_DB: vapor_database - POSTGRES_PASSWORD: vapor_password - ports: - - 5432:5432 - -services: - psql-11: - <<: *common - image: postgres:11 - psql-10: - <<: *common - image: postgres:10 - psql-9: - <<: *common - image: postgres:9 - psql-ssl: - <<: *common - image: scenecheck/postgres-ssl:latest - cockroach: - <<: *common - image: cockroachdb/cockroach:latest - command: start --insecure - ports: - - "5432:26257" - - "8080:8080" -