diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 15b15bb..cd903c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: container: swift:noble steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: { 'fetch-depth': 0 } - name: API breaking changes run: | @@ -74,10 +74,12 @@ jobs: POSTGRES_HOST_AUTH_METHOD: ${{ matrix.postgres-auth }} POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.postgres-auth }} steps: + - name: Ensure curl is available + run: apt-get update -y && apt-get install -y curl - name: Check out package - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Run all tests - run: swift test --sanitize=thread --enable-code-coverage + run: swift test --enable-code-coverage - name: Submit coverage report to Codecov.io uses: vapor/swift-codecov-action@v0.3 with: @@ -115,6 +117,6 @@ jobs: 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@v4 + uses: actions/checkout@v5 - name: Run all tests - run: swift test --sanitize=thread + run: swift test diff --git a/Package.swift b/Package.swift index 8035350..0ea5ef3 100644 --- a/Package.swift +++ b/Package.swift @@ -13,8 +13,9 @@ let package = Package( .library(name: "FluentPostgresDriver", targets: ["FluentPostgresDriver"]), ], dependencies: [ - .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.49.0"), - .package(url: "https://github.com/vapor/postgres-kit.git", from: "2.13.4"), + .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.0"), + .package(url: "https://github.com/vapor/async-kit.git", from: "1.21.0"), ], targets: [ .target( @@ -23,6 +24,7 @@ let package = Package( .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 ), @@ -39,6 +41,7 @@ let package = Package( var swiftSettings: [SwiftSetting] { [ .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("ConciseMagicFile"), .enableUpcomingFeature("ForwardTrailingClosures"), .enableUpcomingFeature("DisableOutwardActorInference"), diff --git a/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg b/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg index 4cc9947..79211b7 100644 --- a/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg +++ b/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg @@ -1,21 +1,25 @@ - - - + + + - - - + + + - - - + + + - - + + diff --git a/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift b/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift index 4055646..56ca567 100644 --- a/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift +++ b/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift @@ -25,11 +25,12 @@ extension DatabaseConfigurationFactory { encodingContext: PostgresEncodingContext = .default, decodingContext: PostgresDecodingContext = .default, sqlLogLevel: Logger.Level = .debug - ) throws -> DatabaseConfigurationFactory { + ) throws -> Self { .postgres( configuration: try .init(url: urlString), maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: nil, encodingContext: encodingContext, decodingContext: decodingContext, sqlLogLevel: sqlLogLevel @@ -54,11 +55,12 @@ extension DatabaseConfigurationFactory { encodingContext: PostgresEncodingContext = .default, decodingContext: PostgresDecodingContext = .default, sqlLogLevel: Logger.Level = .debug - ) throws -> DatabaseConfigurationFactory { + ) throws -> Self { .postgres( configuration: try .init(url: url), maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: nil, encodingContext: encodingContext, decodingContext: decodingContext, sqlLogLevel: sqlLogLevel @@ -81,12 +83,185 @@ extension DatabaseConfigurationFactory { encodingContext: PostgresEncodingContext, decodingContext: PostgresDecodingContext, sqlLogLevel: Logger.Level = .debug - ) -> DatabaseConfigurationFactory { + ) -> 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, + 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( + url: URL, + 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: url), + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, + 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: SQLPostgresConfiguration, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + pruneInterval: TimeAmount?, + maxIdleTimeBeforePruning: TimeAmount = .seconds(120), + encodingContext: PostgresEncodingContext, + decodingContext: PostgresDecodingContext, + sqlLogLevel: Logger.Level = .debug + ) -> Self { .init { FluentPostgresConfiguration( configuration: configuration, maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + pruningInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, encodingContext: encodingContext, decodingContext: decodingContext, sqlLogLevel: sqlLogLevel @@ -108,56 +283,68 @@ extension DatabaseConfigurationFactory { /// /// _ = DatabaseConfigurationFactory.postgres(configuration: .init(unixDomainSocketPath: "", username: "")) extension DatabaseConfigurationFactory { - /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)`` + /// ``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 - ) -> DatabaseConfigurationFactory { + ) -> Self { .postgres( configuration: configuration, maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, encodingContext: encodingContext, decodingContext: .default, sqlLogLevel: sqlLogLevel ) } - /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)`` + /// ``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 - ) -> DatabaseConfigurationFactory { + ) -> Self { .postgres( configuration: configuration, maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, encodingContext: .default, decodingContext: decodingContext, sqlLogLevel: sqlLogLevel ) } - /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)`` + /// ``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 - ) -> DatabaseConfigurationFactory { + ) -> Self { .postgres( configuration: configuration, maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, encodingContext: .default, decodingContext: .default, sqlLogLevel: sqlLogLevel @@ -171,6 +358,8 @@ struct FluentPostgresConfiguration let decodingContext: PostgresDecodingContext let sqlLogLevel: Logger.Level @@ -181,6 +370,8 @@ struct FluentPostgresConfiguration(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { + func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { guard !self.inTransaction else { return closure(self) } diff --git a/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift b/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift index 63e91bd..ed525dd 100644 --- a/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift +++ b/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift @@ -20,7 +20,7 @@ struct PostgresConverterDelegate: SQLConverterDelegate { case .dictionary: SQLRaw("JSONB") case .array(of: let type): - if let type = type, let dataType = self.customDataType(type) { + if let type, let dataType = self.customDataType(type) { SQLArrayDataType(dataType: dataType) } else { SQLRaw("JSONB") diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift index c268085..a45ebc7 100644 --- a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift +++ b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift @@ -1,6 +1,7 @@ import FluentBenchmark import FluentKit import FluentPostgresDriver +import FluentSQL import Logging import PostgresKit import SQLKit @@ -297,7 +298,7 @@ extension DatabaseConfigurationFactory { subconfig: String, encodingContext: PostgresEncodingContext = .default, decodingContext: PostgresDecodingContext = .default - ) -> DatabaseConfigurationFactory { + ) -> Self { let baseSubconfig = SQLPostgresConfiguration( hostname: env("POSTGRES_HOSTNAME_\(subconfig)") ?? "localhost", port: env("POSTGRES_PORT_\(subconfig)").flatMap(Int.init) ?? SQLPostgresConfiguration.ianaPortNumber, @@ -310,6 +311,8 @@ extension DatabaseConfigurationFactory { return .postgres( configuration: baseSubconfig, connectionPoolTimeout: .seconds(30), + pruneInterval: .seconds(30), + maxIdleTimeBeforePruning: .seconds(60), encodingContext: encodingContext, decodingContext: decodingContext )