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 145a8b9..86e1f80 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 @@ -28,7 +28,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 2340c99..043a479 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 bc9e65b..146de01 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "816ee700ae5206734d35d787d67b0af726ff97f74c5e84dec48c842bb3679698", + "originHash" : "ec031c0e73c03866bdfcc8adfdfda67f96b863f8ba73e708298a520ec0daf502", "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 065ec5a..f856435 100644 --- a/Package.swift +++ b/Package.swift @@ -37,16 +37,30 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), .package(url: "https://github.com/vapor/sqlite-nio", from: "1.12.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: [ .target( - name: "FeatherSQLiteDatabase", + name: "SQLiteNIOExtras", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "SQLiteNIO", package: "sqlite-nio"), + ], + swiftSettings: defaultSwiftSettings + ), + .target( + name: "FeatherSQLiteDatabase", + dependencies: [ .product(name: "FeatherDatabase", package: "feather-database"), + .target(name: "SQLiteNIOExtras"), + ], + swiftSettings: defaultSwiftSettings + ), + .testTarget( + name: "SQLiteNIOExtrasTests", + dependencies: [ + .target(name: "SQLiteNIOExtras"), ], swiftSettings: defaultSwiftSettings ), diff --git a/README.md b/README.md index 6bd28f5..1748c5f 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,37 @@ SQLite 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-sqlite-database/releases/tag/1.0.0-beta.2 -) +[![Release: 1.0.0-beta.3](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E3-F05138)](https://github.com/feather-framework/feather-sqlite-database/releases/tag/1.0.0-beta.3) ## Features -- đŸ€ SQLite 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 +- SQLite 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+ +- Swift 6.1+ +- Platforms: + - Linux + - macOS 15+ + - iOS 18+ + - tvOS 18+ + - watchOS 11+ + - visionOS 2+ ## Installation Add the dependency to your `Package.swift`: ```swift -.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.2"), +.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.3"), ``` Then add `FeatherSQLiteDatabase` to your target dependencies: @@ -46,19 +41,13 @@ Then add `FeatherSQLiteDatabase` to your target dependencies: .product(name: "FeatherSQLiteDatabase", package: "feather-sqlite-database"), ``` - ## Usage - -[ - ![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) -]( - https://feather-framework.github.io/feather-sqlite-database/documentation/feathersqlitedatabase/ -) -API documentation is available at the following link. +API documentation is available at the link below: + +[![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138)](https://feather-framework.github.io/feather-sqlite-database/) -> [!TIP] -> Avoid calling `database.execute` while in a transaction; use the transaction `connection` instead. +Here is a brief example: ```swift import Logging @@ -75,19 +64,25 @@ let configuration = SQLiteClient.Configuration( ) let client = SQLiteClient(configuration: configuration) -try await client.run() -let database = SQLiteDatabaseClient(client: client) - -let result = try await database.execute( - query: #""" - SELECT - sqlite_version() AS "version" - WHERE - 1=\#(1); - """# +let database = SQLiteDatabaseClient( + client: client, + logger: logger ) +try await client.run() + +try await database.withConnection { connection in + try await connection.run( + query: #""" + SELECT + sqlite_version() AS "version" + WHERE + 1=\#(1); + """# + ) +} + for try await item in result { let version = try item.decode(column: "version", as: String.self) print(version) @@ -99,7 +94,6 @@ await client.shutdown() > [!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: @@ -110,12 +104,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-sqlite-database/pulls) are welcome. Please keep changes focused and include tests for new logic. 🙏 +[Pull requests](https://github.com/feather-framework/feather-sqlite-database/pulls) are welcome. Please keep changes focused and include tests for new logic. diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift deleted file mode 100644 index 0a3f46f..0000000 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// SQLiteConnection.swift -// feather-sqlite-database -// -// Created by Tibor Bödecs on 2026. 01. 10.. -// - -import FeatherDatabase -import SQLiteNIO - -extension SQLiteConnection: @retroactive DatabaseConnection { - - /// Execute a SQLite query on this connection. - /// - /// This wraps `SQLiteNIO` query execution and maps errors. - /// - Parameter query: The SQLite query to execute. - /// - Throws: A `DatabaseError` if the query fails. - /// - Returns: A query result containing the returned rows. - @discardableResult - public func execute( - query: SQLiteQuery - ) async throws(DatabaseError) -> SQLiteQueryResult { - let maxAttempts = 8 - var attempt = 0 - while true { - do { - let result = try await self.query( - query.sql, - query.bindings - ) - return SQLiteQueryResult(elements: result) - } - catch { - attempt += 1 - if attempt >= maxAttempts { - throw .query(error) - } - let delayMilliseconds = min(1000, 25 << (attempt - 1)) - do { - try await Task.sleep(for: .milliseconds(delayMilliseconds)) - } - catch { - throw .query(error) - } - } - } - } -} diff --git a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift index 946be84..ef17fcd 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift @@ -6,37 +6,30 @@ // import FeatherDatabase -import SQLiteNIO +import Logging +import SQLiteNIOExtras /// A SQLite-backed database client. /// /// Use this client to execute queries and manage transactions on SQLite. public struct SQLiteDatabaseClient: DatabaseClient { - private let client: SQLiteClient + public typealias Connection = SQLiteDatabaseConnection - /// Create a SQLite database client backed by a connection pool. - /// - /// - Parameter client: The SQLite client to use. - public init(client: SQLiteClient) { - self.client = client - } + let client: SQLiteClient + var logger: Logger /// Create a SQLite database client backed by a connection pool. /// - /// - Parameter configuration: The SQLite client configuration. - public init(configuration: SQLiteClient.Configuration) { - self.client = SQLiteClient(configuration: configuration) - } - - /// Pre-open the minimum number of connections. - public func run() async throws { - try await client.run() - } - - /// Close all pooled connections and refuse new leases. - public func shutdown() async { - await client.shutdown() + /// - Parameters: + /// - client: The SQLite client to use. + /// - logger: The logger to use. + public init( + client: SQLiteClient, + logger: Logger + ) { + self.client = client + self.logger = logger } // MARK: - database api @@ -44,33 +37,58 @@ public struct SQLiteDatabaseClient: DatabaseClient { /// Execute work using a leased connection. /// /// The closure is executed with a pooled connection. - /// - Parameters: - /// - isolation: The actor isolation to use for the closure. - /// - closure: A closure that receives the SQLite connection. + /// - Parameters closure: A closure that receives the SQLite connection. /// - Throws: A `DatabaseError` if the connection fails. /// - Returns: The query result produced by the closure. @discardableResult - public func connection( - isolation: isolated (any Actor)? = #isolation, - _ closure: (SQLiteConnection) async throws -> sending T - ) async throws(DatabaseError) -> sending T { - try await client.connection(isolation: isolation, closure) + public func withConnection( + _ closure: (Connection) async throws -> T + ) async throws(DatabaseError) -> T { + do { + return try await client.withConnection { connection in + try await closure( + SQLiteDatabaseConnection( + connection: connection, + logger: logger + ) + ) + } + } + catch { + throw .connection(error) + } } /// Execute work inside a SQLite transaction. /// /// The closure runs between `BEGIN` and `COMMIT` with rollback on failure. - /// - Parameters: - /// - isolation: The actor isolation to use for the closure. - /// - closure: A closure that receives the SQLite connection. + /// - Parameters closure: A closure that receives the SQLite connection. /// - Throws: A `DatabaseError` if transaction handling fails. /// - Returns: The query result produced by the closure. @discardableResult - public func transaction( - isolation: isolated (any Actor)? = #isolation, - _ closure: (SQLiteConnection) async throws -> sending T - ) async throws(DatabaseError) -> sending T { - try await client.transaction(isolation: isolation, closure) + public func withTransaction( + _ closure: (Connection) async throws -> T + ) async throws(DatabaseError) -> T { + do { + return try await client.withTransaction { connection in + try await closure( + SQLiteDatabaseConnection( + connection: connection, + logger: logger + ) + ) + } + } + catch let error as SQLiteTransactionError { + throw .transaction( + SQLiteDatabaseTransactionError( + underlyingError: error + ) + ) + } + catch { + throw .connection(error) + } } } diff --git a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseConnection.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseConnection.swift new file mode 100644 index 0000000..1db5ffa --- /dev/null +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseConnection.swift @@ -0,0 +1,50 @@ +// +// SQLiteDatabaseConnection.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 01. 10. +// + +import FeatherDatabase +import Logging +import SQLiteNIO + +public struct SQLiteDatabaseConnection: DatabaseConnection { + + public typealias Query = SQLiteDatabaseQuery + public typealias RowSequence = SQLiteDatabaseRowSequence + + var connection: SQLiteConnection + public var logger: Logger + + /// Execute a SQLite query on this connection. + /// + /// This wraps `SQLiteNIO` query execution and maps errors. + /// - Parameters: + /// - query: The SQLite query to execute. + /// - handler: The handler to process the result sequence. + /// - 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 result = try await connection.query( + query.sql, + query.bindings + ) + return try await handler( + SQLiteDatabaseRowSequence( + elements: result.map { + .init(row: $0) + } + ) + ) + } + catch { + throw .query(error) + } + } +} diff --git a/Sources/FeatherSQLiteDatabase/SQLiteQuery.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseQuery.swift similarity index 96% rename from Sources/FeatherSQLiteDatabase/SQLiteQuery.swift rename to Sources/FeatherSQLiteDatabase/SQLiteDatabaseQuery.swift index 79ed8bc..e845f76 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteQuery.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseQuery.swift @@ -1,8 +1,8 @@ // -// SQLiteQuery.swift +// SQLiteDatabaseQuery.swift // feather-sqlite-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 SQLiteNIO /// A SQLite query with SQL text and bound parameters. /// /// Use this type to construct SQLite queries safely. -public struct SQLiteQuery: DatabaseQuery { +public struct SQLiteDatabaseQuery: DatabaseQuery { /// The SQL text to execute. /// /// This is the raw SQL string for the query. @@ -36,7 +36,7 @@ public struct SQLiteQuery: DatabaseQuery { } } -extension SQLiteQuery: ExpressibleByStringInterpolation { +extension SQLiteDatabaseQuery: ExpressibleByStringInterpolation { /// A string interpolation builder for SQLite queries. /// diff --git a/Sources/FeatherSQLiteDatabase/SQLiteRow+DatabaseRow.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseRow.swift similarity index 94% rename from Sources/FeatherSQLiteDatabase/SQLiteRow+DatabaseRow.swift rename to Sources/FeatherSQLiteDatabase/SQLiteDatabaseRow.swift index f350fac..3203bf5 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteRow+DatabaseRow.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseRow.swift @@ -1,14 +1,15 @@ // -// SQLiteRow+DatabaseRow.swift +// SQLiteDatabaseRow.swift // feather-sqlite-database // -// Created by Tibor Bödecs on 2026. 01. 10.. +// Created by Tibor Bödecs on 2026. 01. 10. // import FeatherDatabase import SQLiteNIO -extension SQLiteRow: @retroactive DatabaseRow { +public struct SQLiteDatabaseRow: DatabaseRow { + var row: SQLiteRow struct SingleValueDecoder: Decoder, SingleValueDecodingContainer { @@ -91,7 +92,7 @@ extension SQLiteRow: @retroactive DatabaseRow { column: String, as type: T.Type ) throws(DecodingError) -> T { - guard let data = self.column(column) else { + guard let data = row.column(column) else { throw .dataCorrupted( .init( codingPath: [], diff --git a/Sources/FeatherSQLiteDatabase/SQLiteQueryResult.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseRowSequence.swift similarity index 77% rename from Sources/FeatherSQLiteDatabase/SQLiteQueryResult.swift rename to Sources/FeatherSQLiteDatabase/SQLiteDatabaseRowSequence.swift index 9eb9aef..a18a6a3 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteQueryResult.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseRowSequence.swift @@ -1,8 +1,8 @@ // -// SQLiteQueryResult.swift +// SQLiteDatabaseRowSequence.swift // feather-sqlite-database // -// Created by Tibor Bödecs on 2026. 01. 10.. +// Created by Tibor Bödecs on 2026. 01. 10. // import FeatherDatabase @@ -11,21 +11,23 @@ import SQLiteNIO /// A query result backed by SQLite rows. /// /// Use this type to iterate or collect SQLite query results. -public struct SQLiteQueryResult: DatabaseQueryResult { - let elements: [SQLiteRow] +public struct SQLiteDatabaseRowSequence: DatabaseRowSequence { + public typealias Row = SQLiteDatabaseRow + + let elements: [Row] /// An async iterator over SQLite rows. /// /// This iterator traverses the in-memory row list. public struct Iterator: AsyncIteratorProtocol { var index = 0 - let elements: [SQLiteRow] + let elements: [Row] /// Return the next row in the sequence. /// /// This returns `nil` after the last row. /// - Returns: The next `SQLiteRow`, or `nil` when finished. - public mutating func next() async -> SQLiteRow? { + public mutating func next() async -> Row? { guard index < elements.count else { return nil } @@ -47,7 +49,7 @@ public struct SQLiteQueryResult: DatabaseQueryResult { /// This returns the rows held by the result. /// - Throws: An error if collection fails. /// - Returns: An array of `SQLiteRow` values. - public func collect() async throws -> [SQLiteRow] { + public func collect() async throws -> [Row] { elements } } diff --git a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseTransactionError.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseTransactionError.swift new file mode 100644 index 0000000..926137f --- /dev/null +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseTransactionError.swift @@ -0,0 +1,44 @@ +// +// SQLiteDatabaseTransactionError.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 01. 10. +// + +import FeatherDatabase +import SQLiteNIOExtras + +/// Transaction error details for SQLite operations. +/// +/// Use this to capture errors from transaction phases. +public struct SQLiteDatabaseTransactionError: DatabaseTransactionError { + + var underlyingError: SQLiteTransactionError + + /// The source file where the error was created. + /// + /// This is captured with `#fileID` by default. + public var file: String { underlyingError.file } + /// The source line where the error was created. + /// + /// This is captured with `#line` by default. + public var line: Int { underlyingError.line } + + /// The error thrown while beginning the transaction. + /// + /// Set when the `BEGIN` step fails. + public var beginError: Error? { underlyingError.beginError } + /// The error thrown inside the transaction closure. + /// + /// Set when the closure fails before commit. + public var closureError: Error? { underlyingError.closureError } + /// The error thrown while committing the transaction. + /// + /// Set when the `COMMIT` step fails. + public var commitError: Error? { underlyingError.commitError } + /// The error thrown while rolling back the transaction. + /// + /// Set when the `ROLLBACK` step fails. + public var rollbackError: Error? { underlyingError.rollbackError } + +} diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/SQLiteNIOExtras/SQLiteClient.swift similarity index 72% rename from Sources/FeatherSQLiteDatabase/SQLiteClient.swift rename to Sources/SQLiteNIOExtras/SQLiteClient.swift index ccb3364..d0c8c57 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift +++ b/Sources/SQLiteNIOExtras/SQLiteClient.swift @@ -5,7 +5,6 @@ // Created by Tibor Bödecs on 2026. 01. 26.. // -import FeatherDatabase import Logging import SQLiteNIO @@ -112,51 +111,25 @@ public final class SQLiteClient: Sendable { // MARK: - database api - /// Execute a query using a managed connection. - /// - /// This default implementation executes the query inside `connection(_:)`. - /// Busy errors are retried with an exponential backoff (up to 8 attempts). - /// - 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: SQLiteConnection.Query, - ) async throws(DatabaseError) -> SQLiteConnection.Result { - try await connection(isolation: isolation) { connection in - try await connection.execute(query: query) - } - } - /// Execute work using a leased connection. /// /// The connection is returned to the pool when the closure completes. - /// - Parameters: - /// - isolation: The actor isolation to use for the closure. - /// - closure: A closure that receives a SQLite connection. + /// - Parameter closure: A closure that receives a SQLite connection. /// - Throws: A `DatabaseError` if leasing or execution fails. /// - Returns: The result produced by the closure. @discardableResult - public func connection( - isolation: isolated (any Actor)? = #isolation, - _ closure: (SQLiteConnection) async throws -> sending T - ) async throws(DatabaseError) -> sending T { + public func withConnection( + _ closure: (SQLiteConnection) async throws -> T + ) async throws -> T { let connection = try await leaseConnection() do { let result = try await closure(connection) await pool.releaseConnection(connection) return result } - catch let error as DatabaseError { - await pool.releaseConnection(connection) - throw error - } catch { await pool.releaseConnection(connection) - throw .connection(error) + throw error } } @@ -164,25 +137,28 @@ public final class SQLiteClient: Sendable { /// /// The transaction is committed on success and rolled back on failure. /// Busy errors are retried with an exponential backoff (up to 8 attempts). - /// - Parameters: - /// - isolation: The actor isolation to use for the closure. - /// - closure: A closure that receives a SQLite connection. + /// - Parameters closure: A closure that receives a SQLite connection. /// - Throws: A `DatabaseError` if transaction handling fails. /// - Returns: The result produced by the closure. @discardableResult - public func transaction( - isolation: isolated (any Actor)? = #isolation, - _ closure: (SQLiteConnection) async throws -> sending T - ) async throws(DatabaseError) -> sending T { + public func withTransaction( + _ closure: (SQLiteConnection) async throws -> T + ) async throws -> T { let connection = try await leaseConnection() do { - try await connection.execute(query: "BEGIN;") + try await pool.leaseTransactionPermit() } catch { await pool.releaseConnection(connection) - throw DatabaseError.transaction( - SQLiteTransactionError(beginError: error) - ) + throw error + } + do { + _ = try await connection.query("BEGIN;") + } + catch { + await pool.releaseTransactionPermit() + await pool.releaseConnection(connection) + throw SQLiteTransactionError(beginError: error) } var closureHasFinished = false @@ -191,16 +167,8 @@ public final class SQLiteClient: Sendable { let result = try await closure(connection) closureHasFinished = true - do { - try await connection.execute(query: "COMMIT;") - } - catch { - await pool.releaseConnection(connection) - throw DatabaseError.transaction( - SQLiteTransactionError(commitError: error) - ) - } - + _ = try await connection.query("COMMIT;") + await pool.releaseTransactionPermit() await pool.releaseConnection(connection) return result } @@ -211,7 +179,7 @@ public final class SQLiteClient: Sendable { txError.closureError = error do { - try await connection.execute(query: "ROLLBACK;") + _ = try await connection.query("ROLLBACK;") } catch { txError.rollbackError = error @@ -221,8 +189,9 @@ public final class SQLiteClient: Sendable { txError.commitError = error } + await pool.releaseTransactionPermit() await pool.releaseConnection(connection) - throw DatabaseError.transaction(txError) + throw txError } } @@ -232,14 +201,7 @@ public final class SQLiteClient: Sendable { await pool.connectionCount() } - private func leaseConnection() async throws(DatabaseError) - -> SQLiteConnection - { - do { - return try await pool.leaseConnection() - } - catch { - throw .connection(error) - } + private func leaseConnection() async throws -> SQLiteConnection { + try await pool.leaseConnection() } } diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift b/Sources/SQLiteNIOExtras/SQLiteConnectionPool.swift similarity index 66% rename from Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift rename to Sources/SQLiteNIOExtras/SQLiteConnectionPool.swift index a83cbab..b4ca9b1 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift +++ b/Sources/SQLiteNIOExtras/SQLiteConnectionPool.swift @@ -8,10 +8,6 @@ import Logging import SQLiteNIO -enum SQLiteConnectionPoolError: Error, Sendable { - case shutdown -} - actor SQLiteConnectionPool { private struct Waiter { @@ -19,12 +15,20 @@ actor SQLiteConnectionPool { let continuation: CheckedContinuation } + private struct TransactionWaiter { + let id: Int + let continuation: CheckedContinuation + } + private let configuration: SQLiteClient.Configuration private var availableConnections: [SQLiteConnection] = [] private var waiters: [Waiter] = [] private var totalConnections = 0 private var nextWaiterID = 0 private var isShutdown = false + private var transactionInUse = false + private var transactionWaiters: [TransactionWaiter] = [] + private var nextTransactionWaiterID = 0 init( configuration: SQLiteClient.Configuration @@ -112,12 +116,54 @@ actor SQLiteConnectionPool { ) } waiters.removeAll(keepingCapacity: false) + + for waiter in transactionWaiters { + waiter.continuation.resume( + throwing: SQLiteConnectionPoolError.shutdown + ) + } + transactionWaiters.removeAll(keepingCapacity: false) + transactionInUse = false } func connectionCount() -> Int { totalConnections } + func leaseTransactionPermit() async throws { + guard !isShutdown else { + throw SQLiteConnectionPoolError.shutdown + } + + if !transactionInUse { + transactionInUse = true + return + } + + let waiterID = nextTransactionWaiterID + nextTransactionWaiterID += 1 + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + transactionWaiters.append( + TransactionWaiter(id: waiterID, continuation: continuation) + ) + } + } onCancel: { + Task { await self.cancelTransactionWaiter(id: waiterID) } + } + } + + func releaseTransactionPermit() { + if transactionWaiters.isEmpty { + transactionInUse = false + return + } + + let waiter = transactionWaiters.removeFirst() + waiter.continuation.resume() + } + private func cancelWaiter( id: Int ) { @@ -128,23 +174,31 @@ actor SQLiteConnectionPool { waiter.continuation.resume(throwing: CancellationError()) } + private func cancelTransactionWaiter( + id: Int + ) { + guard let index = transactionWaiters.firstIndex(where: { $0.id == id }) + else { + return + } + let waiter = transactionWaiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } + private func makeConnection() async throws -> SQLiteConnection { let connection = try await SQLiteConnection.open( storage: configuration.storage, logger: configuration.logger ) do { - _ = try await connection.execute( - query: - "PRAGMA journal_mode = \(unescaped: configuration.journalMode.rawValue);" + _ = try await connection.query( + "PRAGMA journal_mode = \(configuration.journalMode.rawValue);" ) - _ = try await connection.execute( - query: - "PRAGMA busy_timeout = \(unescaped: String(configuration.busyTimeoutMilliseconds));" + _ = try await connection.query( + "PRAGMA busy_timeout = \(configuration.busyTimeoutMilliseconds);" ) - _ = try await connection.execute( - query: - "PRAGMA foreign_keys = \(unescaped: configuration.foreignKeysMode.rawValue);" + _ = try await connection.query( + "PRAGMA foreign_keys = \(configuration.foreignKeysMode.rawValue);" ) } catch { diff --git a/Sources/SQLiteNIOExtras/SQLiteConnectionPoolError.swift b/Sources/SQLiteNIOExtras/SQLiteConnectionPoolError.swift new file mode 100644 index 0000000..6cf2f80 --- /dev/null +++ b/Sources/SQLiteNIOExtras/SQLiteConnectionPoolError.swift @@ -0,0 +1,10 @@ +// +// SQLiteConnectionPoolError.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 02. 02.. +// + +enum SQLiteConnectionPoolError: Error { + case shutdown +} diff --git a/Sources/FeatherSQLiteDatabase/SQLiteTransactionError.swift b/Sources/SQLiteNIOExtras/SQLiteTransactionError.swift similarity index 86% rename from Sources/FeatherSQLiteDatabase/SQLiteTransactionError.swift rename to Sources/SQLiteNIOExtras/SQLiteTransactionError.swift index f250365..f0b38b3 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteTransactionError.swift +++ b/Sources/SQLiteNIOExtras/SQLiteTransactionError.swift @@ -2,15 +2,13 @@ // SQLiteTransactionError.swift // feather-sqlite-database // -// Created by Tibor Bödecs on 2026. 01. 10.. +// Created by Tibor Bödecs on 2026. 02. 03.. // -import FeatherDatabase - /// Transaction error details for SQLite operations. /// /// Use this to capture errors from transaction phases. -public struct SQLiteTransactionError: DatabaseTransactionError { +public struct SQLiteTransactionError: Error { /// The source file where the error was created. /// @@ -24,19 +22,19 @@ public struct SQLiteTransactionError: DatabaseTransactionError { /// The error thrown while beginning the transaction. /// /// Set when the `BEGIN` step fails. - public var beginError: Error? + public internal(set) var beginError: Error? /// The error thrown inside the transaction closure. /// /// Set when the closure fails before commit. - public var closureError: Error? + public internal(set) var closureError: Error? /// The error thrown while committing the transaction. /// /// Set when the `COMMIT` step fails. - public var commitError: Error? + public internal(set) var commitError: Error? /// The error thrown while rolling back the transaction. /// /// Set when the `ROLLBACK` step fails. - public var rollbackError: Error? + public internal(set) var rollbackError: Error? /// Create a SQLite transaction error payload. /// @@ -48,7 +46,7 @@ public struct SQLiteTransactionError: DatabaseTransactionError { /// - closureError: The error thrown inside the transaction closure. /// - commitError: The error thrown while committing the transaction. /// - rollbackError: The error thrown while rolling back the transaction. - public init( + init( file: String = #fileID, line: Int = #line, beginError: Error? = nil, diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift deleted file mode 100644 index c2b8ebf..0000000 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift +++ /dev/null @@ -1,252 +0,0 @@ -// -// SQLiteClientTestSuite.swift -// feather-sqlite-database -// -// Created by Tibor Bödecs on 2026. 01. 26.. -// - -import FeatherDatabase -import Logging -import SQLiteNIO -import Testing - -@testable import FeatherSQLiteDatabase - -@Suite -struct SQLiteClientTestSuite { - - private func makeTemporaryDatabasePath() -> String { - let suffix = UInt64.random(in: 0...UInt64.max) - return "/tmp/feather-sqlite-\(suffix).sqlite" - } - - private func randomTableSuffix() -> String { - String(UInt64.random(in: 0...UInt64.max)) - } - - private func runUsingTestClient( - _ closure: (SQLiteClient) async throws -> Void - ) async throws { - var logger = Logger(label: "test.sqlite.client") - logger.logLevel = .info - - let configuration = SQLiteClient.Configuration( - storage: .file(path: makeTemporaryDatabasePath()), - logger: logger, - ) - let client = SQLiteClient(configuration: configuration) - - try await client.run() - defer { Task { await client.shutdown() } } - - try await closure(client) - } - - @Test - func concurrentTransactionsUseMultipleConnections() async throws { - var logger = Logger(label: "test.sqlite.client") - logger.logLevel = .info - - let configuration = SQLiteClient.Configuration( - storage: .file(path: makeTemporaryDatabasePath()), - logger: logger - ) - let client = SQLiteClient(configuration: configuration) - - try await client.run() - - try await client.execute( - query: #""" - CREATE TABLE "items" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) - - async let first: Void = client.transaction { connection in - - try await connection.execute( - query: #""" - INSERT INTO "items" - ("id", "name") - VALUES - (1, 'alpha'); - """# - ) - } - - async let second: Void = client.transaction { connection in - try await connection.execute( - query: #""" - INSERT INTO "items" - ("id", "name") - VALUES - (2, 'beta'); - """# - ) - } - - do { - - _ = try await (first, second) - - let result = try await client.execute( - query: #""" - SELECT COUNT(*) AS "count" - FROM "items"; - """# - ) - let rows = try await result.collect() - - #expect(try rows[0].decode(column: "count", as: Int.self) == 2) - #expect(await client.connectionCount() == 2) - } - catch { - Issue.record(error) - } - - await client.shutdown() - } - - @Test - func concurrentTransactionUpdates() async throws { - try await runUsingTestClient { database in - let suffix = randomTableSuffix() - let table = "sessions_\(suffix)" - let sessionID = "session_\(suffix)" - - enum TestError: Error { - case missingRow - } - - try await database.execute( - query: #""" - DROP TABLE IF EXISTS "\#(unescaped: table)"; - """# - ) - try await database.execute( - query: #""" - CREATE TABLE "\#(unescaped: table)" ( - "id" TEXT NOT NULL PRIMARY KEY, - "access_token" TEXT NOT NULL, - "access_expires_at" INTEGER NOT NULL, - "refresh_token" TEXT NOT NULL, - "refresh_count" INTEGER NOT NULL DEFAULT 0 - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "\#(unescaped: table)" - ("id", "access_token", "access_expires_at", "refresh_token", "refresh_count") - VALUES - ( - \#(sessionID), - 'stale', - (strftime('%s','now') - 300), - 'refresh', - 0 - ); - """# - ) - - func getValidAccessToken(sessionID: String) async throws -> String { - try await database.transaction { connection in - - let updateResult = try await connection.execute( - query: #""" - UPDATE "\#(unescaped: table)" - SET - "refresh_count" = "refresh_count" + 1, - "access_token" = 'token_' || ("refresh_count" + 1), - "access_expires_at" = (strftime('%s','now') + 600) - WHERE - "id" = \#(sessionID) - AND "access_expires_at" - <= (strftime('%s','now') + 60) - RETURNING "access_token"; - """# - ) - let updatedRows = try await updateResult.collect() - if let updatedRow = updatedRows.first { - return try updatedRow.decode( - column: "access_token", - as: String.self - ) - } - - let result = try await connection.execute( - query: #""" - SELECT - "access_token", - "refresh_count", - "access_expires_at" > (strftime('%s','now') + 60) AS "is_valid" - FROM "\#(unescaped: table)" - WHERE "id" = \#(sessionID); - """# - ) - let rows = try await result.collect() - - guard let row = rows.first else { - throw TestError.missingRow - } - - let isValid = try row.decode( - column: "is_valid", - as: Bool.self - ) - #expect(isValid == true) - - return try row.decode( - column: "access_token", - as: String.self - ) - } - } - - let workerCount = 80 - var tokens: [String] = [] - try await withThrowingTaskGroup(of: String.self) { group in - for _ in 0.. strftime('%s','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 - ) - } - } -} diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift index 0b3231c..53a55f7 100644 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift @@ -4,10 +4,11 @@ // // Created by Tibor Bödecs on 2026. 01. 10.. // - +// import FeatherDatabase import Logging import SQLiteNIO +import SQLiteNIOExtras import Testing @testable import FeatherSQLiteDatabase @@ -28,7 +29,10 @@ struct SQLiteDatabaseTestSuite { let client = SQLiteClient(configuration: configuration) - let database = SQLiteDatabaseClient(client: client) + let database = SQLiteDatabaseClient( + client: client, + logger: logger + ) try await client.run() try await closure(database) @@ -38,431 +42,436 @@ struct SQLiteDatabaseTestSuite { @Test func foreignKeySupport() async throws { try await runUsingTestDatabaseClient { database in - - let result = - try await database.execute( + try await database.withConnection { connection in + let result = try await connection.run( query: "PRAGMA foreign_keys" - ) - .collect() + ) { try await $0.collect() } - #expect(result.count == 1) - #expect( - try result[0].decode(column: "foreign_keys", as: Int.self) == 1 - ) + #expect(result.count == 1) + #expect( + try result[0].decode(column: "foreign_keys", as: Int.self) + == 1 + ) + } } } @Test func tableCreation() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE IF NOT EXISTS "galaxies" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE IF NOT EXISTS "galaxies" ( - "id" INTEGER PRIMARY KEY, - "name" TEXT - ); - """# - ) - - let results = try await database.execute( - query: #""" - SELECT name - FROM sqlite_master - WHERE type = 'table' - ORDER BY name; - """# - ) + let results = try await connection.run( + query: #""" + SELECT name + FROM sqlite_master + WHERE type = 'table' + ORDER BY name; + """# + ) { try await $0.collect() } - let resultArray = try await results.collect() - #expect(resultArray.count == 1) + #expect(results.count == 1) - let item = resultArray[0] - let name = try item.decode(column: "name", as: String.self) - #expect(name == "galaxies") + let item = results[0] + let name = try item.decode(column: "name", as: String.self) + #expect(name == "galaxies") + } } } @Test func tableInsert() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE IF NOT EXISTS "galaxies" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE IF NOT EXISTS "galaxies" ( - "id" INTEGER PRIMARY KEY, - "name" TEXT - ); - """# - ) - - let name1 = "Andromeda" - let name2 = "Milky Way" + let name1 = "Andromeda" + let name2 = "Milky Way" - try await database.execute( - query: #""" - INSERT INTO "galaxies" - ("id", "name") - VALUES - (\#(nil), \#(name1)), - (\#(nil), \#(name2)); - """# - ) + try await connection.run( + query: #""" + INSERT INTO "galaxies" + ("id", "name") + VALUES + (\#(nil), \#(name1)), + (\#(nil), \#(name2)); + """# + ) - let results = try await database.execute( - query: #""" - SELECT * FROM "galaxies" ORDER BY "name" ASC; - """# - ) + let results = try await connection.run( + query: #""" + SELECT * FROM "galaxies" ORDER BY "name" ASC; + """# + ) { try await $0.collect() } - let resultArray = try await results.collect() - #expect(resultArray.count == 2) + #expect(results.count == 2) - let item1 = resultArray[0] - let name1result = try item1.decode(column: "name", as: String.self) - #expect(name1result == name1) + let item1 = results[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) + let item2 = results[1] + let name2result = try item2.decode( + column: "name", + as: String.self + ) + #expect(name2result == name2) + } } } @Test func rowDecoding() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "foo" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE "foo" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" TEXT - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "foo" - ("id", "value") - VALUES - (1, 'abc'), - (2, NULL); - """# - ) - - let result = - try await database.execute( + try await connection.run( query: #""" - SELECT "id", "value" - FROM "foo" - ORDER BY "id"; + INSERT INTO "foo" + ("id", "value") + VALUES + (1, 'abc'), + (2, NULL); """# ) - .collect() - #expect(result.count == 2) + let result = + try await connection.run( + query: #""" + SELECT "id", "value" + FROM "foo" + ORDER BY "id"; + """# + ) { try await $0.collect() } - let item1 = result[0] - let item2 = result[1] + #expect(result.count == 2) - #expect(try item1.decode(column: "id", as: Int.self) == 1) - #expect(try item2.decode(column: "id", as: Int.self) == 2) + let item1 = result[0] + let item2 = result[1] - #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) == 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 - ) + #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)) - == .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 + ) + } } } @Test func queryEncoding() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + let tableName = "foo" + let idColumn = "id" + let valueColumn = "value" + let row1: (Int, String?) = (1, "abc") + let row2: (Int, String?) = (2, nil) + + try await connection.run( + query: #""" + CREATE TABLE \#(unescaped: tableName) ( + \#(unescaped: idColumn) INTEGER NOT NULL PRIMARY KEY, + \#(unescaped: valueColumn) TEXT + ); + """# + ) - let tableName = "foo" - let idColumn = "id" - let valueColumn = "value" - let row1: (Int, String?) = (1, "abc") - let row2: (Int, String?) = (2, nil) - - try await database.execute( - query: #""" - CREATE TABLE \#(unescaped: tableName) ( - \#(unescaped: idColumn) INTEGER NOT NULL PRIMARY KEY, - \#(unescaped: valueColumn) TEXT - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO \#(unescaped: tableName) - (\#(unescaped: idColumn), \#(unescaped: valueColumn)) - VALUES - (\#(row1.0), \#(row1.1)), - (\#(row2.0), \#(row2.1)); - """# - ) - - let result = - try await database.execute( + try await connection.run( query: #""" - SELECT \#(unescaped: idColumn), \#(unescaped: valueColumn) - FROM \#(unescaped: tableName) - ORDER BY \#(unescaped: idColumn) ASC; + INSERT INTO \#(unescaped: tableName) + (\#(unescaped: idColumn), \#(unescaped: valueColumn)) + VALUES + (\#(row1.0), \#(row1.1)), + (\#(row2.0), \#(row2.1)); """# ) - .collect() - #expect(result.count == 2) + let result = + try await connection.run( + query: #""" + SELECT \#(unescaped: idColumn), \#(unescaped: valueColumn) + FROM \#(unescaped: tableName) + ORDER BY \#(unescaped: idColumn) ASC; + """# + ) { try await $0.collect() } - let item1 = result[0] - let item2 = result[1] + #expect(result.count == 2) - #expect(try item1.decode(column: "id", as: Int.self) == 1) - #expect(try item2.decode(column: "id", as: Int.self) == 2) + let item1 = result[0] + let item2 = result[1] - #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: "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 + ) + } } } @Test func unsafeSQLBindings() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "widgets" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE "widgets" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) + let insert = SQLiteDatabaseQuery( + unsafeSQL: #""" + INSERT INTO "widgets" + ("id", "name") + VALUES + (?, ?); + """#, + bindings: [.integer(1), .text("gizmo")] + ) - let insert = SQLiteQuery( - unsafeSQL: #""" - INSERT INTO "widgets" - ("id", "name") - VALUES - (?, ?); - """#, - bindings: [.integer(1), .text("gizmo")] - ) + try await connection.run(query: insert) - try await database.execute(query: insert) + let result = + try await connection.run( + query: #""" + SELECT "name" + FROM "widgets" + WHERE "id" = 1; + """# + ) { try await $0.collect() } - let result = - try await database.execute( - query: #""" - SELECT "name" - FROM "widgets" - WHERE "id" = 1; - """# + #expect(result.count == 1) + #expect( + try result[0].decode(column: "name", as: String.self) + == "gizmo" ) - .collect() - - #expect(result.count == 1) - #expect( - try result[0].decode(column: "name", as: String.self) == "gizmo" - ) + } } } @Test func optionalStringInterpolationNil() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "notes" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "body" TEXT + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE "notes" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "body" TEXT - ); + let body: String? = nil + let insert: SQLiteDatabaseQuery = #""" + INSERT INTO "notes" + ("id", "body") + VALUES + (1, \#(body)); """# - ) - let body: String? = nil - let insert: SQLiteQuery = #""" - INSERT INTO "notes" - ("id", "body") - VALUES - (1, \#(body)); - """# + try await connection.run(query: insert) - try await database.execute(query: insert) + let result = + try await connection.run( + query: #""" + SELECT "body" + FROM "notes" + WHERE "id" = 1; + """# + ) { try await $0.collect() } - let result = - try await database.execute( - query: #""" - SELECT "body" - FROM "notes" - WHERE "id" = 1; - """# + #expect(result.count == 1) + #expect( + try result[0].decode(column: "body", as: String?.self) + == nil ) - .collect() - - #expect(result.count == 1) - #expect( - try result[0].decode(column: "body", as: String?.self) == nil - ) + } } } @Test func sqliteDataInterpolation() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "tags" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "label" TEXT NOT NULL + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE "tags" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "label" TEXT NOT NULL - ); + let label: SQLiteData = .text("alpha") + let insert: SQLiteDatabaseQuery = #""" + INSERT INTO "tags" + ("id", "label") + VALUES + (1, \#(label)); """# - ) - let label: SQLiteData = .text("alpha") - let insert: SQLiteQuery = #""" - INSERT INTO "tags" - ("id", "label") - VALUES - (1, \#(label)); - """# + try await connection.run(query: insert) - try await database.execute(query: insert) + let result = + try await connection.run( + query: #""" + SELECT "label" + FROM "tags" + WHERE "id" = 1; + """# + ) { try await $0.collect() } - let result = - try await database.execute( - query: #""" - SELECT "label" - FROM "tags" - WHERE "id" = 1; - """# + #expect(result.count == 1) + #expect( + try result[0].decode(column: "label", as: String.self) + == "alpha" ) - .collect() - - #expect(result.count == 1) - #expect( - try result[0].decode(column: "label", as: String.self) - == "alpha" - ) + } } } @Test func resultSequenceIterator() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "numbers" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE "numbers" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" TEXT NOT NULL - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "numbers" - ("id", "value") - VALUES - (1, 'one'), - (2, 'two'); - """# - ) - - let result = try await database.execute( - query: #""" - SELECT "id", "value" - FROM "numbers" - ORDER BY "id"; - """# - ) + try await connection.run( + query: #""" + INSERT INTO "numbers" + ("id", "value") + VALUES + (1, 'one'), + (2, 'two'); + """# + ) - var iterator = result.makeAsyncIterator() - let first = await iterator.next() - let second = await iterator.next() - let third = await iterator.next() + let result = try await connection.run( + query: #""" + SELECT "id", "value" + FROM "numbers" + ORDER BY "id"; + """# + ) { try await $0.collect() } - #expect(first != nil) - #expect(second != nil) - #expect(third == nil) + #expect(result.count == 2) + let first = result[0] + let second = result[1] - if let first { #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.") - } } } @Test func collectFirstReturnsFirstRow() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "widgets" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE "widgets" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "widgets" - ("id", "name") - VALUES - (1, 'alpha'), - (2, 'beta'); - """# - ) - - let result = try await database.execute( - query: #""" - SELECT "name" - FROM "widgets" - ORDER BY "id" ASC; - """# - ) + try await connection.run( + query: #""" + INSERT INTO "widgets" + ("id", "name") + VALUES + (1, 'alpha'), + (2, 'beta'); + """# + ) - let first = try await result.collectFirst() + let result = + try await connection.run( + query: #""" + SELECT "name" + FROM "widgets" + ORDER BY "id" ASC; + """# + ) { try await $0.collect() } + .first - #expect(first != nil) - #expect( - try first?.decode(column: "name", as: String.self) == "alpha" - ) + #expect(result != nil) + #expect( + try result?.decode(column: "name", as: String.self) + == "alpha" + ) + } } } @@ -470,17 +479,19 @@ struct SQLiteDatabaseTestSuite { func transactionSuccess() async throws { try await runUsingTestDatabaseClient { database in - try await database.execute( - query: #""" - CREATE TABLE "items" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "items" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + } - try await database.transaction { connection in - try await connection.execute( + try await database.withTransaction { connection in + try await connection.run( query: #""" INSERT INTO "items" ("id", "name") @@ -490,40 +501,42 @@ struct SQLiteDatabaseTestSuite { ) } - let result = - try await database.execute( + try await database.withConnection { connection in + + let result = try await connection.run( query: #""" SELECT "name" FROM "items" WHERE "id" = 1; """# - ) - .collect() + ) { try await $0.collect() } - #expect(result.count == 1) - #expect( - try result[0].decode(column: "name", as: String.self) - == "widget" - ) + #expect(result.count == 1) + #expect( + try result[0].decode(column: "name", as: String.self) + == "widget" + ) + } } } @Test func transactionFailurePropagates() async throws { try await runUsingTestDatabaseClient { database in - - try await database.execute( - query: #""" - CREATE TABLE "dummy" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL - ); - """# - ) + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "dummy" ( + "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 "dummy" ("id", "name") @@ -532,7 +545,7 @@ struct SQLiteDatabaseTestSuite { """# ) - return try await connection.execute( + try await connection.run( query: #""" INSERT INTO "dummy" ("id", "name") @@ -562,105 +575,106 @@ struct SQLiteDatabaseTestSuite { ) } - let result = - try await database.execute( - query: #""" - SELECT "id" - FROM "dummy"; - """# - ) - .collect() + try await database.withConnection { connection in + let result = + try await connection.run( + query: #""" + SELECT "id" + FROM "dummy"; + """# + ) { try await $0.collect() } - #expect(result.isEmpty) + #expect(result.isEmpty) + } } } @Test func doubleRoundTrip() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "measurements" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" REAL NOT NULL + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE "measurements" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" REAL NOT NULL - ); - """# - ) - - let expected = 1.5 - - try await database.execute( - query: #""" - INSERT INTO "measurements" - ("id", "value") - VALUES - (1, \#(expected)); - """# - ) + let expected = 1.5 - let result = - try await database.execute( + try await connection.run( query: #""" - SELECT "value" - FROM "measurements" - WHERE "id" = 1; + INSERT INTO "measurements" + ("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 "measurements" + WHERE "id" = 1; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "value", as: Double.self) + == expected + ) + } } } @Test func missingColumnThrows() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "items" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE "items" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" TEXT - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "items" - ("id", "value") - VALUES - (1, 'abc'); - """# - ) - - let result = - try await database.execute( + try await connection.run( query: #""" - SELECT "id" - FROM "items"; + INSERT INTO "items" + ("id", "value") + VALUES + (1, 'abc'); """# ) - .collect() - #expect(result.count == 1) + let result = + try await connection.run( + query: #""" + SELECT "id" + FROM "items"; + """# + ) { try await $0.collect() } - do { - _ = try result[0].decode(column: "value", as: String.self) - Issue.record("Expected decoding a missing column to throw.") - } - catch DecodingError.dataCorrupted { + #expect(result.count == 1) - } - catch { - Issue.record( - "Expected a dataCorrupted error for missing column." - ) + 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." + ) + } } } } @@ -668,47 +682,47 @@ struct SQLiteDatabaseTestSuite { @Test func typeMismatchThrows() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "items" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) - try await database.execute( - query: #""" - CREATE TABLE "items" ( - "id" INTEGER NOT NULL PRIMARY KEY, - "value" TEXT - ); - """# - ) - - try await database.execute( - query: #""" - INSERT INTO "items" - ("id", "value") - VALUES - (1, 'abc'); - """# - ) - - let result = - try await database.execute( + try await connection.run( query: #""" - SELECT "value" - FROM "items"; + INSERT INTO "items" + ("id", "value") + VALUES + (1, 'abc'); """# ) - .collect() - #expect(result.count == 1) + let result = + try await connection.run( + query: #""" + SELECT "value" + FROM "items"; + """# + ) { 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." + ) + } } } } @@ -716,21 +730,22 @@ struct SQLiteDatabaseTestSuite { @Test func queryFailureErrorText() async throws { try await runUsingTestDatabaseClient { database in - - do { - _ = try await database.execute( - query: #""" - SELECT * - FROM "missing_table"; - """# - ) - Issue.record("Expected query to fail for missing table.") - } - catch DatabaseError.query(let error) { - #expect("\(error)".contains("no such table")) - } - catch { - Issue.record("Expected database query error to be thrown.") + try await database.withConnection { connection in + do { + _ = try await connection.run( + query: #""" + SELECT * + FROM "missing_table"; + """# + ) + Issue.record("Expected query to fail for missing table.") + } + catch DatabaseError.query(let error) { + #expect("\(error)".contains("no such table")) + } + catch { + Issue.record("Expected database query error to be thrown.") + } } } } @@ -738,22 +753,25 @@ struct SQLiteDatabaseTestSuite { @Test func versionCheck() async throws { try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + let result = try await connection.run( + query: #""" + SELECT + sqlite_version() AS "version" + WHERE + 1=\#(1); + """# + ) { try await $0.collect() } - let result = try await database.execute( - query: #""" - SELECT - sqlite_version() AS "version" - WHERE - 1=\#(1); - """# - ) - - let resultArray = try await result.collect() - #expect(resultArray.count == 1) + #expect(result.count == 1) - let item = resultArray[0] - let version = try item.decode(column: "version", as: String.self) - #expect(version.split(separator: ".").count == 3) + let item = result[0] + let version = try item.decode( + column: "version", + as: String.self + ) + #expect(version.split(separator: ".").count == 3) + } } } } diff --git a/Tests/SQLiteNIOExtrasTests/SQLiteNIOExtrasTestSuite.swift b/Tests/SQLiteNIOExtrasTests/SQLiteNIOExtrasTestSuite.swift new file mode 100644 index 0000000..6999413 --- /dev/null +++ b/Tests/SQLiteNIOExtrasTests/SQLiteNIOExtrasTestSuite.swift @@ -0,0 +1,232 @@ +// +// SQLiteNIOExtrasTestSuite.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 01. 10.. +// +// +import Logging +import SQLiteNIO +import Testing + +@testable import SQLiteNIOExtras + +@Suite +struct SQLiteNIOExtrasTestSuite { + + private func makeTemporaryDatabasePath() -> String { + let suffix = UInt64.random(in: 0...UInt64.max) + return "/tmp/feather-sqlite-\(suffix).sqlite" + } + + private func randomTableSuffix() -> String { + String(UInt64.random(in: 0...UInt64.max)) + } + + private func runUsingTestClient( + _ closure: (SQLiteClient) async throws -> Void + ) async throws { + var logger = Logger(label: "test.sqlite.client") + logger.logLevel = .info + + let configuration = SQLiteClient.Configuration( + storage: .file(path: makeTemporaryDatabasePath()), + logger: logger, + ) + let client = SQLiteClient(configuration: configuration) + + try await client.run() + defer { Task { await client.shutdown() } } + + try await closure(client) + } + + @Test + func concurrentTransactionsUseMultipleConnections() async throws { + try await runUsingTestClient { client in + + try await client.withConnection { connection in + + try await connection.query( + #""" + CREATE TABLE "items" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + } + + async let first = client.withTransaction { connection in + try await connection.query( + #""" + INSERT INTO "items" + ("id", "name") + VALUES + (1, 'alpha'); + """# + ) + } + + async let second = client.withTransaction { connection in + try await connection.query( + #""" + INSERT INTO "items" + ("id", "name") + VALUES + (2, 'beta'); + """# + ) + } + + do { + _ = try await (first, second) + + let rows = try await client.withConnection { connection in + try await connection.query( + #""" + SELECT COUNT(*) AS "count" + FROM "items"; + """# + ) + } + #expect(rows.count == 1) + #expect(rows[0].column("count")?.integer == 2) + + #expect(await client.connectionCount() == 2) + } + catch { + Issue.record(error) + } + + } + } + + @Test + func concurrentTransactionUpdates() async throws { + try await runUsingTestClient { client in + let suffix = randomTableSuffix() + let table = "sessions_\(suffix)" + let sessionID = "session_\(suffix)" + + enum TestError: Error { + case missingRow + } + + try await client.withConnection { connection in + + _ = try await connection.query( + #""" + DROP TABLE IF EXISTS "\#(table)"; + """# + ) + _ = try await connection.query( + #""" + CREATE TABLE "\#(table)" ( + "id" TEXT NOT NULL PRIMARY KEY, + "access_token" TEXT NOT NULL, + "access_expires_at" INTEGER NOT NULL, + "refresh_token" TEXT NOT NULL, + "refresh_count" INTEGER NOT NULL DEFAULT 0 + ); + """# + ) + + _ = try await connection.query( + #""" + INSERT INTO "\#(table)" + ("id", "access_token", "access_expires_at", "refresh_token", "refresh_count") + VALUES + ( + '\#(sessionID)', + 'stale', + (strftime('%s','now') - 300), + 'refresh', + 0 + ); + """# + ) + } + + func getValidAccessToken( + sessionID: String + ) async throws -> String { + try await client.withTransaction { connection in + + let updatedRows = try await connection.query( + #""" + UPDATE "\#(table)" + SET + "refresh_count" = "refresh_count" + 1, + "access_token" = 'token_' || ("refresh_count" + 1), + "access_expires_at" = (strftime('%s','now') + 600) + WHERE + "id" = '\#(sessionID)' + AND "access_expires_at" + <= (strftime('%s','now') + 60) + RETURNING "access_token"; + """# + ) + + if let updatedRow = updatedRows.first { + return updatedRow.column("access_token")?.string ?? "" + } + + let rows = try await connection.query( + #""" + SELECT + "access_token", + "refresh_count", + "access_expires_at" > (strftime('%s','now') + 60) AS "is_valid" + FROM "\#(table)" + WHERE "id" = '\#(sessionID)'; + """# + ) + + guard let row = rows.first else { + throw TestError.missingRow + } + + let isValid = row.column("is_valid")?.bool ?? false + + #expect(isValid == true) + + return row.column("access_token")?.string ?? "" + } + } + + let workerCount = 80 + var tokens: [String] = [] + try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0.. strftime('%s','now') AS "is_valid" + FROM "\#(table)" + WHERE "id" = '\#(sessionID)'; + """# + ) + } + + #expect(result.count == 1) + #expect(result[0].column("refresh_count")?.integer == 1) + #expect(result[0].column("access_token")?.string == "token_1") + #expect(result[0].column("is_valid")?.bool == true) + } + } +}