diff --git a/README.md b/README.md index a2a88ec..0861854 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Abstract database component, providing a shared API surface for database drivers written in Swift. [ - ![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138) + ![Release: 1.0.0-beta.3](https://img.shields.io/badge/Release-1%2E0%2E0--beta%3E2-F05138) ]( - https://github.com/feather-framework/feather-database/releases/tag/1.0.0-beta.2 + https://github.com/feather-framework/feather-database/releases/tag/1.0.0-beta.3 ) ## Features @@ -35,7 +35,7 @@ Abstract database component, providing a shared API surface for database drivers Use Swift Package Manager; add the dependency to your `Package.swift` file: ```swift -.package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.1"), +.package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.3"), ``` Then add `FeatherDatabase` to your target dependencies: @@ -55,9 +55,6 @@ Then add `FeatherDatabase` to your target dependencies: API documentation is available at the following link. Refer to the mock objects in the Tests directory if you want to build a custom database driver implementation. -> [!TIP] -> Avoid calling `database.execute` while in a transaction; use the transaction `connection.execute` instead. - > [!WARNING] > This repository is a work in progress, things can break until it reaches v1.0.0. diff --git a/Sources/FeatherDatabase/DatabaseClient.swift b/Sources/FeatherDatabase/DatabaseClient.swift index ca4b599..8cfeebc 100644 --- a/Sources/FeatherDatabase/DatabaseClient.swift +++ b/Sources/FeatherDatabase/DatabaseClient.swift @@ -18,64 +18,22 @@ public protocol DatabaseClient: Sendable { /// Execute work using a managed connection. /// /// The connection is provided to the closure for the duration of the call. - /// - Parameters: - /// - isolation: The actor isolation to use for the duration of the call. - /// - closure: A closure that receives a connection and returns a result. + /// - Parameter: closure: A closure that receives a connection and returns a result. /// - Throws: A `DatabaseError` if acquiring or using the connection fails. /// - Returns: The result returned by the closure. @discardableResult - func connection( - isolation: isolated (any Actor)?, - _ closure: (Connection) async throws -> sending T, - ) async throws(DatabaseError) -> sending T + func withConnection( + _ closure: (Connection) async throws -> T, + ) async throws(DatabaseError) -> T /// Execute work inside a transaction. /// /// Implementations should wrap the closure in a transaction boundary. - /// - Parameters: - /// - isolation: The actor isolation to use for the duration of the call. - /// - closure: A closure that receives a connection and returns a result. + /// - Parameter: closure: A closure that receives a connection and returns a result. /// - Throws: A `DatabaseError` if the transaction fails. /// - Returns: The result returned by the closure. @discardableResult - func transaction( - isolation: isolated (any Actor)?, - _ closure: (Connection) async throws -> sending T, - ) async throws(DatabaseError) -> sending T - - /// Execute a query using a managed connection. - /// - /// This is a convenience wrapper around `connection(_:)`. - /// - Parameters: - /// - isolation: The actor isolation to use for the duration of the call. - /// - query: The query to execute. - /// - Throws: A `DatabaseError` if execution fails. - /// - Returns: The query result. - @discardableResult - func execute( - isolation: isolated (any Actor)?, - query: Connection.Query, - ) async throws(DatabaseError) -> Connection.Result - -} - -extension DatabaseClient { - - /// Execute a query using a managed connection. - /// - /// This default implementation executes the query inside `connection(_:)`. - /// - Parameters: - /// - isolation: The actor isolation to use for the duration of the call. - /// - query: The query to execute. - /// - Throws: A `DatabaseError` if execution fails. - /// - Returns: The query result. - @discardableResult - public func execute( - isolation: isolated (any Actor)? = #isolation, - query: Connection.Query, - ) async throws(DatabaseError) -> Connection.Result { - try await connection(isolation: isolation) { connection in - try await connection.execute(query: query) - } - } + func withTransaction( + _ closure: (Connection) async throws -> T, + ) async throws(DatabaseError) -> T } diff --git a/Sources/FeatherDatabase/DatabaseConnection.swift b/Sources/FeatherDatabase/DatabaseConnection.swift index dfa4e3e..4de5106 100644 --- a/Sources/FeatherDatabase/DatabaseConnection.swift +++ b/Sources/FeatherDatabase/DatabaseConnection.swift @@ -10,30 +10,33 @@ import Logging /// A connection that can execute database queries. /// /// Implementations provide query execution and lifecycle management. -public protocol DatabaseConnection { +public protocol DatabaseConnection: Sendable { /// The query type supported by this connection. /// /// Use this to define the SQL and bindings type. associatedtype Query: DatabaseQuery - /// The query result type produced by this connection. + + /// The row sequence type produced by this connection. /// - /// The result must conform to `DatabaseQueryResult`. - associatedtype Result: DatabaseQueryResult + /// The result must conform to `DatabaseRowSequence`. + associatedtype RowSequence: DatabaseRowSequence /// The logger used for connection operations. /// /// This is used to record database-related diagnostics. var logger: Logger { get } - /// Execute a query against the connection. + /// Runs a query using the database connection. /// - /// Implementations should translate errors to `DatabaseError`. - /// - Parameter query: The query to execute. + /// - Parameters: + /// - query: The query to execute. + /// - handler: A closure that transforms the result into a generic value. /// - Throws: A `DatabaseError` if execution fails. /// - Returns: The result of the query execution. @discardableResult - func execute( - query: Query - ) async throws(DatabaseError) -> Result + func run( + query: Query, + _ handler: (RowSequence) async throws -> T + ) async throws(DatabaseError) -> T } diff --git a/Sources/FeatherDatabase/DatabaseError.swift b/Sources/FeatherDatabase/DatabaseError.swift index 04acbf4..22253f8 100644 --- a/Sources/FeatherDatabase/DatabaseError.swift +++ b/Sources/FeatherDatabase/DatabaseError.swift @@ -5,51 +5,24 @@ // Created by Tibor Bödecs on 2026. 01. 14.. // -/// A transaction error that captures failure details. -/// -/// Use this protocol to report errors from transaction phases. -public protocol DatabaseTransactionError: Error, Sendable { - /// The source file where the transaction error was created. - /// - /// This is typically populated using `#fileID`. - var file: String { get } - /// The source line where the transaction error was created. - /// - /// This is typically populated using `#line`. - var line: Int { get } - - /// The error thrown while beginning the transaction. - /// - /// This is set when the begin step fails. - var beginError: Error? { get set } - /// The error thrown inside the transaction closure. - /// - /// This is set when the closure fails before commit. - var closureError: Error? { get set } - /// The error thrown while committing the transaction. - /// - /// This is set when the commit step fails. - var commitError: Error? { get set } - /// The error thrown while rolling back the transaction. - /// - /// This is set when the rollback step fails. - var rollbackError: Error? { get set } -} - /// High-level database errors surfaced by the client API. /// /// Use these cases to represent connection, query, and transaction failures. -public enum DatabaseError: Error, Sendable { +public enum DatabaseError: Error { + /// A connection-level failure. /// /// The associated error provides the underlying cause. case connection(Error) - /// A query execution failure. - /// - /// The associated error provides the underlying cause. - case query(Error) + /// A transaction failure. /// /// The associated error includes phase-specific details. case transaction(DatabaseTransactionError) + + /// A query execution failure. + /// + /// The associated error provides the underlying cause. + case query(Error) + } diff --git a/Sources/FeatherDatabase/DatabaseQueryResult.swift b/Sources/FeatherDatabase/DatabaseQueryResult.swift deleted file mode 100644 index 00d8de5..0000000 --- a/Sources/FeatherDatabase/DatabaseQueryResult.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// DatabaseQueryResult.swift -// feather-database -// -// Created by Tibor Bödecs on 2026. 01. 10.. -// - -/// A result type returned from a database query. -/// -/// Use this protocol to iterate rows asynchronously or collect them eagerly. -public protocol DatabaseQueryResult: - AsyncSequence, - Sendable -where - Element == Row -{ - /// A row type produced by the query. - /// - /// This row must conform to `DatabaseRow` for decoding support. - associatedtype Row: DatabaseRow - - /// Collect all rows from the sequence. - /// - /// This method consumes the sequence and returns all elements. - /// - Throws: An error if iteration fails. - /// - Returns: An array of all rows produced by the query. - func collect() async throws -> [Element] - - /// Collect the first available row from the sequence. - /// - /// This method short-circuits after the first element is received. - /// - Throws: An error if iteration fails. - /// - Returns: The first row, or `nil` when the sequence is empty. - func collectFirst() async throws -> Element? -} - -extension DatabaseQueryResult { - - /// Collect the first available row from the sequence. - /// - /// This default implementation iterates until it finds the first element. - /// - Throws: An error if iteration fails. - /// - Returns: The first row, or `nil` when the sequence is empty. - public func collectFirst() async throws -> Element? { - for try await item in self { - return item - } - return nil - } -} diff --git a/Sources/FeatherDatabase/DatabaseRowSequence.swift b/Sources/FeatherDatabase/DatabaseRowSequence.swift new file mode 100644 index 0000000..54af26d --- /dev/null +++ b/Sources/FeatherDatabase/DatabaseRowSequence.swift @@ -0,0 +1,28 @@ +// +// DatabaseRowSequence.swift +// feather-database +// +// Created by Tibor Bödecs on 2026. 01. 10.. +// + +/// A sequence returned from a database query. +/// +/// Use this protocol to iterate rows asynchronously or collect them eagerly. +public protocol DatabaseRowSequence: + AsyncSequence, + Sendable +where + Element == Row +{ + /// A row type produced by the query. + /// + /// This row must conform to `DatabaseRow` for decoding support. + associatedtype Row: DatabaseRow + + /// Collect all rows from the sequence. + /// + /// This method consumes the sequence and returns all elements. + /// - Throws: An error if iteration fails. + /// - Returns: An array of all rows produced by the query. + func collect() async throws -> [Element] +} diff --git a/Sources/FeatherDatabase/DatabaseTransactionError.swift b/Sources/FeatherDatabase/DatabaseTransactionError.swift new file mode 100644 index 0000000..522b0b7 --- /dev/null +++ b/Sources/FeatherDatabase/DatabaseTransactionError.swift @@ -0,0 +1,37 @@ +// +// DatabaseTransactionError.swift +// feather-database +// +// Created by Tibor Bödecs on 2026. 02. 01.. +// + +/// A transaction error that captures failure details. +/// +/// Use this protocol to report errors from transaction phases. +public protocol DatabaseTransactionError: Error { + /// The source file where the transaction error was created. + /// + /// This is typically populated using `#fileID`. + var file: String { get } + /// The source line where the transaction error was created. + /// + /// This is typically populated using `#line`. + var line: Int { get } + + /// The error thrown while beginning the transaction. + /// + /// This is set when the begin step fails. + var beginError: Error? { get } + /// The error thrown inside the transaction closure. + /// + /// This is set when the closure fails before commit. + var closureError: Error? { get } + /// The error thrown while committing the transaction. + /// + /// This is set when the commit step fails. + var commitError: Error? { get } + /// The error thrown while rolling back the transaction. + /// + /// This is set when the rollback step fails. + var rollbackError: Error? { get } +} diff --git a/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift b/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift index cd0faab..76e3e2a 100644 --- a/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift +++ b/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift @@ -16,7 +16,7 @@ struct FeatherDatabaseTestSuite { @Test func executeUsesConnection() async throws { let state = MockDatabaseState() - let result = MockDatabaseQueryResult( + let sequence = MockDatabaseRowSequence( rows: [ MockDatabaseRow(storage: ["name": .string("alpha")]) ] @@ -24,16 +24,55 @@ struct FeatherDatabaseTestSuite { let connection = MockDatabaseConnection( logger: Logger(label: "test"), state: state, - result: result + mockSequence: sequence ) let client = MockDatabaseClient(state: state, connection: connection) let query = MockDatabaseQuery(sql: "SELECT 1", bindings: []) - _ = try await client.execute(query: query) + let decodedRows = try await client.withConnection { connection in + try await connection.run(query: query) { sequence in + try await sequence.collect() + .map { + try $0.decode(column: "name", as: String.self) + } + } + } + #expect(decodedRows.count == 1) - #expect(await state.connectionCount() == 1) + try await client.withConnection { connection in + try await connection.run(query: query) { sequence in + try await sequence.collect() + .map { + try $0.decode(column: "name", as: String.self) + } + } + } + + try await client.withConnection { connection in + try await connection.run(query: query) { _ in + // no value returned, no sequence iteration + } + } + + try await client.withTransaction { connection in + try await connection.run(query: query) { sequence in + try await sequence.collect() + .map { + try $0.decode(column: "name", as: String.self) + } + } + try await connection.run(query: query) { sequence in + try await sequence.collect() + .map { + try $0.decode(column: "name", as: String.self) + } + } + return "ok" + } + + #expect(await state.connectionCount() == 4) let executedQueries = await state.executedQueryList() - #expect(executedQueries.count == 1) + #expect(executedQueries.count == 5) #expect(executedQueries.first?.sql == query.sql) } @@ -43,9 +82,9 @@ struct FeatherDatabaseTestSuite { MockDatabaseRow(storage: ["name": .string("alpha")]), MockDatabaseRow(storage: ["name": .string("beta")]), ] - let result = MockDatabaseQueryResult(rows: rows) + let result = MockDatabaseRowSequence(rows: rows) - let first = try await result.collectFirst() + let first = try await result.collect().first #expect(first != nil) #expect( @@ -55,9 +94,9 @@ struct FeatherDatabaseTestSuite { @Test func collectFirstReturnsNilWhenEmpty() async throws { - let result = MockDatabaseQueryResult(rows: []) + let result = MockDatabaseRowSequence(rows: []) - let first = try await result.collectFirst() + let first = try await result.collect().first #expect(first == nil) } diff --git a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseClient.swift b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseClient.swift index f1389f8..d076863 100644 --- a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseClient.swift +++ b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseClient.swift @@ -12,11 +12,12 @@ struct MockDatabaseClient: DatabaseClient { let state: MockDatabaseState let connection: MockDatabaseConnection - func connection( - isolation: isolated (any Actor)? = #isolation, - _ closure: (MockDatabaseConnection) async throws -> sending T - ) async throws(DatabaseError) -> sending T { + @discardableResult + func withConnection( + _ closure: (MockDatabaseConnection) async throws -> T + ) async throws(DatabaseError) -> T { await state.recordConnection() + do { return try await closure(connection) } @@ -28,10 +29,10 @@ struct MockDatabaseClient: DatabaseClient { } } - func transaction( - isolation: isolated (any Actor)? = #isolation, - _ closure: (MockDatabaseConnection) async throws -> sending T - ) async throws(DatabaseError) -> sending T { + @discardableResult + func withTransaction( + _ closure: (MockDatabaseConnection) async throws -> T + ) async throws(DatabaseError) -> T { await state.recordConnection() do { return try await closure(connection) @@ -39,8 +40,11 @@ struct MockDatabaseClient: DatabaseClient { catch let error as DatabaseError { throw error } + catch let error as DatabaseTransactionError { + throw .transaction(error) + } catch { - throw .connection(error) + throw .query(error) } } diff --git a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseConnection.swift b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseConnection.swift index 18fc9ec..ab4ff8d 100644 --- a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseConnection.swift +++ b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseConnection.swift @@ -10,14 +10,28 @@ import Logging struct MockDatabaseConnection: DatabaseConnection { + typealias Query = MockDatabaseQuery + typealias RowSequence = MockDatabaseRowSequence + let logger: Logger let state: MockDatabaseState - let result: MockDatabaseQueryResult + let mockSequence: RowSequence - func execute( - query: MockDatabaseQuery - ) async throws(DatabaseError) -> MockDatabaseQueryResult { + @discardableResult + func run( + query: Query, + _ handler: (RowSequence) async throws -> T + ) async throws(DatabaseError) -> T { await state.recordExecution(query) - return result + do { + return try await handler(mockSequence) + } + catch let error as DatabaseError { + throw error + } + catch { + throw .query(error) + } } + } diff --git a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseQueryResult.swift b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRowSequence.swift similarity index 82% rename from Tests/FeatherDatabaseTests/Mocks/MockDatabaseQueryResult.swift rename to Tests/FeatherDatabaseTests/Mocks/MockDatabaseRowSequence.swift index 71f5694..e80ada7 100644 --- a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseQueryResult.swift +++ b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRowSequence.swift @@ -1,5 +1,5 @@ // -// MockDatabaseQueryResult.swift +// MockDatabaseRowSequence.swift // feather-database // // Created by Tibor Bodecs on 2026. 01. 10.. @@ -7,7 +7,7 @@ import FeatherDatabase -struct MockDatabaseQueryResult: DatabaseQueryResult { +struct MockDatabaseRowSequence: DatabaseRowSequence { let rows: [MockDatabaseRow] @@ -29,7 +29,7 @@ struct MockDatabaseQueryResult: DatabaseQueryResult { } } - func collect() async throws -> [Element] { + func collect() async throws(DatabaseError) -> [Element] { rows }