diff --git a/Package.resolved b/Package.resolved index 393a8bd..bc9e65b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "3f3c993362a6be1c507fa40f0e4113550a3f2c599ccc4396da554f3df56b6b51", + "originHash" : "816ee700ae5206734d35d787d67b0af726ff97f74c5e84dec48c842bb3679698", "pins" : [ { "identity" : "feather-database", "kind" : "remoteSourceControl", "location" : "https://github.com/feather-framework/feather-database", "state" : { - "revision" : "34b05e9ca725bf857c9bc6e29603a4e457f9969a", - "version" : "1.0.0-beta.1" + "revision" : "147c96803f398c50f5717ce08ebd78a9e3ab7ca7", + "version" : "1.0.0-beta.2" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "4a9a97111099376854a7f8f0f9f88b9d61f52eff", - "version" : "2.92.2" + "revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e", + "version" : "2.93.0" } }, { diff --git a/Package.swift b/Package.swift index 2ab00bc..065ec5a 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,8 @@ 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.1"), + .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.2"), + // [docc-plugin-placeholder] ], targets: [ .target( diff --git a/README.md b/README.md index 84ee13b..6bd28f5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ SQLite driver implementation for the abstract [Feather Database](https://github.com/feather-framework/feather-database) Swift API package. -![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138) +[ + ![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 +) ## Features @@ -33,7 +37,7 @@ SQLite driver implementation for the abstract [Feather Database](https://github. Add the dependency to your `Package.swift`: ```swift -.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.1"), +.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.2"), ``` Then add `FeatherSQLiteDatabase` to your target dependencies: @@ -45,7 +49,11 @@ Then add `FeatherSQLiteDatabase` to your target dependencies: ## Usage -![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) +[ + ![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. @@ -61,15 +69,15 @@ import FeatherSQLiteDatabase var logger = Logger(label: "example") logger.logLevel = .info -let connection = try await SQLiteConnection.open( +let configuration = SQLiteClient.Configuration( storage: .file(path: "/Users/me/db.sqlite"), logger: logger ) -let database = SQLiteDatabaseClient( - connection: connection, - logger: logger -) +let client = SQLiteClient(configuration: configuration) +try await client.run() + +let database = SQLiteDatabaseClient(client: client) let result = try await database.execute( query: #""" @@ -85,7 +93,7 @@ for try await item in result { print(version) } -try await connection.close() +await client.shutdown() ``` > [!WARNING] @@ -104,7 +112,7 @@ The following database driver implementations are available for use: - Build: `swift build` - Test: - local: `swift test` - - using Docker: `swift docker-test` + - using Docker: `make docker-test` - Format: `make format` - Check: `make check` diff --git a/Sources/FeatherSQLiteDatabase/SQLiteClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift new file mode 100644 index 0000000..ccb3364 --- /dev/null +++ b/Sources/FeatherSQLiteDatabase/SQLiteClient.swift @@ -0,0 +1,245 @@ +// +// SQLiteClient.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 01. 26.. +// + +import FeatherDatabase +import Logging +import SQLiteNIO + +/// A SQLite client backed by a connection pool. +/// +/// Use this client to execute queries and transactions concurrently. +public final class SQLiteClient: Sendable { + + /// Configuration values for a pooled SQLite client. + public struct Configuration: Sendable { + + /// SQLite journal mode options for new connections. + public enum JournalMode: String, Sendable { + /// Roll back changes by copying the original content. + case delete = "DELETE" + /// Roll back changes by truncating the rollback journal. + case truncate = "TRUNCATE" + /// Roll back changes by zeroing the journal header. + case persist = "PERSIST" + /// Keep the journal in memory. + case memory = "MEMORY" + /// Use write-ahead logging to improve concurrency. + case wal = "WAL" + /// Disable the rollback journal. + case off = "OFF" + } + + /// SQLite foreign key enforcement options for new connections. + public enum ForeignKeysMode: String, Sendable { + /// Disable foreign key enforcement. + case off = "OFF" + /// Enable foreign key enforcement. + case on = "ON" + } + + /// The SQLite storage to open connections against. + public let storage: SQLiteConnection.Storage + /// Minimum number of pooled connections to keep open. + public let minimumConnections: Int + /// Maximum number of pooled connections to allow. + public let maximumConnections: Int + /// Logger used for pool operations. + public let logger: Logger + /// Journal mode applied to each pooled connection. + public let journalMode: JournalMode + /// Busy timeout, in milliseconds, applied to each pooled connection. + public let busyTimeoutMilliseconds: Int + /// Foreign key enforcement mode applied to each pooled connection. + public let foreignKeysMode: ForeignKeysMode + + /// Create a SQLite client configuration. + /// - Parameters: + /// - storage: The SQLite storage to use. + /// - logger: The logger for database operations. + /// - minimumConnections: The minimum number of pooled connections. + /// - maximumConnections: The maximum number of pooled connections. + /// - journalMode: The journal mode to apply to connections. + /// - foreignKeysMode: The foreign key enforcement mode to apply. + /// - busyTimeoutMilliseconds: The busy timeout to apply, in milliseconds. + public init( + storage: SQLiteConnection.Storage, + logger: Logger, + minimumConnections: Int = 1, + maximumConnections: Int = 8, + journalMode: JournalMode = .wal, + foreignKeysMode: ForeignKeysMode = .on, + busyTimeoutMilliseconds: Int = 1000 + ) { + precondition(minimumConnections >= 0) + precondition(maximumConnections >= 1) + precondition(minimumConnections <= maximumConnections) + precondition(busyTimeoutMilliseconds >= 0) + self.storage = storage + self.minimumConnections = minimumConnections + self.maximumConnections = maximumConnections + self.logger = logger + self.journalMode = journalMode + self.foreignKeysMode = foreignKeysMode + self.busyTimeoutMilliseconds = busyTimeoutMilliseconds + } + } + + private let pool: SQLiteConnectionPool + + /// Create a SQLite client with a connection pool. + /// - Parameter configuration: The client configuration. + public init(configuration: Configuration) { + self.pool = SQLiteConnectionPool( + configuration: configuration + ) + } + + // MARK: - pool service + + /// Pre-open the minimum number of connections. + public func run() async throws { + try await pool.warmup() + } + + /// Close all pooled connections and refuse new leases. + public func shutdown() async { + await pool.shutdown() + } + + // 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. + /// - 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 { + 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) + } + } + + /// Execute work inside a SQLite transaction. + /// + /// 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. + /// - 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 { + let connection = try await leaseConnection() + do { + try await connection.execute(query: "BEGIN;") + } + catch { + await pool.releaseConnection(connection) + throw DatabaseError.transaction( + SQLiteTransactionError(beginError: error) + ) + } + + var closureHasFinished = false + + do { + 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) + ) + } + + await pool.releaseConnection(connection) + return result + } + catch { + var txError = SQLiteTransactionError() + + if !closureHasFinished { + txError.closureError = error + + do { + try await connection.execute(query: "ROLLBACK;") + } + catch { + txError.rollbackError = error + } + } + else { + txError.commitError = error + } + + await pool.releaseConnection(connection) + throw DatabaseError.transaction(txError) + } + } + + // MARK: - pool + + func connectionCount() async -> Int { + await pool.connectionCount() + } + + private func leaseConnection() async throws(DatabaseError) + -> SQLiteConnection + { + do { + return try await pool.leaseConnection() + } + catch { + throw .connection(error) + } + } +} diff --git a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift index a74b7cf..0a3f46f 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnection.swift @@ -20,15 +20,29 @@ extension SQLiteConnection: @retroactive DatabaseConnection { public func execute( query: SQLiteQuery ) async throws(DatabaseError) -> SQLiteQueryResult { - do { - let result = try await self.query( - query.sql, - query.bindings - ) - return SQLiteQueryResult(elements: result) - } - catch { - throw .query(error) + 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/SQLiteConnectionPool.swift b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift new file mode 100644 index 0000000..a83cbab --- /dev/null +++ b/Sources/FeatherSQLiteDatabase/SQLiteConnectionPool.swift @@ -0,0 +1,178 @@ +// +// SQLiteConnectionPool.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 01. 26.. +// + +import Logging +import SQLiteNIO + +enum SQLiteConnectionPoolError: Error, Sendable { + case shutdown +} + +actor SQLiteConnectionPool { + + private struct Waiter { + 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 + + init( + configuration: SQLiteClient.Configuration + ) { + self.configuration = configuration + } + + func warmup() async throws { + guard !isShutdown else { return } + let target = configuration.minimumConnections + guard totalConnections < target else { return } + let newConnections = target - totalConnections + for _ in 0.. SQLiteConnection { + guard !isShutdown else { + throw SQLiteConnectionPoolError.shutdown + } + + if let connection = availableConnections.popLast() { + return connection + } + + if totalConnections < configuration.maximumConnections { + totalConnections += 1 + do { + return try await makeConnection() + } + catch { + totalConnections -= 1 + throw error + } + } + + let waiterID = nextWaiterID + nextWaiterID += 1 + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiters.append( + Waiter(id: waiterID, continuation: continuation) + ) + } + } onCancel: { + Task { await self.cancelWaiter(id: waiterID) } + } + } + + func releaseConnection( + _ connection: SQLiteConnection + ) async { + if isShutdown { + await closeConnection(connection) + return + } + + if waiters.isEmpty { + availableConnections.append(connection) + return + } + + let waiter = waiters.removeFirst() + waiter.continuation.resume(returning: connection) + } + + func shutdown() async { + guard !isShutdown else { return } + isShutdown = true + + let connections = availableConnections + availableConnections.removeAll(keepingCapacity: false) + + for connection in connections { + await closeConnection(connection) + } + + for waiter in waiters { + waiter.continuation.resume( + throwing: SQLiteConnectionPoolError.shutdown + ) + } + waiters.removeAll(keepingCapacity: false) + } + + func connectionCount() -> Int { + totalConnections + } + + private func cancelWaiter( + id: Int + ) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { + return + } + let waiter = waiters.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.execute( + query: + "PRAGMA busy_timeout = \(unescaped: String(configuration.busyTimeoutMilliseconds));" + ) + _ = try await connection.execute( + query: + "PRAGMA foreign_keys = \(unescaped: configuration.foreignKeysMode.rawValue);" + ) + } + catch { + do { + try await connection.close() + } + catch { + configuration.logger.warning( + "Failed to close SQLite connection after setup error", + metadata: ["error": "\(error)"] + ) + } + throw error + } + return connection + } + + private func closeConnection( + _ connection: SQLiteConnection + ) async { + do { + try await connection.close() + } + catch { + configuration.logger.warning( + "Failed to close SQLite connection", + metadata: ["error": "\(error)"] + ) + } + } +} diff --git a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift index cd8c98b..946be84 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseClient.swift @@ -6,7 +6,6 @@ // import FeatherDatabase -import Logging import SQLiteNIO /// A SQLite-backed database client. @@ -14,47 +13,48 @@ import SQLiteNIO /// Use this client to execute queries and manage transactions on SQLite. public struct SQLiteDatabaseClient: DatabaseClient { - var connection: SQLiteConnection - var logger: Logger + private let client: SQLiteClient - /// Create a SQLite database client. + /// Create a SQLite database client backed by a connection pool. /// - /// Use this initializer to provide an already-open connection. - /// - Parameters: - /// - connection: The SQLite connection to use. - /// - logger: The logger for database operations. - public init( - connection: SQLiteConnection, - logger: Logger - ) { - self.connection = connection - self.logger = logger + /// - Parameter client: The SQLite client to use. + public init(client: SQLiteClient) { + self.client = client + } + + /// 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() } // MARK: - database api - /// Execute work using the stored connection. + /// Execute work using a leased connection. /// - /// The closure is executed with the current 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. /// - Throws: A `DatabaseError` if the connection fails. /// - Returns: The query result produced by the closure. @discardableResult - public func connection( + public func connection( isolation: isolated (any Actor)? = #isolation, - _ closure: (SQLiteConnection) async throws -> sending SQLiteQueryResult - ) async throws(DatabaseError) -> sending SQLiteQueryResult { - do { - return try await closure(connection) - } - catch let error as DatabaseError { - throw error - } - catch { - throw .connection(error) - } + _ closure: (SQLiteConnection) async throws -> sending T + ) async throws(DatabaseError) -> sending T { + try await client.connection(isolation: isolation, closure) } /// Execute work inside a SQLite transaction. @@ -66,56 +66,11 @@ public struct SQLiteDatabaseClient: DatabaseClient { /// - Throws: A `DatabaseError` if transaction handling fails. /// - Returns: The query result produced by the closure. @discardableResult - public func transaction( + public func transaction( isolation: isolated (any Actor)? = #isolation, - _ closure: (SQLiteConnection) async throws -> sending SQLiteQueryResult - ) async throws(DatabaseError) -> sending SQLiteQueryResult { - - do { - try await connection.execute(query: "BEGIN;") - } - catch { - throw DatabaseError.transaction( - SQLiteTransactionError(beginError: error) - ) - } - - var closureHasFinished = false - - do { - let result = try await closure(connection) - closureHasFinished = true - - do { - try await connection.execute(query: "COMMIT;") - } - catch { - throw DatabaseError.transaction( - SQLiteTransactionError(commitError: error) - ) - } - - return result - } - catch { - var txError = SQLiteTransactionError() - - if !closureHasFinished { - txError.closureError = error - - do { - try await connection.execute(query: "ROLLBACK;") - } - catch { - txError.rollbackError = error - } - } - else { - txError.commitError = error - } - - throw DatabaseError.transaction(txError) - } + _ closure: (SQLiteConnection) async throws -> sending T + ) async throws(DatabaseError) -> sending T { + try await client.transaction(isolation: isolation, closure) } } diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift new file mode 100644 index 0000000..c2b8ebf --- /dev/null +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteClientTestSuite.swift @@ -0,0 +1,252 @@ +// +// 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 3e700ec..0b3231c 100644 --- a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseTestSuite.swift @@ -21,29 +21,24 @@ struct SQLiteDatabaseTestSuite { var logger = Logger(label: "test") logger.logLevel = .info - let connection = try await SQLiteConnection.open( + let configuration = SQLiteClient.Configuration( storage: .memory, logger: logger ) - let database = SQLiteDatabaseClient( - connection: connection, - logger: logger - ) + let client = SQLiteClient(configuration: configuration) - try await closure(database) + let database = SQLiteDatabaseClient(client: client) - try await connection.close() + try await client.run() + try await closure(database) + await client.shutdown() } @Test func foreignKeySupport() async throws { try await runUsingTestDatabaseClient { database in - try await database.execute( - query: "PRAGMA foreign_keys = ON" - ) - let result = try await database.execute( query: "PRAGMA foreign_keys"