diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 680623b..672e272 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -5,6 +5,7 @@ on: tags: - 'v*' - '[0-9]*' + workflow_dispatch: jobs: diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index a9773ae..d810b04 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -15,7 +15,7 @@ jobs: format_check_enabled : true broken_symlink_check_enabled : true unacceptable_language_check_enabled : true - shell_check_enabled : true + shell_check_enabled : false docs_check_enabled : false api_breakage_check_enabled : false license_header_check_enabled : false @@ -27,7 +27,7 @@ jobs: uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main with: local_swift_dependencies_check_enabled : true - headers_check_enabled : true + headers_check_enabled : false docc_warnings_check_enabled : true swiftlang_tests: diff --git a/Makefile b/Makefile index e165a15..ad60015 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,10 @@ SHELL=/bin/bash baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/main/scripts -check: symlinks language deps lint headers +check: symlinks language deps lint headers docc-warnings package + +package: + curl -s $(baseUrl)/check-swift-package.sh | bash symlinks: curl -s $(baseUrl)/check-broken-symlinks.sh | bash diff --git a/Package.resolved b/Package.resolved index babac96..e0da6c8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "27bb6664d476306e8f9d8e8d349100d725e33f2fbb8059fd08732ada339d15eb", + "originHash" : "fa52841b94ca393999caeb6b5bae49eec5f6dc7f48c7920b5cbfe47915ddb293", "pins" : [ { "identity" : "feather-database", "kind" : "remoteSourceControl", "location" : "https://github.com/feather-framework/feather-database", "state" : { - "revision" : "147c96803f398c50f5717ce08ebd78a9e3ab7ca7", - "version" : "1.0.0-beta.2" + "revision" : "4ef69e67018c4bdf843858e8976c13b97c3afe4c", + "version" : "1.0.0-beta.3" } }, { diff --git a/Package.swift b/Package.swift index 5a252eb..80e44c6 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), .package(url: "https://github.com/vapor/postgres-nio", from: "1.27.0"), - .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.2"), + .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.3"), // [docc-plugin-placeholder] ], targets: [ diff --git a/README.md b/README.md index d087ec5..bed4523 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,31 @@ Postgres driver implementation for the abstract [Feather Database](https://github.com/feather-framework/feather-database) Swift API package. -[ - ![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138) -]( - https://github.com/feather-framework/feather-postgres-database/releases/tag/1.0.0-beta.2 -) +[![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138)](https://github.com/feather-framework/feather-postgres-database/releases/tag/1.0.0-beta.2) ## Features -- 🤝 Postgres driver for Feather Database -- 😱 Automatic query parameter escaping via Swift string interpolation. -- 🔄 Async sequence query results with `Decodable` row support. -- 🧵 Designed for modern Swift concurrency -- 📚 DocC-based API Documentation -- ✅ Unit tests and code coverage +- Postgres driver for Feather Database +- Automatic query parameter escaping via Swift string interpolation. +- Async sequence query results with `Decodable` row support. +- Designed for modern Swift concurrency +- DocC-based API Documentation +- Unit tests and code coverage ## Requirements ![Swift 6.1+](https://img.shields.io/badge/Swift-6%2E1%2B-F05138) ![Platforms: Linux, macOS, iOS, tvOS, watchOS, visionOS](https://img.shields.io/badge/Platforms-Linux_%7C_macOS_%7C_iOS_%7C_tvOS_%7C_watchOS_%7C_visionOS-F05138) - + - Swift 6.1+ -- Platforms: - - Linux - - macOS 15+ - - iOS 18+ - - tvOS 18+ - - watchOS 11+ - - visionOS 2+ +- Platforms: + - Linux + - macOS 15+ + - iOS 18+ + - tvOS 18+ + - watchOS 11+ + - visionOS 2+ ## Installation @@ -46,19 +42,13 @@ Then add `FeatherPostgresDatabase` to your target dependencies: .product(name: "FeatherPostgresDatabase", package: "feather-postgres-database"), ``` - ## Usage - -[ - ![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) -]( - https://feather-framework.github.io/feather-postgres-database/documentation/featherpostgresdatabase/ -) -API documentation is available at the following link. +API documentation is available at the link below: -> [!TIP] -> Avoid calling `database.execute` while in a transaction; use the transaction `connection` instead. +[![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138)](https://feather-framework.github.io/feather-postgres-database/) + +Here is a brief example: ```swift import Logging @@ -100,14 +90,16 @@ try await withThrowingTaskGroup(of: Void.self) { group in } // execute some query group.addTask { - let result = try await database.execute( - query: #""" - SELECT - version() AS "version" - WHERE - 1=\#(1); - """# - ) + let result = try await database.withConnection { connection in + try await connection.run( + query: #""" + SELECT + version() AS "version" + WHERE + 1=\#(1); + """# + ) + } for try await item in result { let version = try item.decode(column: "version", as: String.self) @@ -122,7 +114,6 @@ try await withThrowingTaskGroup(of: Void.self) { group in > [!WARNING] > This repository is a work in progress, things can break until it reaches v1.0.0. - ## Other database drivers The following database driver implementations are available for use: @@ -133,12 +124,12 @@ The following database driver implementations are available for use: ## Development - Build: `swift build` -- Test: - - local: `swift test` - - using Docker: `make docker-test` +- Test: + - local: `swift test` + - using Docker: `make docker-test` - Format: `make format` - Check: `make check` ## Contributing -[Pull requests](https://github.com/feather-framework/feather-postgres-database/pulls) are welcome. Please keep changes focused and include tests for new logic. 🙏 +[Pull requests](https://github.com/feather-framework/feather-postgres-database/pulls) are welcome. Please keep changes focused and include tests for new logic. diff --git a/Sources/FeatherPostgresDatabase/PostgresConnection.swift b/Sources/FeatherPostgresDatabase/PostgresConnection.swift deleted file mode 100644 index d712f90..0000000 --- a/Sources/FeatherPostgresDatabase/PostgresConnection.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// PostgresConnection.swift -// feather-postgres-database -// -// Created by Tibor Bödecs on 2026. 01. 10.. -// - -import FeatherDatabase -import PostgresNIO - -extension PostgresConnection: @retroactive DatabaseConnection { - - /// Execute a Postgres query on this connection. - /// - /// This wraps `PostgresNIO` query execution and maps errors. - /// - Parameter query: The Postgres query to execute. - /// - Throws: A `DatabaseError` if the query fails. - /// - Returns: A query result containing the returned rows. - @discardableResult - public func execute( - query: PostgresQuery - ) async throws(DatabaseError) -> PostgresQueryResult { - do { - let result = try await self.query( - .init( - unsafeSQL: query.sql, - binds: query.bindings - ), - logger: logger - ) - return PostgresQueryResult(backingSequence: result) - } - catch { - throw DatabaseError.query(error) - } - } -} diff --git a/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift b/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift index cee9781..8004f38 100644 --- a/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift +++ b/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift @@ -9,17 +9,13 @@ import FeatherDatabase import Logging import PostgresNIO -/// Make Postgres transaction errors conform to `DatabaseTransactionError`. -/// -/// This allows Postgres errors to flow through `DatabaseError`. -extension PostgresTransactionError: @retroactive DatabaseTransactionError {} - /// A Postgres-backed database client. /// /// Use this client to execute queries and manage transactions on Postgres. public struct PostgresDatabaseClient: DatabaseClient { + public typealias Connection = PostgresDatabaseConnection - var client: PostgresClient + var client: PostgresNIO.PostgresClient var logger: Logger /// Create a Postgres database client. @@ -29,7 +25,7 @@ public struct PostgresDatabaseClient: DatabaseClient { /// - client: The underlying Postgres client. /// - logger: The logger for database operations. public init( - client: PostgresClient, + client: PostgresNIO.PostgresClient, logger: Logger ) { self.client = client @@ -41,18 +37,21 @@ public struct PostgresDatabaseClient: DatabaseClient { /// Execute work using a managed Postgres connection. /// /// The closure receives a Postgres connection for the duration of the call. - /// - Parameters: - /// - isolation: The actor isolation for the operation. - /// - closure: A closure that receives the connection. + /// - Parameter: closure: A closure that receives the connection. /// - Throws: A `DatabaseError` if connection handling fails. /// - Returns: The query result produced by the closure. @discardableResult - public func connection( - isolation: isolated (any Actor)? = #isolation, - _ closure: (PostgresConnection) async throws -> sending T, - ) async throws(DatabaseError) -> sending T { + public func withConnection( + _ closure: (Connection) async throws -> T, + ) async throws(DatabaseError) -> T { do { - return try await client.withConnection(closure) + return try await client.withConnection { connection in + let databaseConnection = PostgresDatabaseConnection( + connection: connection, + logger: logger + ) + return try await closure(databaseConnection) + } } catch let error as DatabaseError { throw error @@ -65,25 +64,30 @@ public struct PostgresDatabaseClient: DatabaseClient { /// Execute work inside a Postgres transaction. /// /// The closure is wrapped in a transactional scope. - /// - Parameters: - /// - isolation: The actor isolation for the operation. - /// - closure: A closure that receives the connection. + /// - Parameter: closure: A closure that receives the connection. /// - Throws: A `DatabaseError` if the transaction fails. /// - Returns: The query result produced by the closure. @discardableResult - public func transaction( - isolation: isolated (any Actor)? = #isolation, - _ closure: ((PostgresConnection) async throws -> sending T), - ) async throws(DatabaseError) -> sending T { + public func withTransaction( + _ closure: (Connection) async throws -> T, + ) async throws(DatabaseError) -> T { do { return try await client.withTransaction( - logger: logger, - isolation: isolation, - closure - ) + logger: logger + ) { connection in + let databaseConnection = PostgresDatabaseConnection( + connection: connection, + logger: logger + ) + return try await closure(databaseConnection) + } } catch let error as PostgresTransactionError { - throw .transaction(error) + throw .transaction( + PostgresDatabaseTransactionError( + underlyingError: error + ) + ) } catch { throw .connection(error) diff --git a/Sources/FeatherPostgresDatabase/PostgresDatabaseConnection.swift b/Sources/FeatherPostgresDatabase/PostgresDatabaseConnection.swift new file mode 100644 index 0000000..b255861 --- /dev/null +++ b/Sources/FeatherPostgresDatabase/PostgresDatabaseConnection.swift @@ -0,0 +1,51 @@ +// +// PostgresDatabaseConnection.swift +// feather-postgres-database +// +// Created by Tibor Bödecs on 2026. 01. 10. +// + +import FeatherDatabase +import PostgresNIO + +public struct PostgresDatabaseConnection: DatabaseConnection { + + public typealias Query = PostgresDatabaseQuery + public typealias RowSequence = PostgresDatabaseRowSequence + + var connection: PostgresConnection + public var logger: Logger + + /// Execute a Postgres query on this connection. + /// + /// This wraps `PostgresNIO` query execution and maps errors. + /// - Parameters: + /// - query: The Postgres query to execute. + /// - handler: A closure that receives the RowSequence result. + /// - Throws: A `DatabaseError` if the query fails. + /// - Returns: A query result containing the returned rows. + @discardableResult + public func run( + query: Query, + _ handler: (RowSequence) async throws -> T = { $0 } + ) async throws(DatabaseError) -> T { + do { + let sequence = try await connection.query( + .init( + unsafeSQL: query.sql, + binds: query.bindings + ), + logger: logger + ) + + return try await handler( + PostgresDatabaseRowSequence( + backingSequence: sequence + ) + ) + } + catch { + throw .query(error) + } + } +} diff --git a/Sources/FeatherPostgresDatabase/PostgresDatabaseQuery.swift b/Sources/FeatherPostgresDatabase/PostgresDatabaseQuery.swift new file mode 100644 index 0000000..04e6cff --- /dev/null +++ b/Sources/FeatherPostgresDatabase/PostgresDatabaseQuery.swift @@ -0,0 +1,159 @@ +// +// PostgresDatabaseQuery.swift +// feather-postgres-database +// +// Created by Tibor Bödecs on 2026. 01. 10. +// + +import FeatherDatabase +import PostgresNIO + +public struct PostgresDatabaseQuery: DatabaseQuery { + /// The bindings type for Postgres queries. + /// + /// This type represents parameter bindings for PostgresNIO. + public typealias Bindings = PostgresBindings + + /// The SQL text to execute. + /// + /// This is the raw SQL string for the query. + public var sql: String { + query.sql + } + + /// The bound parameters for the SQL text. + /// + /// This exposes the underlying `binds` storage. + public var bindings: PostgresBindings { + query.binds + } + + var query: PostgresQuery + +} + +extension PostgresDatabaseQuery: ExpressibleByStringInterpolation { + + public init( + stringLiteral value: String + ) { + self.init(query: .init(stringLiteral: value)) + } + + public init( + stringInterpolation value: StringInterpolation + ) { + self.init( + query: .init( + unsafeSQL: value.sql, + binds: value.binds + ) + ) + } +} + +extension PostgresDatabaseQuery { + + // NOTE: source derived from postgres-nio + public struct StringInterpolation: StringInterpolationProtocol, Sendable { + public typealias StringLiteralType = String + + @usableFromInline + var sql: String + + @usableFromInline + var binds: PostgresBindings + + public init( + literalCapacity: Int, + interpolationCount: Int + ) { + self.sql = "" + self.binds = PostgresBindings(capacity: interpolationCount) + } + + public mutating func appendLiteral( + _ literal: String + ) { + self.sql.append(contentsOf: literal) + } + + @inlinable + public mutating func appendInterpolation< + Value: PostgresThrowingDynamicTypeEncodable + >( + _ value: Value + ) throws { + try self.binds.append(value, context: .default) + self.sql.append(contentsOf: "$\(self.binds.count)") + } + + @inlinable + public mutating func appendInterpolation< + Value: PostgresThrowingDynamicTypeEncodable + >( + _ value: Value? + ) throws { + switch value { + case .none: + self.binds.appendNull() + case .some(let value): + try self.binds.append(value, context: .default) + } + + self.sql.append(contentsOf: "$\(self.binds.count)") + } + + @inlinable + public mutating func appendInterpolation< + Value: PostgresDynamicTypeEncodable + >( + _ value: Value + ) { + self.binds.append(value, context: .default) + self.sql.append(contentsOf: "$\(self.binds.count)") + } + + @inlinable + public mutating func appendInterpolation< + Value: PostgresDynamicTypeEncodable + >( + _ value: Value? + ) { + switch value { + case .none: + self.binds.appendNull() + case .some(let value): + self.binds.append(value, context: .default) + } + + self.sql.append(contentsOf: "$\(self.binds.count)") + } + + @inlinable + public mutating func appendInterpolation< + Value: PostgresThrowingDynamicTypeEncodable, + JSONEncoder: PostgresJSONEncoder + >( + _ value: Value, + context: PostgresEncodingContext + ) throws { + try self.binds.append(value, context: context) + self.sql.append(contentsOf: "$\(self.binds.count)") + } + + @inlinable + public mutating func appendInterpolation( + unescaped interpolated: String + ) { + self.sql.append(contentsOf: interpolated) + } + + @inlinable + public mutating func appendInterpolation( + unescaped interpolated: Int + ) { + self.sql.append(contentsOf: String(interpolated)) + } + } +} diff --git a/Sources/FeatherPostgresDatabase/PostgresRow+DatabaseRow.swift b/Sources/FeatherPostgresDatabase/PostgresDatabaseRow.swift similarity index 90% rename from Sources/FeatherPostgresDatabase/PostgresRow+DatabaseRow.swift rename to Sources/FeatherPostgresDatabase/PostgresDatabaseRow.swift index 9204e01..48faf91 100644 --- a/Sources/FeatherPostgresDatabase/PostgresRow+DatabaseRow.swift +++ b/Sources/FeatherPostgresDatabase/PostgresDatabaseRow.swift @@ -1,14 +1,16 @@ // -// PostgresRow+DatabaseRow.swift +// PostgresDatabaseRow.swift // feather-postgres-database // -// Created by Tibor Bödecs on 2026. 01. 10.. +// Created by Tibor Bödecs on 2026. 01. 10. // import FeatherDatabase import PostgresNIO -extension PostgresRow: @retroactive DatabaseRow { +public struct PostgresDatabaseRow: DatabaseRow { + + var row: PostgresRow /// Decode a column value as the given type. /// @@ -22,7 +24,7 @@ extension PostgresRow: @retroactive DatabaseRow { column: String, as type: T.Type ) throws(DecodingError) -> T { - let row = makeRandomAccess() + let row = row.makeRandomAccess() guard row.contains(column) else { throw .dataCorrupted( .init( diff --git a/Sources/FeatherPostgresDatabase/PostgresQueryResult.swift b/Sources/FeatherPostgresDatabase/PostgresDatabaseRowSequence.swift similarity index 84% rename from Sources/FeatherPostgresDatabase/PostgresQueryResult.swift rename to Sources/FeatherPostgresDatabase/PostgresDatabaseRowSequence.swift index f0b4dc9..7212077 100644 --- a/Sources/FeatherPostgresDatabase/PostgresQueryResult.swift +++ b/Sources/FeatherPostgresDatabase/PostgresDatabaseRowSequence.swift @@ -1,8 +1,8 @@ // -// PostgresQueryResult.swift +// PostgresDatabaseRowSequence.swift // feather-postgres-database // -// Created by Tibor Bödecs on 2026. 01. 10.. +// Created by Tibor Bödecs on 2026. 01. 10. // import FeatherDatabase @@ -11,7 +11,7 @@ import PostgresNIO /// A query result backed by a Postgres row sequence. /// /// Use this type to iterate or collect Postgres query results. -public struct PostgresQueryResult: DatabaseQueryResult { +public struct PostgresDatabaseRowSequence: DatabaseRowSequence { var backingSequence: PostgresRowSequence @@ -28,14 +28,14 @@ public struct PostgresQueryResult: DatabaseQueryResult { /// - Returns: The next `PostgresRow`, or `nil` when finished. #if compiler(>=6.2) @concurrent - public mutating func next() async throws -> PostgresRow? { + public mutating func next() async throws -> PostgresDatabaseRow? { guard !Task.isCancelled else { return nil } guard let postgresRow = try await backingIterator.next() else { return nil } - return postgresRow + return .init(row: postgresRow) } #else public mutating func next() async throws -> PostgresRow? { @@ -65,8 +65,8 @@ public struct PostgresQueryResult: DatabaseQueryResult { /// This consumes the sequence and returns all rows. /// - Throws: An error if iteration fails. /// - Returns: An array of `PostgresRow` values. - public func collect() async throws -> [PostgresRow] { - var items: [PostgresRow] = [] + public func collect() async throws -> [PostgresDatabaseRow] { + var items: [PostgresDatabaseRow] = [] for try await item in self { items.append(item) } diff --git a/Sources/FeatherPostgresDatabase/PostgresDatabaseTransactionError.swift b/Sources/FeatherPostgresDatabase/PostgresDatabaseTransactionError.swift new file mode 100644 index 0000000..a4caf93 --- /dev/null +++ b/Sources/FeatherPostgresDatabase/PostgresDatabaseTransactionError.swift @@ -0,0 +1,38 @@ +// +// PostgresDatabaseTransactionError.swift +// feather-postgres-database +// +// Created by Tibor Bödecs on 2026. 02. 02.. +// + +import FeatherDatabase +import PostgresNIO + +public struct PostgresDatabaseTransactionError: DatabaseTransactionError { + + var underlyingError: PostgresTransactionError + + public var file: String { + underlyingError.file + } + + public var line: Int { + underlyingError.line + } + + public var beginError: (any Error)? { + underlyingError.beginError + } + + public var closureError: (any Error)? { + underlyingError.closureError + } + + public var commitError: (any Error)? { + underlyingError.commitError + } + + public var rollbackError: (any Error)? { + underlyingError.rollbackError + } +} diff --git a/Sources/FeatherPostgresDatabase/PostgresQuery.swift b/Sources/FeatherPostgresDatabase/PostgresQuery.swift deleted file mode 100644 index 8a609d6..0000000 --- a/Sources/FeatherPostgresDatabase/PostgresQuery.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// PostgresQuery.swift -// feather-postgres-database -// -// Created by Tibor Bödecs on 2026. 01. 10.. -// - -import FeatherDatabase -import PostgresNIO - -extension PostgresQuery: @retroactive DatabaseQuery { - /// The bindings type for Postgres queries. - /// - /// This type represents parameter bindings for PostgresNIO. - public typealias Bindings = PostgresBindings - - /// The bound parameters for the SQL text. - /// - /// This exposes the underlying `binds` storage. - public var bindings: PostgresBindings { binds } -} diff --git a/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift b/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift index 8c53f60..594ea52 100644 --- a/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift +++ b/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift @@ -92,54 +92,57 @@ struct FeatherPostgresDatabaseTestSuite { let planetsTable = "planets_\(suffix)" let moonsTable = "moons_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: moonsTable)" CASCADE; - """# - ) - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: planetsTable)" CASCADE; - """# - ) - - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: planetsTable)" ( - "id" INTEGER PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: moonsTable)" ( - "id" INTEGER PRIMARY KEY, - "planet_id" INTEGER NOT NULL - REFERENCES "\#(unescaped: planetsTable)" ("id") - ); - """# - ) - - do { - _ = try await database.execute( + try await database.withTransaction { connection in + + try await connection.run( query: #""" - INSERT INTO "\#(unescaped: moonsTable)" - ("id", "planet_id") - VALUES - (1, 999); + DROP TABLE IF EXISTS "\#(unescaped: moonsTable)" CASCADE; """# ) - Issue.record("Expected foreign key constraint violation.") - } - catch DatabaseError.query(let error) { - #expect( - String(reflecting: error) - .contains("violates foreign key constraint") + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: planetsTable)" CASCADE; + """# ) - } - catch { - Issue.record("Expected database query error to be thrown.") + + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: planetsTable)" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: moonsTable)" ( + "id" INTEGER PRIMARY KEY, + "planet_id" INTEGER NOT NULL + REFERENCES "\#(unescaped: planetsTable)" ("id") + ); + """# + ) + + do { + _ = try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: moonsTable)" + ("id", "planet_id") + VALUES + (1, 999); + """# + ) + Issue.record("Expected foreign key constraint violation.") + } + catch DatabaseError.query(let error) { + #expect( + String(reflecting: error) + .contains("violates foreign key constraint") + ) + } + catch { + Issue.record("Expected database query error to be thrown.") + } } } } @@ -150,37 +153,41 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "galaxies_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - - try await database.execute( - query: #""" - CREATE TABLE IF NOT EXISTS "\#(unescaped: table)" ( - "id" INTEGER PRIMARY KEY, - "name" TEXT - ); - """# - ) - - let results = try await database.execute( - query: #""" - SELECT "tablename" - FROM "pg_tables" - WHERE "schemaname" = 'public' - AND "tablename" = '\#(unescaped: table)' - ORDER BY "tablename"; - """# - ) - - let resultArray = try await results.collect() - #expect(resultArray.count == 1) - - let item = resultArray[0] - let name = try item.decode(column: "tablename", as: String.self) - #expect(name == table) + try await database.withTransaction { connection in + + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + + try await connection.run( + query: #""" + CREATE TABLE IF NOT EXISTS "\#(unescaped: table)" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT + ); + """# + ) + + let results = try await connection.run( + query: #""" + SELECT "tablename" + FROM "pg_tables" + WHERE "schemaname" = 'public' + AND "tablename" = '\#(unescaped: table)' + ORDER BY "tablename"; + """# + ) { + try await $0.collect() + .map { + try $0.decode(column: "tablename", as: String.self) + } + } + + #expect(results.count == 1) + #expect(results[0] == table) + } } } @@ -190,49 +197,63 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "galaxies_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE IF NOT EXISTS "\#(unescaped: table)" ( - "id" INTEGER PRIMARY KEY, - "name" TEXT - ); - """# - ) - - let name1 = "Andromeda" - let name2 = "Milky Way" - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "name") - VALUES - (\#(1), \#(name1)), - (\#(2), \#(name2)); - """# - ) - - let results = try await database.execute( - query: #""" - SELECT * FROM "\#(unescaped: table)" ORDER BY "name" ASC; - """# - ) - - let resultArray = try await results.collect() - #expect(resultArray.count == 2) - - let item1 = resultArray[0] - let name1result = try item1.decode(column: "name", as: String.self) - #expect(name1result == name1) - - let item2 = resultArray[1] - let name2result = try item2.decode(column: "name", as: String.self) - #expect(name2result == name2) + try await database.withConnection { connection in + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE IF NOT EXISTS "\#(unescaped: table)" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT + ); + """# + ) + + let name1 = "Andromeda" + let name2 = "Milky Way" + + struct GalaxyRow: Codable, Sendable { + let id: Int + let name: String + + init(_ row: DatabaseRow) throws { + self.id = try row.decode(column: "id", as: Int.self) + self.name = try row.decode( + column: "name", + as: String.self + ) + } + } + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name") + VALUES + (\#(1), \#(name1)), + (\#(2), \#(name2)); + """# + ) + + let results = try await connection.run( + query: #""" + SELECT * FROM "\#(unescaped: table)" ORDER BY "name" ASC; + """# + ) { + try await $0.collect() + .map { + try GalaxyRow($0) + } + } + + #expect(results.count == 2) + #expect(results[0].name == name1) + #expect(results[1].name == name2) + } + } } @@ -242,63 +263,83 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "foo_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" TEXT - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "value") - VALUES - (1, 'abc'), - (2, NULL); - """# - ) - - let result = - try await database.execute( + try await database.withConnection { connection in + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, 'abc'), + (2, NULL); + """# + ) + + struct FooRow: Codable, Sendable { + let id: Int + let value: String + + init(_ row: DatabaseRow) throws { + self.id = try row.decode(column: "id", as: Int.self) + self.value = try row.decode( + column: "value", + as: String.self + ) + } + } + + let result = try await connection.run( query: #""" SELECT "id", "value" FROM "\#(unescaped: table)" ORDER BY "id"; """# - ) - .collect() + ) { try await $0.collect() } - #expect(result.count == 2) + #expect(result.count == 2) - let item1 = result[0] - let item2 = result[1] + let item1 = result[0] + let item2 = result[1] - #expect(try item1.decode(column: "id", as: Int.self) == 1) - #expect(try item2.decode(column: "id", as: Int.self) == 2) + #expect(try item1.decode(column: "id", as: Int.self) == 1) + #expect(try item2.decode(column: "id", as: Int.self) == 2) - #expect(try item1.decode(column: "id", as: Int?.self) == .some(1)) - #expect((try? item1.decode(column: "value", as: Int?.self)) == nil) + #expect( + try item1.decode(column: "id", as: Int?.self) == .some(1) + ) + #expect( + (try? item1.decode(column: "value", as: Int?.self)) == nil + ) - #expect(try item1.decode(column: "value", as: String.self) == "abc") - #expect( - (try? item2.decode(column: "value", as: String.self)) == nil - ) + #expect( + try item1.decode(column: "value", as: String.self) == "abc" + ) + #expect( + (try? item2.decode(column: "value", as: String.self)) == nil + ) - #expect( - (try item1.decode(column: "value", as: String?.self)) - == .some("abc") - ) - #expect( - (try item2.decode(column: "value", as: String?.self)) == .none - ) + #expect( + (try item1.decode(column: "value", as: String?.self)) + == .some("abc") + ) + #expect( + (try item2.decode(column: "value", as: String?.self)) + == .none + ) + } } } @@ -308,56 +349,59 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "foo_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - - let row1: (Int, String?) = (1, "abc") - let row2: (Int, String?) = (2, nil) - - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" TEXT - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "value") - VALUES - (\#(row1.0), \#(row1.1)), - (\#(row2.0), \#(row2.1)); - """# - ) - - let result = - try await database.execute( + try await database.withTransaction { connection in + try await connection.run( query: #""" - SELECT "id", "value" - FROM "\#(unescaped: table)" - ORDER BY "id" ASC; + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; """# ) - .collect() - #expect(result.count == 2) + let row1: (Int, String?) = (1, "abc") + let row2: (Int, String?) = (2, nil) - let item1 = result[0] - let item2 = result[1] + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (\#(row1.0), \#(row1.1)), + (\#(row2.0), \#(row2.1)); + """# + ) + + let result = + try await connection.run( + query: #""" + SELECT "id", "value" + FROM "\#(unescaped: table)" + ORDER BY "id" ASC; + """# + ) { try await $0.collect() } - #expect(try item1.decode(column: "id", as: Int.self) == 1) - #expect(try item2.decode(column: "id", as: Int.self) == 2) + #expect(result.count == 2) - #expect( - try item1.decode(column: "value", as: String?.self) == "abc" - ) - #expect(try item2.decode(column: "value", as: String?.self) == nil) + let item1 = result[0] + let item2 = result[1] + + #expect(try item1.decode(column: "id", as: Int.self) == 1) + #expect(try item2.decode(column: "id", as: Int.self) == 2) + + #expect( + try item1.decode(column: "value", as: String?.self) == "abc" + ) + #expect( + try item2.decode(column: "value", as: String?.self) == nil + ) + } } } @@ -367,45 +411,47 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "widgets_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) - - let name = "gizmo" - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "name") - VALUES - (\#(1), \#(name)); - """# - ) - - let result = - try await database.execute( + try await database.withConnection { connection in + try await connection.run( query: #""" - SELECT "name" - FROM "\#(unescaped: table)" - WHERE "id" = 1; + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + + let name = "gizmo" + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name") + VALUES + (\#(1), \#(name)); """# ) - .collect() - #expect(result.count == 1) - #expect( - try result[0].decode(column: "name", as: String.self) == "gizmo" - ) + let result = + try await connection.run( + query: #""" + SELECT "name" + FROM "\#(unescaped: table)" + WHERE "id" = 1; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "name", as: String.self) + == "gizmo" + ) + } } } @@ -415,45 +461,48 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "notes_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "body" TEXT - ); - """# - ) - - let body: String? = nil - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "body") - VALUES - (1, \#(body)); - """# - ) - - let result = - try await database.execute( + try await database.withConnection { connection in + + try await connection.run( query: #""" - SELECT "body" - FROM "\#(unescaped: table)" - WHERE "id" = 1; + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "body" TEXT + ); + """# + ) + + let body: String? = nil + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "body") + VALUES + (1, \#(body)); """# ) - .collect() - #expect(result.count == 1) - #expect( - try result[0].decode(column: "body", as: String?.self) == nil - ) + let result = + try await connection.run( + query: #""" + SELECT "body" + FROM "\#(unescaped: table)" + WHERE "id" = 1; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "body", as: String?.self) + == nil + ) + } } } @@ -463,46 +512,48 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "tags_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "label" TEXT NOT NULL - ); - """# - ) - - let label = "alpha" - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "label") - VALUES - (1, \#(label)); - """# - ) - - let result = - try await database.execute( + try await database.withConnection { connection in + + try await connection.run( query: #""" - SELECT "label" - FROM "\#(unescaped: table)" - WHERE "id" = 1; + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "label" TEXT NOT NULL + ); """# ) - .collect() - #expect(result.count == 1) - #expect( - try result[0].decode(column: "label", as: String.self) - == "alpha" - ) + let label = "alpha" + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "label") + VALUES + (1, \#(label)); + """# + ) + + let result = + try await connection.run( + query: #""" + SELECT "label" + FROM "\#(unescaped: table)" + WHERE "id" = 1; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "label", as: String.self) + == "alpha" + ) + } } } @@ -512,66 +563,53 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "numbers_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" TEXT NOT NULL - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "value") - VALUES - (1, 'one'), - (2, 'two'); - """# - ) - - let result = try await database.execute( - query: #""" - SELECT "id", "value" - FROM "\#(unescaped: table)" - ORDER BY "id"; - """# - ) - - var iterator = result.makeAsyncIterator() - let first = try await iterator.next() - let second = try await iterator.next() - let third = try await iterator.next() - - #expect(first != nil) - #expect(second != nil) - #expect(third == nil) - - if let first { + try await database.withConnection { connection in + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, 'one'), + (2, 'two'); + """# + ) + + let result = try await connection.run( + query: #""" + SELECT "id", "value" + FROM "\#(unescaped: table)" + ORDER BY "id"; + """# + ) { try await $0.collect() } + + #expect(result.count == 2) + let first = result[0] + let second = result[1] + #expect(try first.decode(column: "id", as: Int.self) == 1) #expect( try first.decode(column: "value", as: String.self) == "one" ) - } - else { - Issue.record("Expected first iterator element to exist.") - } - if let second { #expect(try second.decode(column: "id", as: Int.self) == 2) #expect( try second.decode(column: "value", as: String.self) == "two" ) } - else { - Issue.record("Expected second iterator element to exist.") - } } } @@ -581,167 +619,176 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "widgets_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" SERIAL PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("name") - VALUES - ('alpha'), - ('beta'); - """# - ) - - let result = try await database.execute( - query: #""" - SELECT "name" - FROM "\#(unescaped: table)" - ORDER BY "id" ASC; - """# - ) - - let first = try await result.collectFirst() - - #expect(first != nil) - #expect( - try first?.decode(column: "name", as: String.self) == "alpha" - ) - } - } + try await database.withConnection { connection in - @Test - func transactionSuccess() async throws { - try await runUsingTestDatabaseClient { database in - let suffix = randomTableSuffix() - let table = "items_\(suffix)" - - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) - - try await database.transaction { connection in - try await connection.execute( + try await connection.run( query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "name") - VALUES - (1, 'widget'); + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" SERIAL PRIMARY KEY, + "name" TEXT NOT NULL + ); """# ) - } - let result = - try await database.execute( + try await connection.run( query: #""" - SELECT "name" - FROM "\#(unescaped: table)" - WHERE "id" = 1; + INSERT INTO "\#(unescaped: table)" + ("name") + VALUES + ('alpha'), + ('beta'); """# ) - .collect() - #expect(result.count == 1) - #expect( - try result[0].decode(column: "name", as: String.self) - == "widget" - ) + let result = + try await connection.run( + query: #""" + SELECT "name" + FROM "\#(unescaped: table)" + ORDER BY "id" ASC; + """# + ) { try await $0.collect() } + .first + + #expect(result != nil) + #expect( + try result?.decode(column: "name", as: String.self) + == "alpha" + ) + } } } @Test - func transactionFailurePropagates() async throws { + func transactionSuccess() async throws { try await runUsingTestDatabaseClient { database in let suffix = randomTableSuffix() - let table = "dummy_\(suffix)" + let table = "items_\(suffix)" + + try await database.withConnection { connection in + + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) - - do { - _ = try await database.transaction { connection in - try await connection.execute( + try await database.withTransaction { connection in + try await connection.run( query: #""" INSERT INTO "\#(unescaped: table)" ("id", "name") VALUES - (1, 'ok'); + (1, 'widget'); """# ) + } - return try await connection.execute( + let result = + try await connection.run( query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "name") - VALUES - (2, NULL); + SELECT "name" + FROM "\#(unescaped: table)" + WHERE "id" = 1; """# - ) - } - Issue.record( - "Expected database transaction error to be thrown." - ) - } - catch DatabaseError.transaction(let error) { - #expect(error.beginError == nil) - #expect(error.closureError != nil) + ) { try await $0.collect() } + + #expect(result.count == 1) #expect( - error.closureError.debugDescription.contains( - "null value in column" - ) - ) - #expect(error.rollbackError == nil) - #expect(error.commitError == nil) - } - catch { - Issue.record( - "Expected database transaction error to be thrown." + try result[0].decode(column: "name", as: String.self) + == "widget" ) } + } + } - let result = - try await database.execute( + @Test + func transactionFailurePropagates() async throws { + try await runUsingTestDatabaseClient { database in + + let suffix = randomTableSuffix() + let table = "dummy_\(suffix)" + + try await database.withConnection { connection in + + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( query: #""" - SELECT "id" - FROM "\#(unescaped: table)"; + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); """# ) - .collect() - #expect(result.isEmpty) + do { + _ = try await database.withTransaction { connection in + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name") + VALUES + (1, 'ok'); + """# + ) + + return try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name") + VALUES + (2, NULL); + """# + ) + } + Issue.record( + "Expected database transaction error to be thrown." + ) + } + catch DatabaseError.transaction(let error) { + #expect(error.beginError == nil) + #expect(error.closureError != nil) + #expect( + error.closureError.debugDescription.contains( + "null value in column" + ) + ) + #expect(error.rollbackError == nil) + #expect(error.commitError == nil) + } + catch { + Issue.record( + "Expected database transaction error to be thrown." + ) + } + + let result = + try await connection.run( + query: #""" + SELECT "id" + FROM "\#(unescaped: table)"; + """# + ) { try await $0.collect() } + + #expect(result.isEmpty) + } } } @@ -756,42 +803,45 @@ struct FeatherPostgresDatabaseTestSuite { case missingRow } - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" TEXT NOT NULL PRIMARY KEY, - "access_token" TEXT NOT NULL, - "access_expires_at" TIMESTAMPTZ NOT NULL, - "refresh_token" TEXT NOT NULL, - "refresh_count" INTEGER NOT NULL DEFAULT 0 - ); - """# - ) - - // set an expired token - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "access_token", "access_expires_at", "refresh_token", "refresh_count") - VALUES - ( - \#(sessionID), - 'stale', - NOW() - INTERVAL '5 minutes', - 'refresh', - 0 + try await database.withConnection { connection in + + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" TEXT NOT NULL PRIMARY KEY, + "access_token" TEXT NOT NULL, + "access_expires_at" TIMESTAMPTZ NOT NULL, + "refresh_token" TEXT NOT NULL, + "refresh_count" INTEGER NOT NULL DEFAULT 0 ); - """# - ) + """# + ) + + // set an expired token + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "access_token", "access_expires_at", "refresh_token", "refresh_count") + VALUES + ( + \#(sessionID), + 'stale', + NOW() - INTERVAL '5 minutes', + 'refresh', + 0 + ); + """# + ) + } func getValidAccessToken(sessionID: String) async throws -> String { - try await database.transaction { connection in - let result = try await connection.execute( + try await database.withTransaction { connection in + let rows = try await connection.run( query: #""" SELECT "access_token", @@ -801,8 +851,7 @@ struct FeatherPostgresDatabaseTestSuite { WHERE "id" = \#(sessionID) FOR UPDATE; """# - ) - let rows = try await result.collect() + ) { try await $0.collect() } guard let row = rows.first else { throw TestError.missingRow @@ -830,7 +879,7 @@ struct FeatherPostgresDatabaseTestSuite { try await Task.sleep(for: .milliseconds(40)) - _ = try await connection.execute( + try await connection.run( query: #""" UPDATE "\#(unescaped: table)" SET @@ -860,32 +909,51 @@ struct FeatherPostgresDatabaseTestSuite { #expect(Set(tokens).count == 1) - let result = - try await database.execute( - query: #""" - SELECT - "access_token", - "refresh_count", - "access_expires_at" > NOW() AS "is_valid" - FROM "\#(unescaped: table)" - WHERE "id" = \#(sessionID); - """# - ) - .collect() - - #expect(result.count == 1) - #expect( - try result[0].decode(column: "refresh_count", as: Int.self) - == 1 - ) - #expect( - try result[0].decode(column: "access_token", as: String.self) - == "token_1" - ) - #expect( - try result[0].decode(column: "is_valid", as: Bool.self) - == true - ) + struct SessionRow: Codable, Sendable { + let refreshCount: Int + let accessToken: String + let isValid: Bool + + init(_ row: DatabaseRow) throws { + self.refreshCount = try row.decode( + column: "refresh_count", + as: Int.self + ) + self.accessToken = try row.decode( + column: "access_token", + as: String.self + ) + self.isValid = try row.decode( + column: "is_valid", + as: Bool.self + ) + } + } + + try await database.withConnection { connection in + + let result = + try await connection.run( + query: #""" + SELECT + "access_token", + "refresh_count", + "access_expires_at" > NOW() AS "is_valid" + FROM "\#(unescaped: table)" + WHERE "id" = \#(sessionID); + """#, + ) { + try await $0.collect() + .map { + try SessionRow($0) + } + } + + #expect(result.count == 1) + #expect(result[0].refreshCount == 1) + #expect(result[0].accessToken == "token_1") + #expect(result[0].isValid) + } } } @@ -895,46 +963,48 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "measurements_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" DOUBLE PRECISION NOT NULL - ); - """# - ) - - let expected = 1.5 - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "value") - VALUES - (1, \#(expected)); - """# - ) - - let result = - try await database.execute( + try await database.withConnection { connection in + + try await connection.run( query: #""" - SELECT "value" - FROM "\#(unescaped: table)" - WHERE "id" = 1; + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" DOUBLE PRECISION NOT NULL + ); + """# + ) + + let expected = 1.5 + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, \#(expected)); """# ) - .collect() - #expect(result.count == 1) - #expect( - try result[0].decode(column: "value", as: Double.self) - == expected - ) + let result = + try await connection.run( + query: #""" + SELECT "value" + FROM "\#(unescaped: table)" + WHERE "id" = 1; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "value", as: Double.self) + == expected + ) + } } } @@ -944,51 +1014,53 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "items_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" TEXT - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "value") - VALUES - (1, 'abc'); - """# - ) - - let result = - try await database.execute( + try await database.withConnection { connection in + + try await connection.run( query: #""" - SELECT "id" - FROM "\#(unescaped: table)"; + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); """# ) - .collect() - #expect(result.count == 1) + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, 'abc'); + """# + ) - do { - _ = try result[0].decode(column: "value", as: String.self) - Issue.record("Expected decoding a missing column to throw.") - } - catch DecodingError.dataCorrupted { + let result = + try await connection.run( + query: #""" + SELECT "id" + FROM "\#(unescaped: table)"; + """# + ) { try await $0.collect() } - } - catch { - Issue.record( - "Expected a dataCorrupted error for missing column." - ) + #expect(result.count == 1) + + do { + _ = try result[0].decode(column: "value", as: String.self) + Issue.record("Expected decoding a missing column to throw.") + } + catch DecodingError.dataCorrupted { + + } + catch { + Issue.record( + "Expected a dataCorrupted error for missing column." + ) + } } } } @@ -999,51 +1071,53 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "items_\(suffix)" - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" TEXT - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "value") - VALUES - (1, 'abc'); - """# - ) - - let result = - try await database.execute( + try await database.withConnection { connection in + + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) + + try await connection.run( query: #""" - SELECT "value" - FROM "\#(unescaped: table)"; + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, 'abc'); """# ) - .collect() - #expect(result.count == 1) + let result = + try await connection.run( + query: #""" + SELECT "value" + FROM "\#(unescaped: table)"; + """# + ) { try await $0.collect() } - do { - _ = try result[0].decode(column: "value", as: Int.self) - Issue.record("Expected decoding a string as Int to throw.") - } - catch DecodingError.typeMismatch { + #expect(result.count == 1) - } - catch { - Issue.record( - "Expected a typeMismatch error when decoding a string as Int." - ) + do { + _ = try result[0].decode(column: "value", as: Int.self) + Issue.record("Expected decoding a string as Int to throw.") + } + catch DecodingError.typeMismatch { + + } + catch { + Issue.record( + "Expected a typeMismatch error when decoding a string as Int." + ) + } } } } @@ -1054,22 +1128,25 @@ struct FeatherPostgresDatabaseTestSuite { let suffix = randomTableSuffix() let table = "missing_table_\(suffix)" - do { - _ = try await database.execute( - query: #""" - SELECT * - FROM "\#(unescaped: table)"; - """# - ) - Issue.record("Expected query to fail for missing table.") - } - catch DatabaseError.query(let error) { - #expect( - String(reflecting: error).contains("does not exist") - ) - } - catch { - Issue.record("Expected database query error to be thrown.") + try await database.withConnection { connection in + + do { + _ = try await connection.run( + query: #""" + SELECT * + FROM "\#(unescaped: table)"; + """# + ) + Issue.record("Expected query to fail for missing table.") + } + catch DatabaseError.query(let error) { + #expect( + String(reflecting: error).contains("does not exist") + ) + } + catch { + Issue.record("Expected database query error to be thrown.") + } } } } @@ -1077,21 +1154,26 @@ struct FeatherPostgresDatabaseTestSuite { @Test func versionCheck() async throws { try await runUsingTestDatabaseClient { database in - let result = try await database.execute( - query: #""" - SELECT - version() AS "version" - WHERE - 1=\#(1); - """# - ) - - let resultArray = try await result.collect() - #expect(resultArray.count == 1) - - let item = resultArray[0] - let version = try item.decode(column: "version", as: String.self) - #expect(version.contains("PostgreSQL")) + try await database.withConnection { connection in + + let result = try await connection.run( + query: #""" + SELECT + version() AS "version" + WHERE + 1=\#(1); + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + + let item = result[0] + let version = try item.decode( + column: "version", + as: String.self + ) + #expect(version.contains("PostgreSQL")) + } } } }