diff --git a/Package.resolved b/Package.resolved index db59dca..7e0b05b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "83d3cc76133b2b6edb8e5e638762c23e93f7f69a4d70acc970ba1d2c9cf8ace3", + "originHash" : "93cc0f0ca420ec1c07aff996c5017b9efedf86f062a162baf495851a59d5c0c3", "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" } }, { diff --git a/Package.swift b/Package.swift index 00d997a..b18384a 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/mysql-nio", from: "1.8.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 b3a6167..2941e60 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ MySQL/MariaDB 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-mysql-database/releases/tag/1.0.0-beta.2 +) ## Features @@ -33,7 +37,7 @@ MySQL/MariaDB driver implementation for the abstract [Feather Database](https:// Add the dependency to your `Package.swift`: ```swift -.package(url: "https://github.com/feather-framework/feather-mysql-database", exact: "1.0.0-beta.1"), +.package(url: "https://github.com/feather-framework/feather-mysql-database", exact: "1.0.0-beta.2"), ``` Then add `FeatherMySQLDatabase` to your target dependencies: @@ -44,8 +48,12 @@ Then add `FeatherMySQLDatabase` 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-mysql-database/documentation/feathermysqldatabase/ +) API documentation is available at the following link. @@ -131,7 +139,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/FeatherMySQLDatabase/MySQLDatabaseClient.swift b/Sources/FeatherMySQLDatabase/MySQLDatabaseClient.swift index 60d0b53..54ae149 100644 --- a/Sources/FeatherMySQLDatabase/MySQLDatabaseClient.swift +++ b/Sources/FeatherMySQLDatabase/MySQLDatabaseClient.swift @@ -42,10 +42,10 @@ public struct MySQLDatabaseClient: DatabaseClient { /// - 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: (MySQLConnection) async throws -> sending MySQLQueryResult - ) async throws(DatabaseError) -> sending MySQLQueryResult { + _ closure: (MySQLConnection) async throws -> sending T + ) async throws(DatabaseError) -> sending T { do { return try await closure(connection) } @@ -66,10 +66,10 @@ public struct MySQLDatabaseClient: 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: (MySQLConnection) async throws -> sending MySQLQueryResult - ) async throws(DatabaseError) -> sending MySQLQueryResult { + _ closure: (MySQLConnection) async throws -> sending T + ) async throws(DatabaseError) -> sending T { do { try await connection.execute(query: "START TRANSACTION;") diff --git a/Tests/FeatherMySQLDatabaseTests/FeatherMySQLDatabaseTestSuite.swift b/Tests/FeatherMySQLDatabaseTests/FeatherMySQLDatabaseTestSuite.swift index 27910bf..b83b1a8 100644 --- a/Tests/FeatherMySQLDatabaseTests/FeatherMySQLDatabaseTestSuite.swift +++ b/Tests/FeatherMySQLDatabaseTests/FeatherMySQLDatabaseTestSuite.swift @@ -84,7 +84,7 @@ struct MySQLDatabaseTestSuite { try await connection.close().get() try await eventLoopGroup.shutdownGracefully() - throw error + Issue.record(error) } } @@ -996,4 +996,148 @@ struct MySQLDatabaseTestSuite { } } + @Test + func concurrentTransactionUpdates() async throws { + try await runUsingTestDatabaseClient { 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` VARCHAR(255) NOT NULL PRIMARY KEY, + `access_token` TEXT NOT NULL, + `access_expires_at` TIMESTAMP NOT NULL, + `refresh_token` TEXT NOT NULL, + `refresh_count` INTEGER NOT NULL DEFAULT 0 + ); + """# + ) + + // set an expired token + try await database.execute( + query: #""" + INSERT INTO `\#(unescaped: table)` + (`id`, `access_token`, `access_expires_at`, `refresh_token`, `refresh_count`) + VALUES + ( + \#(sessionID), + 'stale', + NOW() - INTERVAL 5 MINUTE, + 'refresh', + 0 + ); + """# + ) + + func getValidAccessToken(sessionID: String) async throws -> String { + try await database.transaction { connection in + let result = try await connection.execute( + query: #""" + SELECT + `access_token`, + `refresh_count`, + `access_expires_at` > NOW() + INTERVAL 60 SECOND AS `is_valid` + FROM `\#(unescaped: table)` + WHERE `id` = \#(sessionID) + FOR UPDATE; + """# + ) + 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 + ) + if isValid { + // token was valid, must be called X times + return try row.decode( + column: "access_token", + as: String.self + ) + } + + // refresh, this branch can only be called 1 time + let refreshCount = try row.decode( + column: "refresh_count", + as: Int.self + ) + let newRefreshCount = refreshCount + 1 + let newToken = "token_\(newRefreshCount)" + + try await Task.sleep(for: .milliseconds(40)) + + _ = try await connection.execute( + query: #""" + UPDATE `\#(unescaped: table)` + SET + `access_token` = \#(newToken), + `access_expires_at` = NOW() + INTERVAL 10 MINUTE, + `refresh_count` = \#(newRefreshCount) + WHERE `id` = \#(sessionID); + """# + ) + + return newToken + } + } + + let workerCount = 80 + var tokens: [String] = [] + try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0.. 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 + ) + } + } + }