diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..fe287d0
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,7 @@
+[*.swift]
+indent_style = space
+indent_size = 4
+tab_width = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..6ff9614
--- /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 849dbe1..0000000
--- a/.github/CONTRIBUTING.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Contributing to Fluent's PostgreSQL Driver
-
-π Welcome to the Vapor team! 
-
-## Docker
-
-In order to build and test against Postgres, you will need a database running. The easiest way to do this is using Docker and the included `docker-compose.yml` file.
-
-If you have Docker installed on your computer, all you will need to do is:
-
-```sh
-docker-compose up
-```
-
-This will start the two databases required for running this package's unit tests.
-
-## Xcode
-
-To open the project in Xcode:
-
-- Clone the repo to your computer
-- Drag and drop the folder onto Xcode
-
-To test within Xcode, press `CMD+U`.
-
-## SPM
-
-To develop using SPM, open the code in your favorite code editor. Use the following commands from within the project's root folder to build and test.
-
-```sh
-swift build
-swift test
-```
-
-## 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: [vapor.team](http://vapor.team).
-
-— Thanks! π
diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml
index f27346a..829c389 100644
--- a/.github/workflows/api-docs.yml
+++ b/.github/workflows/api-docs.yml
@@ -1,18 +1,17 @@
 name: deploy-api-docs
 on:
- push:
- branches:
- - master
+ push:
+ branches:
+ - main
+permissions:
+ contents: read
+ id-token: write
 
 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: fluent-postgres-driver
+ modules: FluentPostgresDriver
+ pathsToInvalidate: /fluentpostgresdriver/*
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 882a93c..e2f5a40 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,92 +1,140 @@
 name: test
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
 on:
- pull_request:
- push:
- branches:
- - master
+ pull_request: { types: [opened, reopened, synchronize, ready_for_review] }
+ push: { branches: [ main ] }
+permissions:
+ contents: read
+env:
+ LOG_LEVEL: info
+ POSTGRES_HOSTNAME_A: &postgres_host_a 'psql-a'
+ POSTGRES_HOSTNAME_B: &postgres_host_b 'psql-b'
+ POSTGRES_HOSTNAME: *postgres_host_a
+ POSTGRES_DB_A: &postgres_db_a 'test_database_a'
+ POSTGRES_DB_B: &postgres_db_b 'test_database_b'
+ POSTGRES_DB: *postgres_db_a
+ POSTGRES_USER_A: &postgres_user_a 'test_username'
+ POSTGRES_USER_B: &postgres_user_b 'test_username'
+ POSTGRES_USER: *postgres_user_a
+ POSTGRES_PASSWORD_A: &postgres_pass_a 'test_password'
+ POSTGRES_PASSWORD_B: &postgres_pass_b 'test_password'
+ POSTGRES_PASSWORD: *postgres_pass_a
+
 jobs:
- linux:
+ api-breakage:
+ if: ${{ github.event_name == 'pull_request' && !(github.event.pull_request.draft || false) }}
 runs-on: ubuntu-latest
+ container: swift:noble
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+ 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
+
+ linux-all:
+ if: ${{ !(github.event.pull_request.draft || false) }}
 strategy:
 fail-fast: false
 matrix:
- image:
- # 5.2 Stable
- - swift:5.2-xenial
- - swift:5.2-bionic
- # 5.2 Unstable
- - swiftlang/swift:nightly-5.2-xenial
- - swiftlang/swift:nightly-5.2-bionic
- # 5.3 Unstable
- - swiftlang/swift:nightly-5.3-xenial
- - swiftlang/swift:nightly-5.3-bionic
- # Master Unsable
- - swiftlang/swift:nightly-master-xenial
- - swiftlang/swift:nightly-master-bionic
- - swiftlang/swift:nightly-master-focal
- - swiftlang/swift:nightly-master-centos8
- - swiftlang/swift:nightly-master-amazonlinux2
- dbimage: 
- - postgres:11
- - postgres:12
- - postgres:13
- container: ${{ matrix.image }}
+ include:
+ - postgres-image-a: 'postgres:13'
+ postgres-image-b: 'postgres:14'
+ postgres-auth: 'trust'
+ swift-image: 'swift:6.0-noble'
+ - postgres-image-a: 'postgres:15'
+ postgres-image-b: 'postgres:16'
+ postgres-auth: 'md5'
+ swift-image: 'swift:6.1-noble'
+ - postgres-image-a: 'postgres:17'
+ postgres-image-b: 'postgres:18'
+ postgres-auth: 'scram-sha-256'
+ swift-image: 'swift:6.2-noble'
+ container: ${{ matrix.swift-image }}
+ runs-on: ubuntu-latest
 services:
- postgres-a:
- image: ${{ matrix.dbimage }}
- env: 
- POSTGRES_USER: vapor_username
- POSTGRES_PASSWORD: vapor_password
- POSTGRES_DB: vapor_database
- postgres-b:
- image: ${{ matrix.dbimage }}
+ *postgres_host_a:
+ image: ${{ matrix.postgres-image-a }}
 env:
- POSTGRES_USER: vapor_username
- POSTGRES_PASSWORD: vapor_password
- POSTGRES_DB: vapor_database
- env:
- POSTGRES_HOSTNAME_A: postgres-a
- POSTGRES_HOSTNAME_B: postgres-b
- LOG_LEVEL: info
+ POSTGRES_USER: *postgres_user_a
+ POSTGRES_DB: *postgres_db_a
+ POSTGRES_PASSWORD: *postgres_pass_a
+ POSTGRES_HOST_AUTH_METHOD: ${{ matrix.postgres-auth }}
+ POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.postgres-auth }}
+ *postgres_host_b:
+ image: ${{ matrix.postgres-image-b }}
+ env:
+ POSTGRES_USER: *postgres_user_b
+ POSTGRES_DB: *postgres_db_b
+ POSTGRES_PASSWORD: *postgres_pass_b
+ POSTGRES_HOST_AUTH_METHOD: ${{ matrix.postgres-auth }}
+ POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.postgres-auth }}
 steps:
- - name: Checkout code
- uses: actions/checkout@v2
- - name: Run tests with Thread Sanitizer
- run: swift test --enable-test-discovery --sanitize=thread
- macOS:
+ - name: Ensure curl is available
+ run: apt-get update -y && apt-get install -y curl
+ - name: Check out package
+ uses: actions/checkout@v5
+ - name: Run all tests
+ run: swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable
+ - name: Submit coverage report to Codecov.io
+ uses: vapor/swift-codecov-action@v0.3
+ with:
+ codecov_token: ${{ secrets.CODECOV_TOKEN }}
+
+ macos-all:
+ if: ${{ !(github.event.pull_request.draft || false) }}
 strategy:
 fail-fast: false
 matrix:
- include: 
- - formula: postgresql@11
- datadir: postgresql@11
- - formula: postgresql@12
- datadir: postgres
+ include:
+ - macos-version: macos-15
+ xcode-version: latest-stable
+ - macos-version: macos-26
+ xcode-version: latest-stable
+ runs-on: ${{ matrix.macos-version }}
 env:
- POSTGRES_DATABASE_A: vapor_database_a
- POSTGRES_DATABASE_B: vapor_database_b
- runs-on: macos-latest
+ LOG_LEVEL: debug
+ POSTGRES_HOSTNAME: 127.0.0.1
+ POSTGRES_HOSTNAME_A: 127.0.0.1
+ POSTGRES_HOSTNAME_B: 127.0.0.1
 steps:
 - name: Select latest available Xcode
- uses: maxim-lobanov/setup-xcode@1.0
+ uses: maxim-lobanov/setup-xcode@v1
 with:
- xcode-version: latest
- - name: Replace Postgres install and start server
- run: |
- brew uninstall --force postgresql php && rm -rf /usr/local/{etc,var}/{postgres,pg}*
- brew install ${{ matrix.formula }} && brew link --force ${{ matrix.formula }}
- initdb --locale=C -E UTF-8 $(brew --prefix)/var/${{ matrix.datadir }}
- brew services start ${{ matrix.formula }}
- - name: Wait for server to be ready
- run: until pg_isready; do sleep 1; done
- timeout-minutes: 2
- - name: Setup users and databases for Postgres
+ xcode-version: ${{ matrix.xcode-version }}
+ - name: Install Postgres, setup DB and auth, and wait for server start
 run: |
- createuser --createdb --login vapor_username
- for db in vapor_database_{a,b}; do
- createdb -Ovapor_username $db && psql $db <<<"alter SCHEMA public OWNER TO vapor_username;" - done + brew upgrade || true + export PATH="$(brew --prefix)/opt/postgresql@16/bin:$PATH" PGDATA=/tmp/vapor-postgres-test PGUSER="${POSTGRES_USER_A}" + brew install postgresql@18 && brew link --force postgresql@18 + initdb --locale=C --auth-host "scram-sha-256" -U "${POSTGRES_USER_A}" --pwfile=<(echo "${POSTGRES_PASSWORD_A}") + pg_ctl start --wait + PGPASSWORD="${POSTGRES_PASSWORD_A}" createdb -w -O "${POSTGRES_USER_A}" "${POSTGRES_DB_A}" + PGPASSWORD="${POSTGRES_PASSWORD_A}" createdb -w -O "${POSTGRES_USER_B}" "${POSTGRES_DB_B}" + PGPASSWORD="${POSTGRES_PASSWORD_A}" psql -w "${POSTGRES_DB_A}" <<<"alter SCHEMA public OWNER TO ${POSTGRES_USER_A};" + PGPASSWORD="${POSTGRES_PASSWORD_A}" psql -w "${POSTGRES_DB_B}" <<<"alter SCHEMA public OWNER TO ${POSTGRES_USER_B};" + timeout-minutes: 15 - name: Checkout code - uses: actions/checkout@v2 - - name: Run tests with Thread Sanitizer - run: swift test --enable-test-discovery --sanitize=thread + uses: actions/checkout@v5 + - name: Run all tests + run: swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable + - name: Submit coverage report to Codecov.io + uses: vapor/swift-codecov-action@v0.3 + with: + codecov_token: ${{ secrets.CODECOV_TOKEN }} + + musl: + runs-on: ubuntu-latest + container: swift:6.2-noble + timeout-minutes: 30 + steps: + - name: Check out code + uses: actions/checkout@v5 + - name: Install SDK + run: swift sdk install https://download.swift.org/swift-6.2-release/static-sdk/swift-6.2-RELEASE/swift-6.2-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum d2225840e592389ca517bbf71652f7003dbf45ac35d1e57d98b9250368769378 + - name: Build + run: swift build --swift-sdk x86_64-swift-linux-musl diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..04c7ade --- /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/fluentpostgresdriver/documentation/fluentpostgresdriver/" diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..e95ade0 --- /dev/null +++ b/.swift-format @@ -0,0 +1,77 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : false, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 4 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 150, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : { + "never" : { + } + }, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : true, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : true, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : true, + "spacesBeforeEndOfLineComments" : 1, + "tabversion" : 1 +} diff --git a/Package.swift b/Package.swift index ff0cc7c..b211c69 100644 --- a/Package.swift +++ b/Package.swift @@ -1,29 +1,49 @@ -// swift-tools-version:5.2 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "fluent-postgres-driver", platforms: [ - .macOS(.v10_15) + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), ], products: [ .library(name: "FluentPostgresDriver", targets: ["FluentPostgresDriver"]), ], dependencies: [ - .package(url: "https://github.com/vapor/async-kit.git", from: "1.2.0"), - .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.0.0"), - .package(url: "https://github.com/vapor/postgres-kit.git", from: "2.3.0"), + .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.52.2"), + .package(url: "https://github.com/vapor/postgres-kit.git", from: "2.14.1"), + .package(url: "https://github.com/vapor/async-kit.git", from: "1.21.0"), ], targets: [ - .target(name: "FluentPostgresDriver", dependencies: [ - .product(name: "AsyncKit", package: "async-kit"), - .product(name: "FluentKit", package: "fluent-kit"), - .product(name: "FluentSQL", package: "fluent-kit"), - .product(name: "PostgresKit", package: "postgres-kit"), - ]), - .testTarget(name: "FluentPostgresDriverTests", dependencies: [ - .product(name: "FluentBenchmark", package: "fluent-kit"), - .target(name: "FluentPostgresDriver"), - ]), + .target( + name: "FluentPostgresDriver", + dependencies: [ + .product(name: "FluentKit", package: "fluent-kit"), + .product(name: "FluentSQL", package: "fluent-kit"), + .product(name: "PostgresKit", package: "postgres-kit"), + .product(name: "AsyncKit", package: "async-kit"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "FluentPostgresDriverTests", + dependencies: [ + .product(name: "FluentBenchmark", package: "fluent-kit"), + .target(name: "FluentPostgresDriver"), + ], + swiftSettings: swiftSettings + ), ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ExistentialAny"), + //.enableUpcomingFeature("InternalImportsByDefault"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("InferIsolatedConformances"), + //.enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("ImmutableWeakCaptures"), +] } diff --git a/README.md b/README.md index 84be6fa..08a2ba1 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,31 @@ 
- FluentPostgresDriver
- 
- 
- 
- Documentation
- 
- 
- Team Chat
- 
- 
- MIT License
- 
- 
- Continuous Integration
- 
- 
- Swift 5.2
- 
+FluentPostgresDriver
+
+
+Documentation
+Team Chat
+MIT License
+Continuous Integration
+Code Coverage
+Swift 6.0+
 
+
+
+
+FluentPostgresDriver is a [FluentKit] driver for PostgreSQL clients. It provides support for using the Fluent ORM with PostgreSQL databases, and uses [PostgresKit] to provide [SQLKit] driver services, [PostgresNIO] to connect and communicate with the database server asynchronously, and [AsyncKit] to provide connection pooling.
+
+[FluentKit]: https://github.com/vapor/fluent-kit
+[SQLKit]: https://github.com/vapor/sql-kit
+[PostgresKit]: https://github.com/vapor/postgres-kit
+[PostgresNIO]: https://github.com/vapor/postgres-nio
+[AsyncKit]: https://github.com/vapor/async-kit
+
+### Usage
+
+Use the SPM string to easily include the dependendency in your `Package.swift` file:
+
+```swift
+.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0")
+```
+
+For additional information, see [the Fluent documentation](https://docs.vapor.codes/fluent/overview/).
diff --git a/Sources/FluentPostgresDriver/DatabaseID+PostgreSQL.swift b/Sources/FluentPostgresDriver/DatabaseID+PostgreSQL.swift
new file mode 100644
index 0000000..d50397f
--- /dev/null
+++ b/Sources/FluentPostgresDriver/DatabaseID+PostgreSQL.swift
@@ -0,0 +1,7 @@
+import FluentKit
+
+extension DatabaseID {
+ public static var psql: DatabaseID {
+ .init(string: "psql")
+ }
+}
diff --git a/Sources/FluentPostgresDriver/Deprecations/FluentPostgresConfiguration+Deprecated.swift b/Sources/FluentPostgresDriver/Deprecations/FluentPostgresConfiguration+Deprecated.swift
new file mode 100644
index 0000000..5948911
--- /dev/null
+++ b/Sources/FluentPostgresDriver/Deprecations/FluentPostgresConfiguration+Deprecated.swift
@@ -0,0 +1,135 @@
+import FluentKit
+import Foundation
+import Logging
+import NIOCore
+import PostgresKit
+import PostgresNIO
+
+// N.B.: This excessive method duplication is required to maintain the original public API, which allowed defaulting
+// either the encoder, decoder, or both. The "defaulting both" versions now forward to the new API, the others are here.
+
+// Factory methods accepting both encoder and decoder
+extension DatabaseConfigurationFactory {
+ enum FluentPostgresError: Error {
+ case invalidURL(String)
+ }
+
+ @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.")
+ public static func postgres(
+ url: String, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10),
+ encoder: PostgresDataEncoder, decoder: PostgresDataDecoder, sqlLogLevel: Logger.Level = .debug
+ ) throws -> DatabaseConfigurationFactory {
+ guard let configuration = PostgresConfiguration(url: url) else { throw FluentPostgresError.invalidURL(url) }
+ return .postgres(configuration: configuration,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout,
+ encoder: encoder, decoder: decoder, sqlLogLevel: sqlLogLevel
+ )
+ }
+
+ @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.")
+ public static func postgres(
+ url: URL, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10),
+ encoder: PostgresDataEncoder, decoder: PostgresDataDecoder, sqlLogLevel: Logger.Level = .debug
+ ) throws -> DatabaseConfigurationFactory {
+ guard let configuration = PostgresConfiguration(url: url) else { throw FluentPostgresError.invalidURL(url.absoluteString) }
+ return .postgres( configuration: configuration,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout,
+ encoder: encoder, decoder: decoder, sqlLogLevel: sqlLogLevel
+ )
+ }
+
+ @available(*, deprecated, message: "Use `.postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.")
+ public static func postgres(
+ hostname: String, port: Int = PostgresConfiguration.ianaPortNumber,
+ username: String, password: String, database: String? = nil, tlsConfiguration: TLSConfiguration? = nil,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ encoder: PostgresDataEncoder = .init(),
+ decoder: PostgresDataDecoder = .init(),
+ sqlLogLevel: Logger.Level = .debug
+ ) -> DatabaseConfigurationFactory {
+ .postgres(configuration: .init(
+ hostname: hostname, port: port, username: username, password: password, database: database, tlsConfiguration: tlsConfiguration),
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout,
+ encoder: encoder, decoder: decoder, sqlLogLevel: sqlLogLevel
+ )
+ }
+
+ @available(*, deprecated, message: "Use `.postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.")
+ public static func postgres(
+ configuration: PostgresConfiguration,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ encoder: PostgresDataEncoder = .init(),
+ decoder: PostgresDataDecoder = .init(),
+ sqlLogLevel: Logger.Level = .debug
+ ) -> DatabaseConfigurationFactory {
+ .postgres(
+ configuration: .init(legacyConfiguration: configuration),
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout,
+ encodingContext: .init(jsonEncoder: TypeErasedPostgresJSONEncoder(json: encoder.json)),
+ decodingContext: .init(jsonDecoder: TypeErasedPostgresJSONDecoder(json: decoder.json)),
+ sqlLogLevel: sqlLogLevel
+ )
+ }
+}
+
+// Factory methods accepting only encoder or only decoder.
+extension DatabaseConfigurationFactory {
+ @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.")
+ public static func postgres(
+ url: String, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10),
+ encoder: PostgresDataEncoder, sqlLogLevel: Logger.Level = .debug
+ ) throws -> DatabaseConfigurationFactory {
+ try .postgres(url: url,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout,
+ encoder: encoder, decoder: .init(), sqlLogLevel: sqlLogLevel
+ )
+ }
+
+ @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.")
+ public static func postgres(
+ url: String, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10),
+ decoder: PostgresDataDecoder, sqlLogLevel: Logger.Level = .debug
+ ) throws -> DatabaseConfigurationFactory {
+ try .postgres(url: url,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout,
+ encoder: .init(), decoder: decoder, sqlLogLevel: sqlLogLevel
+ )
+ }
+
+ @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.")
+ public static func postgres(
+ url: URL, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10),
+ encoder: PostgresDataEncoder, sqlLogLevel: Logger.Level = .debug
+ ) throws -> DatabaseConfigurationFactory {
+ try .postgres(url: url,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout,
+ encoder: encoder, decoder: .init(), sqlLogLevel: sqlLogLevel
+ )
+ }
+
+ @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.")
+ public static func postgres(
+ url: URL, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10),
+ decoder: PostgresDataDecoder, sqlLogLevel: Logger.Level = .debug
+ ) throws -> DatabaseConfigurationFactory {
+ try .postgres(url: url,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout,
+ encoder: .init(), decoder: decoder, sqlLogLevel: sqlLogLevel
+ )
+ }
+}
+
+// N.B.: If you change something in these two types, update the copies in PostgresKit too.
+fileprivate struct TypeErasedPostgresJSONDecoder: PostgresJSONDecoder {
+ let json: any PostgresJSONDecoder
+ func decode(_: T.Type, from: Data) throws -> T { try self.json.decode(T.self, from: from) }
+ func decode(_: T.Type, from: ByteBuffer) throws -> T { try self.json.decode(T.self, from: from) }
+}
+
+fileprivate struct TypeErasedPostgresJSONEncoder: PostgresJSONEncoder {
+ let json: any PostgresJSONEncoder
+ func encode(_ value: T) throws -> Data { try self.json.encode(value) }
+ func encode(_ value: T, into: inout ByteBuffer) throws { try self.json.encode(value, into: &into) }
+}
diff --git a/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg b/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg
new file mode 100644
index 0000000..79211b7
--- /dev/null
+++ b/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg
@@ -0,0 +1,25 @@
+
diff --git a/Sources/FluentPostgresDriver/Docs.docc/index.md b/Sources/FluentPostgresDriver/Docs.docc/index.md
new file mode 100644
index 0000000..08593bc
--- /dev/null
+++ b/Sources/FluentPostgresDriver/Docs.docc/index.md
@@ -0,0 +1,14 @@
+# ``FluentPostgresDriver``
+
+FluentPostgresDriver is a [FluentKit] driver for PostgreSQL clients.
+
+## Overview
+
+FluentPostgresDriver provides support for using the Fluent ORM with PostgresSQL databases. It uses [PostgresKit] to provide [SQLKit] driver services, [PostgresNIO] to connect and communicate with the database server asynchronously, and [AsyncKit] to provide connection pooling.
+
+[FluentKit]: https://github.com/vapor/fluent-kit
+[SQLKit]: https://github.com/vapor/sql-kit
+[PostgresKit]: https://github.com/vapor/postgres-kit
+[PostgresNIO]: https://github.com/vapor/postgres-nio
+[AsyncKit]: https://github.com/vapor/async-kit
+
diff --git a/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json b/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json
new file mode 100644
index 0000000..c0625a0
--- /dev/null
+++ b/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json
@@ -0,0 +1,24 @@
+{
+ "theme": {
+ "aside": { "border-radius": "16px", "border-width": "3px", "border-style": "double" },
+ "border-radius": "0",
+ "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" },
+ "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" },
+ "color": {
+ "fluentpsqldriver": "#336791",
+ "documentation-intro-fill": "radial-gradient(circle at top, var(--color-fluentpsqldriver) 30%, #000 100%)",
+ "documentation-intro-accent": "var(--color-fluentpsqldriver)",
+ "hero-eyebrow": "white",
+ "documentation-intro-figure": "white",
+ "hero-title": "white",
+ "logo-base": { "dark": "#fff", "light": "#000" },
+ "logo-shape": { "dark": "#000", "light": "#fff" },
+ "fill": { "dark": "#000", "light": "#fff" }
+ },
+ "icons": { "technology": "/fluentpostgresdriver/images/FluentPostgresDriver/vapor-fluentpostgresdriver-logo.svg" }
+ },
+ "features": {
+ "quickNavigation": { "enable": true },
+ "i18n": { "enable": true }
+ }
+}
diff --git a/Sources/FluentPostgresDriver/Exports.swift b/Sources/FluentPostgresDriver/Exports.swift
index 0febffa..f33f24c 100644
--- a/Sources/FluentPostgresDriver/Exports.swift
+++ b/Sources/FluentPostgresDriver/Exports.swift
@@ -1,8 +1,2 @@
-@_exported import FluentKit
-@_exported import PostgresKit
-
-extension DatabaseID {
- public static var psql: DatabaseID {
- return .init(string: "psql")
- }
-}
+@_documentation(visibility: internal) @_exported import FluentKit
+@_documentation(visibility: internal) @_exported import PostgresKit
diff --git a/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift b/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift
index 582322d..56ca567 100644
--- a/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift
+++ b/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift
@@ -1,110 +1,385 @@
+import AsyncKit
+import FluentKit
+import Foundation
+import Logging
+import NIOCore
+import PostgresKit
+import PostgresNIO
+
 extension DatabaseConfigurationFactory {
+ /// Create a PostgreSQL database configuration from a URL string.
+ ///
+ /// See ``PostgresKit/SQLPostgresConfiguration/init(url:)`` for the allowed URL format.
+ ///
+ /// - Parameters:
+ /// - urlString: The URL describing the connection, as a string.
+ /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop.
+ /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request.
+ /// - encodingContext: Encoding context to use for serializing data.
+ /// - decodingContext: Decoding context to use for deserializing data.
+ /// - sqlLogLevel: Level at which to log SQL queries.
 public static func postgres(
 url urlString: String,
 maxConnectionsPerEventLoop: Int = 1,
- connectionPoolTimeout: NIO.TimeAmount = .seconds(10),
- encoder: PostgresDataEncoder = .init(),
- decoder: PostgresDataDecoder = .init()
- ) throws -> DatabaseConfigurationFactory {
- guard let url = URL(string: urlString) else {
- throw FluentPostgresError.invalidURL(urlString)
- }
- return try .postgres(
- url: url,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ encodingContext: PostgresEncodingContext = .default,
+ decodingContext: PostgresDecodingContext = .default,
+ sqlLogLevel: Logger.Level = .debug
+ ) throws -> Self {
+ .postgres(
+ configuration: try .init(url: urlString),
 maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
 connectionPoolTimeout: connectionPoolTimeout,
- encoder: encoder,
- decoder: decoder
+ pruneInterval: nil,
+ encodingContext: encodingContext,
+ decodingContext: decodingContext,
+ sqlLogLevel: sqlLogLevel
 )
 }
 
+ /// Create a PostgreSQL database configuration from a URL.
+ ///
+ /// See ``PostgresKit/SQLPostgresConfiguration/init(url:)`` for the allowed URL format.
+ ///
+ /// - Parameters:
+ /// - url: The URL describing the connection.
+ /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop.
+ /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request.
+ /// - encodingContext: Encoding context to use for serializing data.
+ /// - decodingContext: Decoding context to use for deserializing data.
+ /// - sqlLogLevel: Level at which to log SQL queries.
 public static func postgres(
 url: URL,
 maxConnectionsPerEventLoop: Int = 1,
- connectionPoolTimeout: NIO.TimeAmount = .seconds(10),
- encoder: PostgresDataEncoder = .init(),
- decoder: PostgresDataDecoder = .init()
- ) throws -> DatabaseConfigurationFactory {
- guard let configuration = PostgresConfiguration(url: url) else {
- throw FluentPostgresError.invalidURL(url.absoluteString)
- }
- return .postgres(
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ encodingContext: PostgresEncodingContext = .default,
+ decodingContext: PostgresDecodingContext = .default,
+ sqlLogLevel: Logger.Level = .debug
+ ) throws -> Self {
+ .postgres(
+ configuration: try .init(url: url),
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: nil,
+ encodingContext: encodingContext,
+ decodingContext: decodingContext,
+ sqlLogLevel: sqlLogLevel
+ )
+ }
+
+ /// Create a PostgreSQL database configuration from lower-level configuration.
+ ///
+ /// - Parameters:
+ /// - configuration: A ``PostgresKit/SQLPostgresConfiguration`` describing the connection.
+ /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop.
+ /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request.
+ /// - encodingContext: Encoding context to use for serializing data.
+ /// - decodingContext: Decoding context to use for deserializing data.
+ /// - sqlLogLevel: Level at which to log SQL queries.
+ public static func postgres(
+ configuration: SQLPostgresConfiguration,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ encodingContext: PostgresEncodingContext,
+ decodingContext: PostgresDecodingContext,
+ sqlLogLevel: Logger.Level = .debug
+ ) -> Self {
+ .postgres(
+ configuration: configuration,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: nil,
+ encodingContext: encodingContext,
+ decodingContext: decodingContext,
+ sqlLogLevel: sqlLogLevel
+ )
+ }
+}
+
+extension DatabaseConfigurationFactory {
+ /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)``
+ /// with the `decodingContext` defaulted.
+ public static func postgres(
+ configuration: SQLPostgresConfiguration,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ encodingContext: PostgresEncodingContext,
+ sqlLogLevel: Logger.Level = .debug
+ ) -> Self {
+ .postgres(
+ configuration: configuration,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: nil,
+ encodingContext: encodingContext,
+ decodingContext: .default,
+ sqlLogLevel: sqlLogLevel
+ )
+ }
+
+ /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)``
+ /// with the `encodingContext` defaulted.
+ public static func postgres(
+ configuration: SQLPostgresConfiguration,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ decodingContext: PostgresDecodingContext,
+ sqlLogLevel: Logger.Level = .debug
+ ) -> Self {
+ .postgres(
 configuration: configuration,
 maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
- connectionPoolTimeout: connectionPoolTimeout
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: nil,
+ encodingContext: .default,
+ decodingContext: decodingContext,
+ sqlLogLevel: sqlLogLevel
+ )
+ }
+
+ /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)``
+ /// with both `encodingContext` and `decodingContext` defaulted.
+ public static func postgres(
+ configuration: SQLPostgresConfiguration,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ sqlLogLevel: Logger.Level = .debug
+ ) -> Self {
+ .postgres(
+ configuration: configuration,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: nil,
+ encodingContext: .default,
+ decodingContext: .default,
+ sqlLogLevel: sqlLogLevel
+ )
+ }
+}
+
+extension DatabaseConfigurationFactory {
+ /// Create a PostgreSQL database configuration from a URL string.
+ ///
+ /// See ``PostgresKit/SQLPostgresConfiguration/init(url:)`` for the allowed URL format.
+ ///
+ /// - Parameters:
+ /// - urlString: The URL describing the connection, as a string.
+ /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop.
+ /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request.
+ /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default),
+ /// no pruning is performed.
+ /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled.
+ /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`.
+ /// - encodingContext: Encoding context to use for serializing data.
+ /// - decodingContext: Decoding context to use for deserializing data.
+ /// - sqlLogLevel: Level at which to log SQL queries.
+ public static func postgres(
+ url urlString: String,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ pruneInterval: TimeAmount?,
+ maxIdleTimeBeforePruning: TimeAmount = .seconds(120),
+ encodingContext: PostgresEncodingContext = .default,
+ decodingContext: PostgresDecodingContext = .default,
+ sqlLogLevel: Logger.Level = .debug
+ ) throws -> Self {
+ .postgres(
+ configuration: try .init(url: urlString),
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: pruneInterval,
+ maxIdleTimeBeforePruning: maxIdleTimeBeforePruning,
+ encodingContext: encodingContext,
+ decodingContext: decodingContext,
+ sqlLogLevel: sqlLogLevel
 )
 }
 
+ /// Create a PostgreSQL database configuration from a URL.
+ ///
+ /// See ``PostgresKit/SQLPostgresConfiguration/init(url:)`` for the allowed URL format.
+ ///
+ /// - Parameters:
+ /// - url: The URL describing the connection.
+ /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop.
+ /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request.
+ /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default),
+ /// no pruning is performed.
+ /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled.
+ /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`.
+ /// - encodingContext: Encoding context to use for serializing data.
+ /// - decodingContext: Decoding context to use for deserializing data.
+ /// - sqlLogLevel: Level at which to log SQL queries.
 public static func postgres(
- hostname: String,
- port: Int = PostgresConfiguration.ianaPortNumber,
- username: String,
- password: String,
- database: String? = nil,
- tlsConfiguration: TLSConfiguration? = nil,
+ url: URL,
 maxConnectionsPerEventLoop: Int = 1,
- connectionPoolTimeout: NIO.TimeAmount = .seconds(10),
- encoder: PostgresDataEncoder = .init(),
- decoder: PostgresDataDecoder = .init()
- ) -> DatabaseConfigurationFactory {
- return .postgres(
- configuration: .init(
- hostname: hostname,
- port: port,
- username: username,
- password: password,
- database: database,
- tlsConfiguration: tlsConfiguration
- ),
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ pruneInterval: TimeAmount?,
+ maxIdleTimeBeforePruning: TimeAmount = .seconds(120),
+ encodingContext: PostgresEncodingContext = .default,
+ decodingContext: PostgresDecodingContext = .default,
+ sqlLogLevel: Logger.Level = .debug
+ ) throws -> Self {
+ .postgres(
+ configuration: try .init(url: url),
 maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
- connectionPoolTimeout: connectionPoolTimeout
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: pruneInterval,
+ maxIdleTimeBeforePruning: maxIdleTimeBeforePruning,
+ encodingContext: encodingContext,
+ decodingContext: decodingContext,
+ sqlLogLevel: sqlLogLevel
 )
 }
 
+ /// Create a PostgreSQL database configuration from lower-level configuration.
+ ///
+ /// - Parameters:
+ /// - configuration: A ``PostgresKit/SQLPostgresConfiguration`` describing the connection.
+ /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop.
+ /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request.
+ /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default),
+ /// no pruning is performed.
+ /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled.
+ /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`.
+ /// - encodingContext: Encoding context to use for serializing data.
+ /// - decodingContext: Decoding context to use for deserializing data.
+ /// - sqlLogLevel: Level at which to log SQL queries.
 public static func postgres(
- configuration: PostgresConfiguration,
+ configuration: SQLPostgresConfiguration,
 maxConnectionsPerEventLoop: Int = 1,
- connectionPoolTimeout: NIO.TimeAmount = .seconds(10),
- encoder: PostgresDataEncoder = .init(),
- decoder: PostgresDataDecoder = .init()
- ) -> DatabaseConfigurationFactory {
- return DatabaseConfigurationFactory {
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ pruneInterval: TimeAmount?,
+ maxIdleTimeBeforePruning: TimeAmount = .seconds(120),
+ encodingContext: PostgresEncodingContext,
+ decodingContext: PostgresDecodingContext,
+ sqlLogLevel: Logger.Level = .debug
+ ) -> Self {
+ .init {
 FluentPostgresConfiguration(
- middleware: [],
 configuration: configuration,
 maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
 connectionPoolTimeout: connectionPoolTimeout,
- encoder: encoder,
- decoder: decoder
+ pruningInterval: pruneInterval,
+ maxIdleTimeBeforePruning: maxIdleTimeBeforePruning,
+ encodingContext: encodingContext,
+ decodingContext: decodingContext,
+ sqlLogLevel: sqlLogLevel
 )
 }
 }
 }
 
-struct FluentPostgresConfiguration: DatabaseConfiguration {
- var middleware: [AnyModelMiddleware]
- let configuration: PostgresConfiguration
- let maxConnectionsPerEventLoop: Int
- /// The amount of time to wait for a connection from
- /// the connection pool before timing out.
- let connectionPoolTimeout: NIO.TimeAmount
- let encoder: PostgresDataEncoder
- let decoder: PostgresDataDecoder
+/// We'd like to just default the context parameters of the "actual" method. Unfortunately, there are a few
+/// cases involving the UNIX domain socket initalizer where usage can resolve to either the new
+/// `SQLPostgresConfiguration`-based method or the deprecated `PostgresConfiguration`-based method, with no
+/// obvious way to disambiguate which to call. Because the context parameters are generic, if they are defaulted,
+/// the compiler resolves the ambiguity in favor of the deprecated method (which has no generic parameters).
+/// However, by adding the non-defaulted-parameter variant which takes neither context, we've provided a version
+/// which has no generic parameters either, which allows the compiler to resolve the ambiguity in favor of
+/// the one with better availability (i.e. the one that isn't deprecated).
+///
+/// Example affected code:
+///
+/// _ = DatabaseConfigurationFactory.postgres(configuration: .init(unixDomainSocketPath: "", username: ""))
+extension DatabaseConfigurationFactory {
+ /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:pruneInterval:maxIdleTimeBeforePruning:encodingContext:decodingContext:sqlLogLevel:)``
+ /// with the `decodingContext` defaulted.
+ public static func postgres(
+ configuration: SQLPostgresConfiguration,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ pruneInterval: TimeAmount?,
+ maxIdleTimeBeforePruning: TimeAmount = .seconds(120),
+ encodingContext: PostgresEncodingContext,
+ sqlLogLevel: Logger.Level = .debug
+ ) -> Self {
+ .postgres(
+ configuration: configuration,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: pruneInterval,
+ maxIdleTimeBeforePruning: maxIdleTimeBeforePruning,
+ encodingContext: encodingContext,
+ decodingContext: .default,
+ sqlLogLevel: sqlLogLevel
+ )
+ }
 
- func makeDriver(for databases: Databases) -> DatabaseDriver {
- let db = PostgresConnectionSource(
- configuration: configuration
+ /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:pruneInterval:maxIdleTimeBeforePruning:encodingContext:decodingContext:sqlLogLevel:)``
+ /// with the `encodingContext` defaulted.
+ public static func postgres(
+ configuration: SQLPostgresConfiguration,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ pruneInterval: TimeAmount?,
+ maxIdleTimeBeforePruning: TimeAmount = .seconds(120),
+ decodingContext: PostgresDecodingContext,
+ sqlLogLevel: Logger.Level = .debug
+ ) -> Self {
+ .postgres(
+ configuration: configuration,
+ maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: pruneInterval,
+ maxIdleTimeBeforePruning: maxIdleTimeBeforePruning,
+ encodingContext: .default,
+ decodingContext: decodingContext,
+ sqlLogLevel: sqlLogLevel
 )
- let pool = EventLoopGroupConnectionPool(
- source: db,
+ }
+
+ /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:pruneInterval:maxIdleTimeBeforePruning:encodingContext:decodingContext:sqlLogLevel:)``
+ /// with both `encodingContext` and `decodingContext` defaulted.
+ public static func postgres(
+ configuration: SQLPostgresConfiguration,
+ maxConnectionsPerEventLoop: Int = 1,
+ connectionPoolTimeout: TimeAmount = .seconds(10),
+ pruneInterval: TimeAmount?,
+ maxIdleTimeBeforePruning: TimeAmount = .seconds(120),
+ sqlLogLevel: Logger.Level = .debug
+ ) -> Self {
+ .postgres(
+ configuration: configuration,
 maxConnectionsPerEventLoop: maxConnectionsPerEventLoop,
- requestTimeout: connectionPoolTimeout,
+ connectionPoolTimeout: connectionPoolTimeout,
+ pruneInterval: pruneInterval,
+ maxIdleTimeBeforePruning: maxIdleTimeBeforePruning,
+ encodingContext: .default,
+ decodingContext: .default,
+ sqlLogLevel: sqlLogLevel
+ )
+ }
+}
+
+/// The actual concrete configuration type produced by a configuration factory.
+struct FluentPostgresConfiguration: DatabaseConfiguration {
+ var middleware: [any AnyModelMiddleware] = []
+ fileprivate let configuration: SQLPostgresConfiguration
+ let maxConnectionsPerEventLoop: Int
+ let connectionPoolTimeout: TimeAmount
+ let pruningInterval: TimeAmount?
+ let maxIdleTimeBeforePruning: TimeAmount
+ let encodingContext: PostgresEncodingContext
+ let decodingContext: PostgresDecodingContext
+ let sqlLogLevel: Logger.Level
+
+ func makeDriver(for databases: Databases) -> any DatabaseDriver {
+ let connectionSource = PostgresConnectionSource(sqlConfiguration: self.configuration)
+ let elgPool = EventLoopGroupConnectionPool(
+ source: connectionSource,
+ maxConnectionsPerEventLoop: self.maxConnectionsPerEventLoop,
+ requestTimeout: self.connectionPoolTimeout,
+ pruneInterval: self.pruningInterval,
+ maxIdleTimeBeforePruning: self.maxIdleTimeBeforePruning,
 on: databases.eventLoopGroup
 )
+
 return _FluentPostgresDriver(
- pool: pool,
- encoder: encoder,
- decoder: decoder
+ pool: elgPool,
+ encodingContext: self.encodingContext,
+ decodingContext: self.decodingContext,
+ sqlLogLevel: self.sqlLogLevel
 )
 }
 }
diff --git a/Sources/FluentPostgresDriver/FluentPostgresDatabase.swift b/Sources/FluentPostgresDriver/FluentPostgresDatabase.swift
index 4ecdbac..e54b10d 100644
--- a/Sources/FluentPostgresDriver/FluentPostgresDatabase.swift
+++ b/Sources/FluentPostgresDriver/FluentPostgresDatabase.swift
@@ -1,147 +1,155 @@
+import FluentKit
 import FluentSQL
+import Logging
+import PostgresKit
+import PostgresNIO
+import SQLKit
 
-struct _FluentPostgresDatabase {
- let database: PostgresDatabase
+struct _FluentPostgresDatabase {
+ let database: any SQLDatabase
 let context: DatabaseContext
- let encoder: PostgresDataEncoder
- let decoder: PostgresDataDecoder
+ let encodingContext: PostgresEncodingContext
+ let decodingContext: PostgresDecodingContext
 let inTransaction: Bool
 }
 
 extension _FluentPostgresDatabase: Database {
 func execute(
 query: DatabaseQuery,
- onOutput: @escaping (DatabaseOutput) -> ()
+ onOutput: @escaping @Sendable (any DatabaseOutput) -> Void
 ) -> EventLoopFuture {
- var expression = SQLQueryConverter(delegate: PostgresConverterDelegate())
- .convert(query)
- switch query.action {
- case .create:
- expression = PostgresReturningID(
- base: expression,
- idKey: query.customIDKey ?? .id
- )
- default: break
- }
- let (sql, binds) = self.serialize(expression)
- do {
- return try self.query(sql, binds.map { try self.encoder.encode(0γγ«) }) {
- onOutput(0γγ«.databaseOutput(using: self.decoder))
- }
- } catch {
- return self.eventLoop.makeFailedFuture(error)
+ var expression = SQLQueryConverter(delegate: PostgresConverterDelegate()).convert(query)
+
+ /// For `.create` query actions, we want to return the generated IDs, unless the `customIDKey` is the
+ /// empty string, which we use as a very hacky signal for "we don't implement this for composite IDs yet".
+ if case .create = query.action, query.customIDKey != .some(.string("")) {
+ expression = SQLKit.SQLList([expression, SQLReturning(.init((query.customIDKey ?? .id).description))], separator: SQLRaw(" "))
 }
+
+ return self.execute(sql: expression, { onOutput(0γγ«.databaseOutput()) })
 }
 
 func execute(schema: DatabaseSchema) -> EventLoopFuture {
- let expression = SQLSchemaConverter(delegate: PostgresConverterDelegate())
- .convert(schema)
- let (sql, binds) = self.serialize(expression)
- do {
- return try self.query(sql, binds.map { try self.encoder.encode(0γγ«) }) {
- fatalError("unexpected row: \(0γγ«)")
- }
- } catch {
- return self.eventLoop.makeFailedFuture(error)
- }
+ let expression = SQLSchemaConverter(delegate: PostgresConverterDelegate()).convert(schema)
+
+ return self.execute(
+ sql: expression,
+ // N.B.: Don't fatalError() here; what're users supposed to do about it?
+ { self.logger.debug("Unexpected row returned from schema query: \(0γγ«)") }
+ )
 }
 
 func execute(enum e: DatabaseEnum) -> EventLoopFuture {
 switch e.action {
 case .create:
- let builder = self.sql().create(enum: e.name)
- for c in e.createCases {
- _ = builder.value(c)
- }
- return builder.run()
+ return e.createCases.reduce(self.create(enum: e.name)) { 0γγ«.value(1γγ«) }.run()
 case .update:
 if !e.deleteCases.isEmpty {
- self.logger.error("PostgreSQL does not support deleting enum cases.")
+ self.logger.debug("PostgreSQL does not support deleting enum cases.")
 }
 guard !e.createCases.isEmpty else {
 return self.eventLoop.makeSucceededFuture(())
 }
- let builder = self.sql().alter(enum: e.name)
- for create in e.createCases {
- _ = builder.add(value: create)
- }
- return builder.run()
+
+ return self.eventLoop.flatten(
+ e.createCases.map { create in
+ self.alter(enum: e.name).add(value: create).run()
+ }
+ )
 case .delete:
- return self.sql().drop(enum: e.name).run()
+ return self.drop(enum: e.name).run()
 }
 }
 
- func transaction(_ closure: @escaping (Database) -> EventLoopFuture) -> EventLoopFuture {
+ func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture {
 guard !self.inTransaction else {
 return closure(self)
 }
- return self.database.withConnection { conn in
- conn.simpleQuery("BEGIN").flatMap { _ in
- let db = _FluentPostgresDatabase(
- database: conn,
- context: self.context,
- encoder: self.encoder,
- decoder: self.decoder,
- inTransaction: true
+ return self.withConnection { conn in
+ guard let sqlConn = conn as? any SQLDatabase else {
+ fatalError(
+ """
+ Connection yielded by a Fluent+Postgres database is not also an SQLDatabase.
+ This is a bug in Fluent; please report it at https://github.com/vapor/fluent-postgres-driver/issues
+ """
 )
- return closure(db).flatMap { result in
- conn.simpleQuery("COMMIT").map { _ in
- result
- }
+ }
+ return sqlConn.raw("BEGIN").run().flatMap {
+ closure(conn).flatMap { result in
+ sqlConn.raw("COMMIT").run().and(value: result).map { 1γγ« }
 }.flatMapError { error in
- conn.simpleQuery("ROLLBACK").flatMapThrowing { _ in
- throw error
- }
+ sqlConn.raw("ROLLBACK").run().flatMapThrowing { throw error }
 }
 }
 }
 }
- 
- func withConnection(_ closure: @escaping (Database) -> EventLoopFuture) -> EventLoopFuture {
- self.database.withConnection {
- closure(_FluentPostgresDatabase(
- database: 0,γγ«
- context: self.context,
- encoder: self.encoder,
- decoder: self.decoder,
- inTransaction: self.inTransaction
- ))
+
+ func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture {
+ self.withConnection { (underlying: any PostgresDatabase) in
+ closure(
+ _FluentPostgresDatabase(
+ database: underlying.sql(
+ encodingContext: self.encodingContext,
+ decodingContext: self.decodingContext,
+ queryLogLevel: self.database.queryLogLevel
+ ),
+ context: self.context,
+ encodingContext: self.encodingContext,
+ decodingContext: self.decodingContext,
+ inTransaction: true
+ )
+ )
 }
 }
 }
 
-extension _FluentPostgresDatabase: SQLDatabase {
- var dialect: SQLDialect {
- PostgresDialect()
+extension _FluentPostgresDatabase: TransactionControlDatabase {
+ func beginTransaction() -> EventLoopFuture {
+ self.raw("BEGIN").run()
 }
- 
- public func execute(
- sql query: SQLExpression,
- _ onRow: @escaping (SQLRow) -> ()
- ) -> EventLoopFuture {
- self.sql(encoder: encoder, decoder: decoder).execute(sql: query, onRow)
+
+ func commitTransaction() -> EventLoopFuture {
+ self.raw("COMMIT").run()
+ }
+
+ func rollbackTransaction() -> EventLoopFuture {
+ self.raw("ROLLBACK").run()
 }
 }
 
-extension _FluentPostgresDatabase: PostgresDatabase {
- func send(_ request: PostgresRequest, logger: Logger) -> EventLoopFuture {
- self.database.send(request, logger: logger)
+extension _FluentPostgresDatabase: SQLDatabase {
+ var version: (any SQLDatabaseReportedVersion)? { self.database.version }
+ var dialect: any SQLDialect { self.database.dialect }
+ var queryLogLevel: Logger.Level? { self.database.queryLogLevel }
+
+ func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> Void) -> EventLoopFuture {
+ self.database.execute(sql: query, onRow)
 }
- 
- func withConnection(_ closure: @escaping (PostgresConnection) -> EventLoopFuture) -> EventLoopFuture {
- self.database.withConnection(closure)
+
+ func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> Void) async throws {
+ try await self.database.execute(sql: query, onRow)
+ }
+
+ func withSession(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> R) async throws -> R {
+ try await self.database.withSession(closure)
 }
 }
 
-private struct PostgresReturningID: SQLExpression {
- let base: SQLExpression
- let idKey: FieldKey
+extension _FluentPostgresDatabase: PostgresDatabase {
+ func send(_ request: any PostgresRequest, logger: Logger) -> EventLoopFuture {
+ self.withConnection { 0γγ«.send(request, logger: logger) }
+ }
 
- func serialize(to serializer: inout SQLSerializer) {
- serializer.statement {
- 0γγ«.append(self.base)
- 0γγ«.append("RETURNING")
- 0γγ«.append(SQLIdentifier(self.idKey.description))
+ func withConnection(_ closure: @escaping (PostgresConnection) -> EventLoopFuture) -> EventLoopFuture {
+ guard let psqlDb: any PostgresDatabase = self.database as? any PostgresDatabase else {
+ fatalError(
+ """
+ Connection yielded by a Fluent+Postgres database is not also a PostgresDatabase.
+ This is a bug in Fluent; please report it at https://github.com/vapor/fluent-postgres-driver/issues
+ """
+ )
 }
+
+ return psqlDb.withConnection(closure)
 }
 }
diff --git a/Sources/FluentPostgresDriver/FluentPostgresDriver.swift b/Sources/FluentPostgresDriver/FluentPostgresDriver.swift
index 3f4d9fe..53ebcb0 100644
--- a/Sources/FluentPostgresDriver/FluentPostgresDriver.swift
+++ b/Sources/FluentPostgresDriver/FluentPostgresDriver.swift
@@ -1,27 +1,34 @@
-enum FluentPostgresError: Error {
- case invalidURL(String)
-}
+import AsyncKit
+import FluentKit
+import Logging
+import NIOCore
+import PostgresKit
 
-struct _FluentPostgresDriver: DatabaseDriver {
+/// Marked `@unchecked Sendable` to silence warning about `PostgresConnectionSource`
+struct _FluentPostgresDriver: DatabaseDriver, @unchecked Sendable {
 let pool: EventLoopGroupConnectionPool
- let encoder: PostgresDataEncoder
- let decoder: PostgresDataDecoder
- 
- var eventLoopGroup: EventLoopGroup {
- self.pool.eventLoopGroup
- }
- 
- func makeDatabase(with context: DatabaseContext) -> Database {
+ let encodingContext: PostgresEncodingContext
+ let decodingContext: PostgresDecodingContext
+ let sqlLogLevel: Logger.Level
+
+ func makeDatabase(with context: DatabaseContext) -> any Database {
 _FluentPostgresDatabase(
- database: self.pool.pool(for: context.eventLoop).database(logger: context.logger),
+ database: self.pool
+ .pool(for: context.eventLoop)
+ .database(logger: context.logger)
+ .sql(encodingContext: self.encodingContext, decodingContext: self.decodingContext, queryLogLevel: self.sqlLogLevel),
 context: context,
- encoder: self.encoder,
- decoder: self.decoder,
+ encodingContext: self.encodingContext,
+ decodingContext: self.decodingContext,
 inTransaction: false
 )
 }
- 
+
 func shutdown() {
- self.pool.shutdown()
+ try? self.pool.syncShutdownGracefully()
+ }
+
+ func shutdownAsync() async {
+ try? await self.pool.shutdownAsync()
 }
 }
diff --git a/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift b/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift
index 7156cd2..ed525dd 100644
--- a/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift
+++ b/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift
@@ -1,64 +1,55 @@
+import FluentKit
 import FluentSQL
+import SQLKit
 
 struct PostgresConverterDelegate: SQLConverterDelegate {
- func customDataType(_ dataType: DatabaseSchema.DataType) -> SQLExpression? {
+ func customDataType(_ dataType: DatabaseSchema.DataType) -> (any SQLExpression)? {
 switch dataType {
 case .uuid:
- return SQLRaw("UUID")
+ SQLRaw("UUID")
 case .bool:
- return SQLRaw("BOOL")
+ SQLRaw("BOOL")
 case .data:
- return SQLRaw("BYTEA")
+ SQLRaw("BYTEA")
 case .date:
- return SQLRaw("DATE")
+ SQLRaw("DATE")
 case .datetime:
- return SQLRaw("TIMESTAMPTZ")
+ SQLRaw("TIMESTAMPTZ")
 case .double:
- return SQLRaw("DOUBLE PRECISION")
+ SQLRaw("DOUBLE PRECISION")
 case .dictionary:
- return SQLRaw("JSONB")
+ SQLRaw("JSONB")
 case .array(of: let type):
- if let type = type, let dataType = self.customDataType(type) {
- return SQLArrayDataType(dataType: dataType)
+ if let type, let dataType = self.customDataType(type) {
+ SQLArrayDataType(dataType: dataType)
 } else {
- return SQLRaw("JSONB")
+ SQLRaw("JSONB")
 }
 case .enum(let value):
- return SQLIdentifier(value.name)
+ SQLIdentifier(value.name)
 case .int8, .uint8:
- return SQLRaw(#""char""#)
+ SQLIdentifier("char")
 case .int16, .uint16:
- return SQLRaw("SMALLINT")
+ SQLRaw("SMALLINT")
 case .int32, .uint32:
- return SQLRaw("INT")
+ SQLRaw("INT")
 case .int64, .uint64:
- return SQLRaw("BIGINT")
+ SQLRaw("BIGINT")
 case .string:
- return SQLRaw("TEXT")
+ SQLRaw("TEXT")
 case .time:
- return SQLRaw("TIME")
+ SQLRaw("TIME")
 case .float:
- return SQLRaw("FLOAT")
+ SQLRaw("FLOAT")
 case .custom:
- return nil
- }
- }
-
- func nestedFieldExpression(_ column: String, _ path: [String]) -> SQLExpression {
- switch path.count {
- case 1:
- return SQLRaw("\(column)->>'\(path[0])'")
- case 2...:
- let inner = path[0..")
- return SQLRaw("\(column)->\(inner)->>'\(path.last!)'")
- default:
- fatalError()
+ nil
 }
 }
 }
 
 private struct SQLArrayDataType: SQLExpression {
- let dataType: SQLExpression
+ let dataType: any SQLExpression
+
 func serialize(to serializer: inout SQLSerializer) {
 self.dataType.serialize(to: &serializer)
 serializer.write("[]")
diff --git a/Sources/FluentPostgresDriver/PostgresError+Database.swift b/Sources/FluentPostgresDriver/PostgresError+Database.swift
index 4ea2df3..595df63 100644
--- a/Sources/FluentPostgresDriver/PostgresError+Database.swift
+++ b/Sources/FluentPostgresDriver/PostgresError+Database.swift
@@ -1,73 +1,111 @@
+import FluentKit
 import FluentSQL
+import PostgresKit
+import PostgresNIO
 
-extension PostgresError: DatabaseError {
- public var isSyntaxError: Bool {
- switch self.code {
+extension PostgresError.Code {
+ fileprivate var isSyntaxError: Bool {
+ switch self {
 case .syntaxErrorOrAccessRuleViolation,
- .syntaxError,
- .insufficientPrivilege,
- .cannotCoerce,
- .groupingError,
- .windowingError,
- .invalidRecursion,
- .invalidForeignKey,
- .invalidName,
- .nameTooLong,
- .reservedName,
- .datatypeMismatch,
- .indeterminateDatatype,
- .collationMismatch,
- .indeterminateCollation,
- .wrongObjectType,
- .undefinedColumn,
- .undefinedFunction,
- .undefinedTable,
- .undefinedParameter,
- .undefinedObject,
- .duplicateColumn,
- .duplicateCursor,
- .duplicateDatabase,
- .duplicateFunction,
- .duplicatePreparedStatement,
- .duplicateSchema,
- .duplicateTable,
- .duplicateAlias,
- .duplicateObject,
- .ambiguousColumn,
- .ambiguousFunction,
- .ambiguousParameter,
- .ambiguousAlias,
- .invalidColumnReference,
- .invalidColumnDefinition,
- .invalidCursorDefinition,
- .invalidDatabaseDefinition,
- .invalidFunctionDefinition,
- .invalidPreparedStatementDefinition,
- .invalidSchemaDefinition,
- .invalidTableDefinition,
- .invalidObjectDefinition:
- return true
+ .syntaxError,
+ .insufficientPrivilege,
+ .cannotCoerce,
+ .groupingError,
+ .windowingError,
+ .invalidRecursion,
+ .invalidForeignKey,
+ .invalidName,
+ .nameTooLong,
+ .reservedName,
+ .datatypeMismatch,
+ .indeterminateDatatype,
+ .collationMismatch,
+ .indeterminateCollation,
+ .wrongObjectType,
+ .undefinedColumn,
+ .undefinedFunction,
+ .undefinedTable,
+ .undefinedParameter,
+ .undefinedObject,
+ .duplicateColumn,
+ .duplicateCursor,
+ .duplicateDatabase,
+ .duplicateFunction,
+ .duplicatePreparedStatement,
+ .duplicateSchema,
+ .duplicateTable,
+ .duplicateAlias,
+ .duplicateObject,
+ .ambiguousColumn,
+ .ambiguousFunction,
+ .ambiguousParameter,
+ .ambiguousAlias,
+ .invalidColumnReference,
+ .invalidColumnDefinition,
+ .invalidCursorDefinition,
+ .invalidDatabaseDefinition,
+ .invalidFunctionDefinition,
+ .invalidPreparedStatementDefinition,
+ .invalidSchemaDefinition,
+ .invalidTableDefinition,
+ .invalidObjectDefinition:
+ true
+ default:
+ false
+ }
+ }
+
+ fileprivate var isConstraintFailure: Bool {
+ switch self {
+ case .integrityConstraintViolation,
+ .restrictViolation,
+ .notNullViolation,
+ .foreignKeyViolation,
+ .uniqueViolation,
+ .checkViolation,
+ .exclusionViolation:
+ true
 default:
- return false
+ false
 }
 }
- 
+}
+
+// Used for DatabaseError conformance
+extension PostgresError {
+ public var isSyntaxError: Bool { self.code.isSyntaxError }
 public var isConnectionClosed: Bool {
- return false
+ switch self {
+ case .connectionClosed: true
+ default: false
+ }
 }
- 
+ public var isConstraintFailure: Bool { self.code.isConstraintFailure }
+}
+
+// Used for DatabaseError conformance
+extension PSQLError {
+ public var isSyntaxError: Bool {
+ switch self.code {
+ case .server: self.serverInfo?[.sqlState].map { PostgresError.Code(raw: 0γγ«).isSyntaxError } ?? false
+ default: false
+ }
+ }
+
+ public var isConnectionClosed: Bool {
+ switch self.code {
+ case .serverClosedConnection, .clientClosedConnection: true
+ default: false
+ }
+ }
+
 public var isConstraintFailure: Bool {
 switch self.code {
- case .integrityConstraintViolation,
- .restrictViolation,
- .notNullViolation,
- .foreignKeyViolation,
- .uniqueViolation,
- .checkViolation,
- .exclusionViolation:
- return true
- default:
- return false
+ case .server: self.serverInfo?[.sqlState].map { PostgresError.Code(raw: 0γγ«).isConstraintFailure } ?? false
+ default: false
 }
 }
 }
+
+extension PostgresError: @retroactive DatabaseError {}
+extension PSQLError: @retroactive DatabaseError {}
diff --git a/Sources/FluentPostgresDriver/PostgresRow+Database.swift b/Sources/FluentPostgresDriver/PostgresRow+Database.swift
index 1747bf1..beea4b6 100644
--- a/Sources/FluentPostgresDriver/PostgresRow+Database.swift
+++ b/Sources/FluentPostgresDriver/PostgresRow+Database.swift
@@ -1,89 +1,39 @@
-import class Foundation.JSONDecoder
+import FluentKit
+import PostgresKit
+import PostgresNIO
+import SQLKit
 
-extension PostgresRow {
- internal func databaseOutput(using decoder: PostgresDataDecoder) -> DatabaseOutput {
- _PostgresDatabaseOutput(
- row: self,
- decoder: decoder
- )
+extension SQLRow {
+ func databaseOutput() -> some DatabaseOutput {
+ _PostgresDatabaseOutput(row: self, schema: nil)
 }
 }
 
 private struct _PostgresDatabaseOutput: DatabaseOutput {
- let row: PostgresRow
- let decoder: PostgresDataDecoder
+ let row: any SQLRow
+ let schema: String?
 
 var description: String {
- self.row.description
+ String(describing: self.row)
 }
 
- func decodeNil(_ key: FieldKey) throws -> Bool {
- if let data = self.row.column(self.columnName(key)) {
- return data.type == .null
- } else {
- return true
- }
+ private func adjust(key: FieldKey) -> FieldKey {
+ self.schema.map { .prefix(.prefix(.string(0γγ«), "_"), key) } ?? key
 }
 
- func contains(_ key: FieldKey) -> Bool {
- self.row.column(self.columnName(key)) != nil
- }
-
- func schema(_ schema: String) -> DatabaseOutput {
- _SchemaDatabaseOutput(
- output: self,
- schema: schema
- )
- }
-
- func decode(_ key: FieldKey, as type: T.Type) throws -> T
- where T: Decodable
- {
- try self.row.sql(decoder: self.decoder)
- .decode(column: self.columnName(key), as: T.self)
- }
-
- func columnName(_ key: FieldKey) -> String {
- switch key {
- case .id:
- return "id"
- case .aggregate:
- return key.description
- case .string(let name):
- return name
- case .prefix(let prefix, let key):
- return self.columnName(prefix) + self.columnName(key)
- }
- }
-}
-
-private struct _SchemaDatabaseOutput: DatabaseOutput {
- let output: DatabaseOutput
- let schema: String
-
- var description: String {
- self.output.description
- }
-
- func schema(_ schema: String) -> DatabaseOutput {
- self.output.schema(schema)
+ func schema(_ schema: String) -> any DatabaseOutput {
+ _PostgresDatabaseOutput(row: self.row, schema: schema)
 }
 
 func contains(_ key: FieldKey) -> Bool {
- self.output.contains(self.key(key))
+ self.row.contains(column: self.adjust(key: key).description)
 }
 
 func decodeNil(_ key: FieldKey) throws -> Bool {
- try self.output.decodeNil(self.key(key))
- }
-
- func decode(_ key: FieldKey, as type: T.Type) throws -> T
- where T: Decodable
- {
- try self.output.decode(self.key(key), as: T.self)
+ try self.row.decodeNil(column: self.adjust(key: key).description)
 }
 
- private func key(_ key: FieldKey) -> FieldKey {
- .prefix(.string(self.schema + "_"), key)
+ func decode(_ key: FieldKey, as: T.Type) throws -> T {
+ try self.row.decode(column: self.adjust(key: key).description, as: T.self)
 }
 }
diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift
index 1fade7d..97ee68d 100644
--- a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift
+++ b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift
@@ -1,18 +1,72 @@
-import Logging
 import FluentBenchmark
+import FluentKit
 import FluentPostgresDriver
+import FluentSQL
+import Logging
+import PostgresKit
+import SQLKit
+import Testing
 import XCTest
 
-final class FluentPostgresDriverTests: XCTestCase {
- func testAll() throws { try self.benchmarker.testAll() }
- 
- #if Xcode
+func withDbs(_ closure: @escaping @Sendable (_ dbs: Databases, _ db: any Database) async throws -> Void) async throws {
+ let databases = Databases(threadPool: .singleton, on: MultiThreadedEventLoopGroup.singleton)
+
+ databases.use(.testPostgres(subconfig: "A"), as: .a)
+ databases.use(.testPostgres(subconfig: "B"), as: .b)
+
+ do {
+ let a = databases.database(.a, logger: .init(label: "test.fluent.a"), on: databases.eventLoopGroup.any())!
+ _ = try await (a as! any SQLDatabase).raw("drop schema if exists public cascade").run()
+ _ = try await (a as! any SQLDatabase).raw("create schema public").run()
+
+ let b = databases.database(.b, logger: .init(label: "test.fluent.b"), on: databases.eventLoopGroup.any())!
+ _ = try await (b as! any SQLDatabase).raw("drop schema if exists public cascade").run()
+ _ = try await (b as! any SQLDatabase).raw("create schema public").run()
+
+ try await closure(databases, a)
+ await databases.shutdownAsync()
+ } catch {
+ print(String(reflecting: error))
+ await databases.shutdownAsync()
+ throw error
+ }
+}
+
+final class FluentBenchmarksTests: XCTestCase {
+ var benchmarker: FluentBenchmarker { .init(databases: self.dbs) }
+ var dbs: Databases!
+
+ override func setUp() async throws {
+ try await super.setUp()
+
+ XCTAssert(isLoggingConfigured)
+ self.dbs = Databases(threadPool: .singleton, on: MultiThreadedEventLoopGroup.singleton)
+
+ self.dbs.use(.testPostgres(subconfig: "A"), as: .a)
+ self.dbs.use(.testPostgres(subconfig: "B"), as: .b)
+
+ let a = self.dbs.database(.a, logger: .init(label: "test.fluent.a"), on: self.dbs.eventLoopGroup.any())
+ _ = try await (a as! any PostgresDatabase).query("drop schema public cascade").get()
+ _ = try await (a as! any PostgresDatabase).query("create schema public").get()
+
+ let b = self.dbs.database(.b, logger: .init(label: "test.fluent.b"), on: self.dbs.eventLoopGroup.any())
+ _ = try await (b as! any PostgresDatabase).query("drop schema public cascade").get()
+ _ = try await (b as! any PostgresDatabase).query("create schema public").get()
+ }
+
+ override func tearDown() async throws {
+ await self.dbs.shutdownAsync()
+ try await super.tearDown()
+ }
+
 func testAggregate() throws { try self.benchmarker.testAggregate() }
 func testArray() throws { try self.benchmarker.testArray() }
 func testBatch() throws { try self.benchmarker.testBatch() }
+ func testChild() throws { try self.benchmarker.testChildren() }
 func testChildren() throws { try self.benchmarker.testChildren() }
 func testChunk() throws { try self.benchmarker.testChunk() }
 func testCodable() throws { try self.benchmarker.testCodable() }
+ func testCompositeID() throws { try self.benchmarker.testCompositeID() }
 func testCRUD() throws { try self.benchmarker.testCRUD() }
 func testEagerLoad() throws { try self.benchmarker.testEagerLoad() }
 func testEnum() throws { try self.benchmarker.testEnum() }
@@ -33,188 +87,257 @@ final class FluentPostgresDriverTests: XCTestCase {
 func testSiblings() throws { try self.benchmarker.testSiblings() }
 func testSoftDelete() throws { try self.benchmarker.testSoftDelete() }
 func testSort() throws { try self.benchmarker.testSort() }
+ func testSQL() throws { try self.benchmarker.testSQL() }
 func testTimestamp() throws { try self.benchmarker.testTimestamp() }
 func testTransaction() throws { try self.benchmarker.testTransaction() }
 func testUnique() throws { try self.benchmarker.testUnique() }
- #endif
-
- func testDatabaseError() throws {
- let sql = (self.db as! SQLDatabase)
- do {
- try sql.raw("asd").run().wait()
- } catch let error as DatabaseError where error.isSyntaxError {
- // PASS
- } catch {
- XCTFail("\(error)")
- }
- do {
- try sql.raw("CREATE TABLE foo (name TEXT UNIQUE)").run().wait()
- try sql.raw("INSERT INTO foo (name) VALUES ('bar')").run().wait()
- try sql.raw("INSERT INTO foo (name) VALUES ('bar')").run().wait()
- } catch let error as DatabaseError where error.isConstraintFailure {
- // pass
- } catch {
- XCTFail("\(error)")
- }
- }
- 
- func testBlob() throws {
- final class Foo: Model {
- static let schema = "foos"
+}
 
- @ID(key: "id")
- var id: Int?
+@Suite(.serialized)
+struct AllSuites {}
 
- @Field(key: "data")
- var data: [UInt8]
+extension AllSuites {
+@Suite
+struct FluentPostgresDriverTests {
+ init() {
+ #expect(isLoggingConfigured)
+ }
 
- init() { }
+ #if !compiler(<6.1) // #expect(throws:) doesn't return the Error until 6.1 + @Test + func databaseError() async throws { + try await withDbs { dbs, db in + let sql1 = (db as! any SQLDatabase) + let error1 = await #expect(throws: (any Error).self) { try await sql1.raw("asdf").run() } + #expect((error1 as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: error1))") + #expect(!((error1 as? any DatabaseError)?.isConstraintFailure ?? true), "\(String(reflecting: error1))") + #expect(!((error1 as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error1))") + + let sql2 = (dbs.database(.a, logger: .init(label: "test.fluent.a"), on: dbs.eventLoopGroup.any())!) as! any SQLDatabase + try await sql2.drop(table: "foo").ifExists().run() + try await sql2.create(table: "foo").column("name", type: .text, .unique).run() + try await sql2.insert(into: "foo").columns("name").values("bar").run() + let error2 = await #expect(throws: (any Error).self) { try await sql2.insert(into: "foo").columns("name").values("bar").run() } + #expect(!((error2 as? any DatabaseError)?.isSyntaxError ?? true), "\(String(reflecting: error2))") + #expect((error2 as? any DatabaseError)?.isConstraintFailure ?? false, "\(String(reflecting: error2))") + #expect(!((error2 as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error2))") } - struct CreateFoo: Migration { - func prepare(on database: Database) -> EventLoopFuture {
- return database.schema("foos")
+ // Disabled until we figure out why it hangs instead of throwing an error.
+ //let postgres = (self.dbs.database(.a, logger: .init(label: "test.fluent.a"), on: self.eventLoopGroup.any())!) as! any PostgresDatabase
+ //await XCTAssertThrowsErrorAsync(try await postgres.withConnection { conn in
+ // conn.close().flatMap {
+ // conn.sql().insert(into: "foo").columns("name").values("bar").run()
+ // }
+ //}.get()) {
+ // XCTAssertTrue((0γγ« as? any DatabaseError)?.isConnectionClosed ?? false, "\(String(reflecting: 0γγ«))")
+ // XCTAssertFalse((0γγ« as? any DatabaseError)?.isSyntaxError ?? true, "\(String(reflecting: 0γγ«))")
+ // XCTAssertFalse((0γγ« as? any DatabaseError)?.isConstraintFailure ?? true, "\(String(reflecting: 0γγ«))")
+ //}
+ }
+ #endif
+
+ @Test
+ func blob() async throws {
+ struct CreateFoo: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ try await database.schema("foos")
 .field("id", .int, .identifier(auto: true))
 .field("data", .data, .required)
 .create()
 }
 
- func revert(on database: Database) -> EventLoopFuture {
- return database.schema("foos").delete()
+ func revert(on database: any Database) async throws {
+ try await database.schema("foos").delete()
 }
 }
 
- try CreateFoo().prepare(on: self.db).wait()
- try CreateFoo().revert(on: self.db).wait()
+ try await withDbs { _, db in
+ try await CreateFoo().prepare(on: db)
+ try await CreateFoo().revert(on: db)
+ }
 }
 
- func testSaveModelWithBool() throws {
- final class Organization: Model {
+ @Test
+ func saveModelWithBool() async throws {
+ final class Organization: Model, @unchecked Sendable {
 static let schema = "orgs"
 
- @ID(custom: "id", generatedBy: .database)
- var id: Int?
-
- @Field(key: "disabled")
- var disabled: Bool
+ @ID(custom: "id", generatedBy: .database) var id: Int?
+ @Field(key: "disabled") var disabled: Bool
 
- init() { }
+ init() {}
 }
 
- struct CreateOrganization: Migration {
- func prepare(on database: Database) -> EventLoopFuture {
- return database.schema("orgs")
+ struct CreateOrganization: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ try await database.schema("orgs")
 .field("id", .int, .identifier(auto: true))
 .field("disabled", .bool, .required)
 .create()
 }
 
- func revert(on database: Database) -> EventLoopFuture {
- return database.schema("orgs").delete()
+ func revert(on database: any Database) async throws {
+ try await database.schema("orgs").delete()
 }
 }
 
- try CreateOrganization().prepare(on: self.db).wait()
- defer {
- try! CreateOrganization().revert(on: self.db).wait()
+ try await withDbs { _, db in
+ try await CreateOrganization().prepare(on: db)
+ do {
+ let new = Organization()
+ new.disabled = false
+ try await new.save(on: db)
+ } catch {
+ try? await CreateOrganization().revert(on: db)
+ throw error
+ }
+ try await CreateOrganization().revert(on: db)
 }
-
- let new = Organization()
- new.disabled = false
- try new.save(on: self.db).wait()
 }
 
- func testCustomJSON() throws {
- try EventMigration().prepare(on: self.db).wait()
- defer { try! EventMigration().revert(on: self.db).wait() }
-
- let jsonEncoder = JSONEncoder()
- jsonEncoder.dateEncodingStrategy = .iso8601
- let jsonDecoder = JSONDecoder()
- jsonDecoder.dateDecodingStrategy = .iso8601
-
- let configuration = PostgresConfiguration(
- hostname: env("POSTGRES_HOSTNAME_A") ?? "localhost",
- port: env("POSTGRES_PORT_A").flatMap(Int.init) ?? PostgresConfiguration.ianaPortNumber,
- username: "vapor_username",
- password: "vapor_password",
- database: env("POSTGRES_DATABASE_A") ?? "vapor_database"
- )
- self.dbs.use(.postgres(
- configuration: configuration,
- encoder: PostgresDataEncoder(json: jsonEncoder),
- decoder: PostgresDataDecoder(json: jsonDecoder)
- ), as: .iso8601)
- let db = self.dbs.database(
- .iso8601,
- logger: .init(label: "test"),
- on: self.eventLoopGroup.next()
- )!
-
- let date = Date()
- let event = Event()
- event.id = 1
- event.metadata = Metadata(createdAt: date)
- try event.save(on: db).wait()
-
- let rows = try EventStringlyTyped.query(on: db).filter(\.$id == 1).all().wait()
- let expected = ISO8601DateFormatter().string(from: date)
- XCTAssertEqual(rows[0].metadata["createdAt"], expected)
+ @Test
+ func customJSON() async throws {
+ try await withDbs { dbs, _ in
+ let jsonEncoder = JSONEncoder()
+ jsonEncoder.dateEncodingStrategy = .iso8601
+ let jsonDecoder = JSONDecoder()
+ jsonDecoder.dateDecodingStrategy = .iso8601
+
+ dbs.use(
+ .testPostgres(
+ subconfig: "A",
+ encodingContext: .init(jsonEncoder: jsonEncoder),
+ decodingContext: .init(jsonDecoder: jsonDecoder)
+ ),
+ as: .iso8601
+ )
+ let db = dbs.database(
+ .iso8601,
+ logger: .init(label: "test"),
+ on: dbs.eventLoopGroup.any()
+ )!
+
+ try await EventMigration().prepare(on: db)
+ do {
+ let date = Date()
+ let event = Event()
+ event.id = 1
+ event.metadata = Metadata(createdAt: date)
+ try await event.save(on: db)
+
+ let rows = try await EventStringlyTyped.query(on: db).filter(\.$id == 1).all()
+ let expected = ISO8601DateFormatter().string(from: date)
+ #expect(rows[0].metadata["createdAt"] == expected)
+ } catch {
+ try? await EventMigration().revert(on: db)
+ throw error
+ }
+ try await EventMigration().revert(on: db)
+ }
 }
 
- 
- var benchmarker: FluentBenchmarker {
- return .init(databases: self.dbs)
- }
- var eventLoopGroup: EventLoopGroup!
- var threadPool: NIOThreadPool!
- var dbs: Databases!
- var db: Database {
- self.benchmarker.database
- }
- var postgres: PostgresDatabase {
- self.db as! PostgresDatabase
+ @Test
+ func enumAddingMultipleCases() async throws {
+ try await withDbs { _, db in
+ try await EnumMigration().prepare(on: db)
+ do {
+ try await EventWithFooMigration().prepare(on: db)
+ do {
+ let event = EventWithFoo()
+ event.foobar = .foo
+ try await event.save(on: db)
+
+ await #expect(throws: Never.self) { try await EnumAddMultipleCasesMigration().prepare(on: db) }
+
+ event.foobar = .baz
+ await #expect(throws: Never.self) { try await event.update(on: db) }
+ event.foobar = .qux
+ await #expect(throws: Never.self) { try await event.update(on: db) }
+
+ await #expect(throws: Never.self) { try await EnumAddMultipleCasesMigration().revert(on: db) }
+ } catch {
+ try? await EventWithFooMigration().revert(on: db)
+ throw error
+ }
+ } catch {
+ try? await EnumMigration().revert(on: db)
+ throw error
+ }
+ }
 }
- 
- override func setUpWithError() throws {
- try super.setUpWithError()
- 
- let aConfig = PostgresConfiguration(
- hostname: env("POSTGRES_HOSTNAME_A") ?? "localhost",
- port: env("POSTGRES_PORT_A").flatMap(Int.init) ?? PostgresConfiguration.ianaPortNumber,
- username: env("POSTGRES_USERNAME_A") ?? "vapor_username",
- password: env("POSTGRES_PASSWORD_A") ?? "vapor_password",
- database: env("POSTGRES_DATABASE_A") ?? "vapor_database"
- )
- let bConfig = PostgresConfiguration(
- hostname: env("POSTGRES_HOSTNAME_B") ?? "localhost",
- port: env("POSTGRES_PORT_B").flatMap(Int.init) ?? PostgresConfiguration.ianaPortNumber,
- username: env("POSTGRES_USERNAME_B") ?? "vapor_username",
- password: env("POSTGRES_PASSWORD_B") ?? "vapor_password",
- database: env("POSTGRES_DATABASE_B") ?? "vapor_database"
- )
- XCTAssert(isLoggingConfigured)
- self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
- self.threadPool = NIOThreadPool(numberOfThreads: System.coreCount)
- self.dbs = Databases(threadPool: threadPool, on: self.eventLoopGroup)
 
- self.dbs.use(.postgres(configuration: aConfig), as: .a)
- self.dbs.use(.postgres(configuration: bConfig), as: .b)
-
- let a = self.dbs.database(.a, logger: Logger(label: "test.fluent.a"), on: self.eventLoopGroup.next())
- _ = try (a as! PostgresDatabase).query("drop schema public cascade").wait()
- _ = try (a as! PostgresDatabase).query("create schema public").wait()
-
- let b = self.dbs.database(.b, logger: Logger(label: "test.fluent.b"), on: self.eventLoopGroup.next())
- _ = try (b as! PostgresDatabase).query("drop schema public cascade").wait()
- _ = try (b as! PostgresDatabase).query("create schema public").wait()
+ @Test
+ func encodingArrayOfModels() async throws {
+ final class Elem: Model, ExpressibleByIntegerLiteral, @unchecked Sendable {
+ static let schema = ""
+ @ID(custom: .id) var id: Int?
+ init() {}
+ init(integerLiteral l: Int) { self.id = l }
+ }
+ final class Seq: Model, ExpressibleByNilLiteral, ExpressibleByArrayLiteral, @unchecked Sendable {
+ static let schema = "seqs"
+ @ID(custom: .id) var id: Int?
+ @OptionalField(key: "list") var list: [Elem]?
+ init() {}
+ init(nilLiteral: ()) { self.list = nil }
+ init(arrayLiteral el: Elem...) { self.list = el }
+ }
+ try await withDbs { _, db in
+ do {
+ try await db.schema(Seq.schema).field(.id, .int, .identifier(auto: true)).field("list", .sql(embed: "JSONB[]")).create()
+
+ let s1: Seq = [1, 2]
+ let s2: Seq = nil
+ try await s1.create(on: db)
+ try await s2.create(on: db)
+
+ // Make sure it went into the DB as "array of jsonb" rather than as "array of one jsonb containing array" or such.
+ let raws = try await (db as! any SQLDatabase).raw("SELECT array_to_json(list)::text t FROM seqs").all().map {
+ try 0γγ«.decode(column: "t", as: String?.self)
+ }
+ #expect(raws == [#"[{"id": 1},{"id": 2}]"#, nil])
+
+ // Make sure it round-trips through Fluent.
+ let seqs = try await Seq.query(on: db).all()
+
+ #expect(seqs.count == 2)
+ #expect(seqs.dropFirst(0).first?.id == s1.id)
+ #expect(seqs.dropFirst(0).first?.list?.map(\.id) == s1.list?.map(\.id))
+ #expect(seqs.dropFirst(1).first?.id == s2.id)
+ #expect(seqs.dropFirst(1).first?.list?.map(\.id) == s2.list?.map(\.id))
+ } catch let error {
+ Issue.record("caught error: \(String(reflecting: error))")
+ }
+ try await db.schema(Seq.schema).delete()
+ }
 }
+}
+}
+
+extension DatabaseConfigurationFactory {
+ static func testPostgres(
+ subconfig: String,
+ encodingContext: PostgresEncodingContext = .default,
+ decodingContext: PostgresDecodingContext = .default
+ ) -> Self {
+ let baseSubconfig = SQLPostgresConfiguration(
+ hostname: env("POSTGRES_HOSTNAME_\(subconfig)") ?? env("POSTGRES_HOSTNAME_A") ?? env("POSTGRES_HOSTNAME") ?? "localhost",
+ port: (env("POSTGRES_PORT_\(subconfig)") ?? env("POSTGRES_PORT_A") ?? env("POSTGRES_PORT")).flatMap(Int.init) ?? SQLPostgresConfiguration.ianaPortNumber,
+ username: env("POSTGRES_USER_\(subconfig)") ?? env("POSTGRES_USER_A") ?? env("POSTGRES_USER") ?? "test_username",
+ password: env("POSTGRES_PASSWORD_\(subconfig)") ?? env("POSTGRES_PASSWORD_A") ?? env("POSTGRES_PASSWORD") ?? "test_password",
+ database: env("POSTGRES_DB_\(subconfig)") ?? env("POSTGRES_DB_A") ?? env("POSTGRES_DB") ?? "test_database",
+ tls: try! .prefer(.init(configuration: .makeClientConfiguration()))
+ )
 
- override func tearDownWithError() throws {
- self.dbs.shutdown()
- try self.threadPool.syncShutdownGracefully()
- try self.eventLoopGroup.syncShutdownGracefully()
- try super.tearDownWithError()
+ return .postgres(
+ configuration: baseSubconfig,
+ connectionPoolTimeout: .seconds(30),
+ pruneInterval: .seconds(30),
+ maxIdleTimeBeforePruning: .seconds(60),
+ encodingContext: encodingContext,
+ decodingContext: decodingContext
+ )
 }
 }
 
@@ -224,15 +347,11 @@ extension DatabaseID {
 static let b = DatabaseID(string: "b")
 }
 
-func env(_ name: String) -> String? {
- ProcessInfo.processInfo.environment[name]
-}
-
 struct Metadata: Codable {
 let createdAt: Date
 }
 
-final class Event: Model {
+final class Event: Model, @unchecked Sendable {
 static let schema = "events"
 
 @ID(custom: "id", generatedBy: .database)
@@ -242,7 +361,7 @@ final class Event: Model {
 var metadata: Metadata
 }
 
-final class EventStringlyTyped: Model {
+final class EventStringlyTyped: Model, @unchecked Sendable {
 static let schema = "events"
 
 @ID(custom: "id", generatedBy: .database)
@@ -252,24 +371,115 @@ final class EventStringlyTyped: Model {
 var metadata: [String: String]
 }
 
-struct EventMigration: Migration {
- func prepare(on database: Database) -> EventLoopFuture {
- return database.schema(Event.schema)
+struct EventMigration: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ try await database.schema(Event.schema)
 .field("id", .int, .identifier(auto: true))
 .field("metadata", .json, .required)
 .create()
 }
 
- func revert(on database: Database) -> EventLoopFuture {
- return database.schema(Event.schema).delete()
+ func revert(on database: any Database) async throws {
+ try await database.schema(Event.schema).delete()
 }
 }
 
-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
+final class EventWithFoo: Model, @unchecked Sendable {
+ static let schema = "foobar_events"
+
+ @ID
+ var id: UUID?
+
+ @Enum(key: "foo")
+ var foobar: Foobar
+}
+
+enum Foobar: String, Codable {
+ static let schema = "foobars"
+ case foo
+ case bar
+ case baz
+ case qux
+}
+
+struct EventWithFooMigration: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ let foobar = try await database.enum(Foobar.schema).read()
+ try await database.schema(EventWithFoo.schema)
+ .id()
+ .field("foo", foobar, .required)
+ .create()
+ }
+
+ func revert(on database: any Database) async throws {
+ try await database.schema(EventWithFoo.schema).delete()
 }
+}
+
+struct EnumMigration: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ _ = try await database.enum(Foobar.schema)
+ .case("foo")
+ .case("bar")
+ .create()
+ }
+
+ func revert(on database: any Database) async throws {
+ try await database.enum(Foobar.schema).delete()
+ }
+}
+
+struct EnumAddMultipleCasesMigration: AsyncMigration {
+ func prepare(on database: any Database) async throws {
+ _ = try await database.enum(Foobar.schema)
+ .case("baz")
+ .case("qux")
+ .update()
+ }
+
+ func revert(on database: any Database) async throws {
+ _ = try await database.enum(Foobar.schema)
+ .deleteCase("baz")
+ .deleteCase("qux")
+ .update()
+ }
+}
+
+func env(_ e: String) -> String? {
+ ProcessInfo.processInfo.environment[e]
+}
+
+let isLoggingConfigured: Bool = {
+ LoggingSystem.bootstrap { QuickLogHandler(label: 0,γγ« level: env("LOG_LEVEL").flatMap { .init(rawValue: 0γγ«) } ?? .info) }
 return true
 }()
+
+struct QuickLogHandler: LogHandler {
+ private let label: String
+ var logLevel = Logger.Level.info, metadataProvider = LoggingSystem.metadataProvider, metadata = Logger.Metadata()
+ subscript(metadataKey key: String) -> Logger.Metadata.Value? { get { self.metadata[key] } set { self.metadata[key] = newValue } }
+ init(label: String, level: Logger.Level) { (self.label, self.logLevel) = (label, level) }
+ func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt) {
+ print("\(self.timestamp()) \(level) \(self.label) :\(self.prettify(metadata ?? [:]).map { " \(0γγ«)" } ?? "") [\(source)] \(message)")
+ }
+ private func prettify(_ metadata: Logger.Metadata) -> String? {
+ self.metadata.merging(self.metadataProvider?.get() ?? [:]) { 1γγ« }.merging(metadata) { 1γγ« }.sorted { 0γγ«.0 < 1γγ«.0 }.map { "\(0γγ«)=\(1γγ«.mvDesc)" }.joined(separator: " ") + } + private func timestamp() -> String { .init(unsafeUninitializedCapacity: 255) { buffer in
+ var timestamp = time(nil)
+ return localtime(×tamp).map { strftime(buffer.baseAddress!, buffer.count, "%Y-%m-%dT%H:%M:%S%z", 0γγ«) } ?? buffer.initialize(fromContentsOf: "".utf8)
+ } }
+}
+extension Logger.MetadataValue {
+ var mvDesc: String { switch self {
+ case .dictionary(let dict): "[\(dict.mapValues(\.mvDesc).lazy.sorted { 0γγ«.0 < 1γγ«.0 }.map { "\(0γγ«): \(1γγ«)" }.joined(separator: ", "))]"
+ case .array(let list): "[\(list.map(\.mvDesc).joined(separator: ", "))]"
+ case .string(let str): #""\#(str)""#
+ case .stringConvertible(let repr): switch repr {
+ case let repr as Bool: "\(repr)"
+ case let repr as any FixedWidthInteger: "\(repr)"
+ case let repr as any BinaryFloatingPoint: "\(repr)"
+ default: #""\#(String(describing: repr))""#
+ }
+ } }
+}
diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift
new file mode 100644
index 0000000..50d2e35
--- /dev/null
+++ b/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift
@@ -0,0 +1,45 @@
+import FluentKit
+import Testing
+
+extension AllSuites {
+@Suite
+struct FluentPostgresTransactionControlTests {
+ init() { #expect(isLoggingConfigured) }
+
+ #if !compiler(<6.1) // #expect(throws:) doesn't return the Error until 6.1
+ @Test
+ func rollback() async throws {
+ try await withDbs { _, db in try await db.withConnection { db in
+ try await CreateTodo().prepare(on: db)
+ do {
+ try await (db as! any TransactionControlDatabase).beginTransaction().get()
+ let error = await #expect(throws: (any Error).self) {
+ try await [Todo(title: "Test"), Todo(title: "Test")].create(on: db)
+ try await (db as! any TransactionControlDatabase).commitTransaction().get()
+ }
+ #expect(String(reflecting: error).contains("sqlState: 23505"), "\(String(reflecting: error))")
+ try await (db as! any TransactionControlDatabase).rollbackTransaction().get()
+ #expect(try await Todo.query(on: db).count() == 0)
+ } catch {
+ try? await CreateTodo().revert(on: db)
+ throw error
+ }
+ try await CreateTodo().revert(on: db)
+ } }
+ }
+ #endif
+ 
+ final class Todo: Model, @unchecked Sendable {
+ static let schema = "todos"
+ @ID var id
+ @Field(key: "title") var title: String
+ init() {}
+ init(title: String) { self.title = title }
+ }
+
+ struct CreateTodo: AsyncMigration {
+ func prepare(on database: any Database) async throws { try await database.schema(Todo.schema).id().field("title", .string, .required).unique(on: "title").create() }
+ func revert(on database: any Database) async throws { try await database.schema(Todo.schema).delete() }
+ }
+}
+}
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 13b7a21..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-version: '3'
-
-services:
- a:
- image: postgres
- environment:
- POSTGRES_USER: vapor_username
- POSTGRES_DB: vapor_database
- POSTGRES_PASSWORD: vapor_password
- ports:
- - 5432:5432
- b:
- image: postgres
- environment:
- POSTGRES_USER: vapor_username
- POSTGRES_DB: vapor_database
- POSTGRES_PASSWORD: vapor_password
- ports:
- - 5433:5432