From a6f64547aebdec21cadc122b75793663c1a2be79 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Fri, 5 Jan 2024 10:54:02 +0100 Subject: [PATCH] Get HummingbirdFluent working with HB 2.0 (#11) * Get fluent code compiling * Get tests running * Update CI * Cleanup, remove history management code * HBFluentPersistDriver doesnt need a Clock * Use @MainActor for accessing migrations * Make HBFluent Sendable Wrap migrations in a MainActorBox Wrap Databases in a UnsafeTransfer * Include 2.x.x branch in CI * Add Service.run override * Fix persist tests * Update for addServices api change * Restructure tests slightly * Update readme * Change prints to debug logging --- .github/workflows/api-breakage.yml | 2 +- .github/workflows/ci.yml | 12 +- Package.swift | 14 +- README.md | 40 +- .../Application+fluent.swift | 47 --- Sources/HummingbirdFluent/Fluent.swift | 122 ++---- .../HummingbirdFluent/Persist+fluent.swift | 173 ++++----- .../HummingbirdFluent/Request+fluent.swift | 37 -- .../HummingbirdFluent/UnsafeTransfer.swift | 70 ++++ .../HummingbirdFluentTests/FluentTests.swift | 136 ++----- .../HummingbirdFluentTests/PersistTests.swift | 352 ++++++++---------- 11 files changed, 408 insertions(+), 597 deletions(-) delete mode 100644 Sources/HummingbirdFluent/Application+fluent.swift delete mode 100644 Sources/HummingbirdFluent/Request+fluent.swift create mode 100644 Sources/HummingbirdFluent/UnsafeTransfer.swift diff --git a/.github/workflows/api-breakage.yml b/.github/workflows/api-breakage.yml index f1b8f3f..b6bed85 100644 --- a/.github/workflows/api-breakage.yml +++ b/.github/workflows/api-breakage.yml @@ -8,7 +8,7 @@ jobs: linux: runs-on: ubuntu-latest container: - image: swift:5.8 + image: swift:5.9 steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dce1189..11c8ebf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,11 @@ on: push: branches: - main + - 2.x.x paths: - '**.swift' - '**.yml' pull_request: - branches: - - main paths: - '**.swift' - '**.yml' @@ -21,15 +20,14 @@ jobs: strategy: matrix: image: - - 'swift:5.6' - - 'swift:5.7' - - 'swift:5.8' + - 'swift:5.9' + - 'swiftlang/swift:nightly-5.10-jammy' container: image: ${{ matrix.image }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build run: | - swift build + swift test diff --git a/Package.swift b/Package.swift index 0b89c17..daaac1c 100644 --- a/Package.swift +++ b/Package.swift @@ -1,31 +1,33 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "hummingbird-fluent", - platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13)], + platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)], products: [ .library(name: "HummingbirdFluent", targets: ["HummingbirdFluent"]), ], dependencies: [ - .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", branch: "2.x.x"), .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.17.0"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"), // used in tests .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"), // .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"), - // .package(url: "https://github.com/vapor/fluent-mysql-driver.git", from: "4.0.0"), ], targets: [ .target(name: "HummingbirdFluent", dependencies: [ - .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "FluentKit", package: "fluent-kit"), + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), ]), .testTarget(name: "HummingbirdFluentTests", dependencies: [ .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), // .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), - // .product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"), .byName(name: "HummingbirdFluent"), .product(name: "HummingbirdFoundation", package: "hummingbird"), .product(name: "HummingbirdXCT", package: "hummingbird"), diff --git a/README.md b/README.md index de5e8cf..7467e9a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Hummingbird interface to the [Fluent](https://github.com/vapor/fluent-kit) database ORM. -Hummingbird doesn't come with any database drivers or ORM. This library provides a connection to Vapor's database ORM. The Vapor guys have been generous and forward thinking enough to ensure Fluent-kit can be used independent of Vapor. They have a small library that links Vapor to Fluent, this library does pretty much the same thing for Hummingbird. +Hummingbird doesn't come with any database drivers or ORM. This library provides a connection to Vapor's database ORM. The Vapor guys have been generous and forward thinking enough to ensure Fluent-kit can be used independent of Vapor. This package collates the fluent features into one. It also provides a driver for the Hummingbird Persist framework. ## Usage @@ -12,30 +12,38 @@ The following initializes an SQLite database and adds a single migration `Create import FluentSQLiteDriver import HummingbirdFluent -let app = HBApplication() -// add Fluent -app.addFluent() +let logger = Logger(label: "MyApp") +let fluent = HBFluent(logger: logger) // add sqlite database -app.fluent.databases.use(.sqlite(.file("db.sqlite")), as: .sqlite) -// add migrations -app.fluent.migrations.add(CreateTodo()) +fluent.databases.use(.sqlite(.file("db.sqlite")), as: .sqlite) +// add migration +await fluent.migrations.add(CreateTodo()) // migrate if arguments.migrate { - try app.fluent.migrate().wait() + try fluent.migrate().wait() } ``` -In general the interface to Fluent follows the same pattern as Vapor, except the `db` and `migrations` objects are only accessible from within the `fluent` object, and you need to call `HBApplication.addFluent()` at initialization. -Fluent can be used from a route as follows. The database is accessible via `HBRequest.db`. +Fluent can be used from a route as follows. ```swift -app.router - .endpoint("todos") - .get(":id") { request in - guard let id = request.parameters.get("id", as: UUID.self) else { return request.failure(HBHTTPError(.badRequest)) } - return Todo.find(id, on: request.db) +let router = HBRouter() +router + .group("todos") + .get(":id") { request, context in + guard let id = context.parameters.get("id", as: UUID.self) else { return request.failure(HBHTTPError(.badRequest)) } + return Todo.find(id, on: fluent.db()) } ``` -Here we are returning a `Todo` with an id specified in the path. +Here we are returning a `Todo` with an id specified in the request URI. + +You can then bring this together by creating an application that uses the router and adding fluent to its list of services + +```swift +var app = HBApplication(router: router) +// add the fluent service to the application so it can manage shutdown correctly +app.addServices(fluent) +try await app.runService() +``` You can find more documentation on Fluent [here](https://docs.vapor.codes/4.0/fluent/overview/). diff --git a/Sources/HummingbirdFluent/Application+fluent.swift b/Sources/HummingbirdFluent/Application+fluent.swift deleted file mode 100644 index dc8ede0..0000000 --- a/Sources/HummingbirdFluent/Application+fluent.swift +++ /dev/null @@ -1,47 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2021-2021 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import FluentKit -import Hummingbird -import Logging -import NIOCore - -extension HBApplication { - /// Create Fluent management object. - public func addFluent() { - self.fluent = .init(application: self) - } - - /// Get default database - public var db: Database { - self.db(nil) - } - - /// Get database with ID - /// - Parameter id: database id - /// - Returns: database - public func db(_ id: DatabaseID?) -> Database { - self.fluent.db(id, on: self.eventLoopGroup.any()) - } - - /// Fluent interface object - public var fluent: HBFluent { - get { self.extensions.get(\.fluent) } - set { - self.extensions.set(\.fluent, value: newValue) { fluent in - fluent.shutdown() - } - } - } -} diff --git a/Sources/HummingbirdFluent/Fluent.swift b/Sources/HummingbirdFluent/Fluent.swift index cfd7bc4..a26fe33 100644 --- a/Sources/HummingbirdFluent/Fluent.swift +++ b/Sources/HummingbirdFluent/Fluent.swift @@ -14,61 +14,32 @@ import FluentKit import Hummingbird +import ServiceLifecycle + +@MainActor +public struct MainActorBox: Sendable { + public let value: Value +} + +extension DatabaseID: @unchecked Sendable {} /// Manage fluent databases and migrations /// /// You can either create this separate from `HBApplication` or add it to your application /// using `HBApplication.addFluent`. -public struct HBFluent { - /// Fluent history management - public class History { - /// Is history recording enabled - public private(set) var enabled: Bool - // History of queries to Fluent - public private(set) var history: QueryHistory? - - init() { - self.enabled = false - self.history = nil - } - - /// Start recording history - public func start() { - self.enabled = true - self.history = .init() - } - - /// Stop recording history - public func stop() { - self.enabled = false - } - - /// Clear history - public func clear() { - self.history = .init() - } - } - - /// Databases attached - public let databases: Databases - /// List of migrations - public let migrations: Migrations - /// Event loop group used by migrator +public struct HBFluent: Sendable, Service { + /// Event loop group public let eventLoopGroup: EventLoopGroup /// Logger public let logger: Logger - /// Fluent history setup - public let history: History + /// List of migrations. Only accessible from the main actor + @MainActor + public var migrations: Migrations { self._migrations.value } + /// Databases attached + public var databases: Databases { self._databases.wrappedValue } - /// Initialize HBFluent - /// - Parameter application: application to get NIOThreadPool, EventLoopGroup and Logger from - init(application: HBApplication) { - self.databases = Databases(threadPool: application.threadPool, on: application.eventLoopGroup) - self.migrations = .init() - self.eventLoopGroup = application.eventLoopGroup - self.logger = application.logger - self.history = .init() - } + private let _databases: UnsafeTransfer + private let _migrations: MainActorBox /// Initialize HBFluent /// - Parameters: @@ -76,23 +47,24 @@ public struct HBFluent { /// - threadPool: NIOThreadPool used by databases /// - logger: Logger used by databases public init( - eventLoopGroup: EventLoopGroup, - threadPool: NIOThreadPool, + eventLoopGroupProvider: EventLoopGroupProvider = .singleton, + threadPool: NIOThreadPool = .singleton, logger: Logger ) { - self.databases = Databases(threadPool: threadPool, on: eventLoopGroup) - self.migrations = .init() + let eventLoopGroup = eventLoopGroupProvider.eventLoopGroup + self._databases = .init(Databases(threadPool: threadPool, on: eventLoopGroup)) + self._migrations = .init(value: .init()) self.eventLoopGroup = eventLoopGroup self.logger = logger - self.history = .init() } - /// Shutdown databases - public func shutdown() { - self.databases.shutdown() + public func run() async throws { + await GracefulShutdownWaiter().wait() + self._databases.wrappedValue.shutdown() } /// fluent migrator + @MainActor public var migrator: Migrator { Migrator( databases: self.databases, @@ -103,46 +75,34 @@ public struct HBFluent { } /// Run migration if needed - public func migrate() -> EventLoopFuture { - self.migrator.setupIfNeeded().flatMap { - self.migrator.prepareBatch() - } + @MainActor + public func migrate() async throws { + try await self.migrator.setupIfNeeded().get() + try await self.migrator.prepareBatch().get() } /// Run revert if needed - public func revert() -> EventLoopFuture { - self.migrator.setupIfNeeded().flatMap { - self.migrator.revertAllBatches() - } + @MainActor + public func revert() async throws { + try await self.migrator.setupIfNeeded().get() + try await self.migrator.revertAllBatches().get() } /// Return Database connection /// /// - Parameters: /// - id: ID of database - /// - eventLoop: Eventloop database connection is running on + /// - history: Query history storage + /// - pageSizeLimit: Set page size limit to avoid server overload /// - Returns: Database connection - public func db(_ id: DatabaseID? = nil, on eventLoop: EventLoop) -> Database { - self.databases + public func db(_ id: DatabaseID? = nil, history: QueryHistory? = nil, pageSizeLimit: Int? = nil) -> Database { + self._databases.wrappedValue .database( id, logger: self.logger, - on: eventLoop, - history: self.history.enabled ? self.history.history : nil + on: self.eventLoopGroup.any(), + history: history, + pageSizeLimit: pageSizeLimit )! } } - -/// async/await -@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) -extension HBFluent { - /// Run migration if needed - public func migrate() async throws { - try await self.migrate().get() - } - - /// Run revert if needed - public func revert() async throws { - try await self.revert().get() - } -} diff --git a/Sources/HummingbirdFluent/Persist+fluent.swift b/Sources/HummingbirdFluent/Persist+fluent.swift index 4045a83..e463c86 100644 --- a/Sources/HummingbirdFluent/Persist+fluent.swift +++ b/Sources/HummingbirdFluent/Persist+fluent.swift @@ -12,151 +12,110 @@ // //===----------------------------------------------------------------------===// +import AsyncAlgorithms import FluentKit import Foundation import Hummingbird import NIOCore +import ServiceLifecycle /// Fluent driver for persist system for storing persistent cross request key/value pairs -public class HBFluentPersistDriver: HBPersistDriver { +public final class HBFluentPersistDriver: HBPersistDriver { + let fluent: HBFluent + let databaseID: DatabaseID? + let tidyUpFrequency: Duration + /// Initialize HBFluentPersistDriver /// - Parameters: /// - fluent: Fluent setup /// - databaseID: ID of database to use - public init(fluent: HBFluent, databaseID: DatabaseID? = nil) { + /// - tidyUpFrequequency: How frequently cleanup expired database entries should occur + public init(fluent: HBFluent, databaseID: DatabaseID? = nil, tidyUpFrequency: Duration = .seconds(600)) async { self.fluent = fluent self.databaseID = databaseID - self.fluent.migrations.add(CreatePersistModel()) - self.tidyTask = fluent.eventLoopGroup.next().scheduleRepeatedTask(initialDelay: .hours(1), delay: .hours(1)) { _ in - self.tidy() - } - } - - /// shutdown driver, cancel tidy task - public func shutdown() { - self.tidyTask?.cancel() + self.tidyUpFrequency = tidyUpFrequency + await self.fluent.migrations.add(CreatePersistModel()) + self.tidy() } /// Create new key. This doesn't check for the existence of this key already so may fail if the key already exists - public func create(key: String, value: Object, expires: TimeAmount?, request: HBRequest) -> EventLoopFuture { + public func create(key: String, value: Object, expires: Duration?) async throws { + let db = self.fluent.db(self.databaseID) + let data = try JSONEncoder().encode(value) + let date = expires.map { Date.now + Double($0.components.seconds) } ?? Date.distantFuture + let model = PersistModel(id: key, data: data, expires: date) do { - let db = self.database(on: request.eventLoop) - let data = try JSONEncoder().encode(value) - let date = expires.map { Date() + Double($0.nanoseconds) / 1_000_000_000 } ?? Date.distantFuture - let model = PersistModel(id: key, data: data, expires: date) - return model.save(on: db) - .flatMapErrorThrowing { error in - // if save fails because of constraint then throw duplicate error - if let error = error as? DatabaseError, error.isConstraintFailure { - throw HBPersistError.duplicate - } - throw error - } - .map { _ in } + try await model.save(on: db) + } catch let error as DatabaseError where error.isConstraintFailure { + throw HBPersistError.duplicate } catch { - return request.eventLoop.makeFailedFuture(error) + self.fluent.logger.debug("Error: \(error)") } } /// Set value for key. - public func set(key: String, value: Object, expires: TimeAmount?, request: HBRequest) -> EventLoopFuture { + public func set(key: String, value: Object, expires: Duration?) async throws { + let db = self.fluent.db(self.databaseID) + let data = try JSONEncoder().encode(value) + let date = expires.map { Date.now + Double($0.components.seconds) } ?? Date.distantFuture + let model = PersistModel(id: key, data: data, expires: date) do { - let db = self.database(on: request.eventLoop) - let data = try JSONEncoder().encode(value) - let date = expires.map { Date() + Double($0.nanoseconds) / 1_000_000_000 } ?? Date.distantFuture - let model = PersistModel(id: key, data: data, expires: date) - return model.save(on: db) - .flatMapError { error in - // if save fails because of constraint then try to update instead - if let error = error as? DatabaseError, error.isConstraintFailure { - return PersistModel.query(on: db) - .filter(\._$id == key) - .first() - .flatMap { model in - if let model = model { - model.data = data - model.expires = date - return model.update(on: db).map { _ in } - } else { - let model = PersistModel(id: key, data: data, expires: date) - return model.save(on: db).map { _ in } - } - } - } - return request.eventLoop.makeFailedFuture(error) - } - .map { _ in } + try await model.save(on: db) + } catch let error as DatabaseError where error.isConstraintFailure { + // if save fails because of constraint then try to update instead + let model = try await PersistModel.query(on: db) + .filter(\._$id == key) + .first() + if let model = model { + model.data = data + model.expires = date + try await model.update(on: db) + } else { + let model = PersistModel(id: key, data: data, expires: date) + try await model.save(on: db) + } } catch { - return request.eventLoop.makeFailedFuture(error) + self.fluent.logger.debug("Error: \(error)") } } /// Get value for key - public func get(key: String, as object: Object.Type, request: HBRequest) -> EventLoopFuture { - let db = self.database(on: request.eventLoop) - return PersistModel.query(on: db) - .filter(\._$id == key) - .filter(\.$expires > Date()) - .first() - .flatMapThrowing { - guard let data = $0?.data else { return nil } - return try JSONDecoder().decode(object, from: data) - } - .flatMapErrorThrowing { error in - print(error) - throw error - } + public func get(key: String, as object: Object.Type) async throws -> Object? { + let db = self.fluent.db(self.databaseID) + do { + let query = try await PersistModel.query(on: db) + .filter(\._$id == key) + .filter(\.$expires > Date()) + .first() + guard let data = query?.data else { return nil } + return try JSONDecoder().decode(object, from: data) + } } /// Remove key - public func remove(key: String, request: HBRequest) -> EventLoopFuture { - let db = self.database(on: request.eventLoop) - return PersistModel.find(key, on: db) - .flatMap { model in - guard let model = model else { return request.eventLoop.makeSucceededVoidFuture() } - return model.delete(force: true, on: db) - } + public func remove(key: String) async throws { + let db = self.fluent.db(self.databaseID) + let model = try await PersistModel.find(key, on: db) + guard let model = model else { return } + return try await model.delete(force: true, on: db) } /// tidy up database by cleaning out expired keys func tidy() { - _ = PersistModel.query(on: self.database(on: self.fluent.eventLoopGroup.next())) + _ = PersistModel.query(on: self.fluent.db(self.databaseID)) .filter(\.$expires < Date()) .delete() } - - /// Get database connection on event loop - func database(on eventLoop: EventLoop) -> Database { - self.fluent.db(self.databaseID, on: eventLoop) - } - - let fluent: HBFluent - let databaseID: DatabaseID? - var tidyTask: RepeatedTask? } -/// Factory class for persist drivers -extension HBPersistDriverFactory { - /// Fluent driver for persist system - public static var fluent: HBPersistDriverFactory { - .init(create: { app in - precondition( - app.extensions.exists(\HBApplication.fluent), - "Cannot use Fluent persist driver without having setup Fluent. Please call HBApplication.addFluent()" - ) - return HBFluentPersistDriver(fluent: app.fluent, databaseID: nil) - }) - } - - /// Fluent driver for persist system using a specific database id - public static func fluent(_ datebaseID: DatabaseID?) -> HBPersistDriverFactory { - .init(create: { app in - precondition( - app.extensions.exists(\HBApplication.fluent), - "Cannot use Fluent persist driver without having setup Fluent. Please call HBApplication.addFluent()" - ) - return HBFluentPersistDriver(fluent: app.fluent, databaseID: datebaseID) - }) +/// Service protocol requirements +extension HBFluentPersistDriver { + public func run() async throws { + let timerSequence = AsyncTimerSequence(interval: self.tidyUpFrequency, clock: .suspending) + .cancelOnGracefulShutdown() + for try await _ in timerSequence { + self.tidy() + } } } @@ -194,6 +153,6 @@ struct CreatePersistModel: Migration { } func revert(on database: Database) -> EventLoopFuture { - database.schema("_persist_").delete() + database.schema("_hb_persist_").delete() } } diff --git a/Sources/HummingbirdFluent/Request+fluent.swift b/Sources/HummingbirdFluent/Request+fluent.swift deleted file mode 100644 index a1c5485..0000000 --- a/Sources/HummingbirdFluent/Request+fluent.swift +++ /dev/null @@ -1,37 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2021-2021 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import FluentKit -import Hummingbird - -extension HBRequest { - /// Get default database - public var db: Database { - self.db(nil) - } - - /// Get database with ID - /// - Parameter id: database id - /// - Returns: database - public func db(_ id: DatabaseID?) -> Database { - self.application.fluent.db(id, on: self.eventLoop) - } - - /// Object to attach fluent related structures (currently unused) - public struct Fluent { - let request: HBRequest - } - - public var fluent: Fluent { return .init(request: self) } -} diff --git a/Sources/HummingbirdFluent/UnsafeTransfer.swift b/Sources/HummingbirdFluent/UnsafeTransfer.swift new file mode 100644 index 0000000..eae1295 --- /dev/null +++ b/Sources/HummingbirdFluent/UnsafeTransfer.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Hummingbird server framework project +// +// Copyright (c) 2021-2023 the Hummingbird authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2021-2022 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@available(*, deprecated, renamed: "Sendable") +public typealias NIOSendable = Swift.Sendable + +@preconcurrency public protocol _NIOPreconcurrencySendable: Sendable {} + +@available(*, deprecated, message: "use @preconcurrency and Sendable directly") +public typealias NIOPreconcurrencySendable = _NIOPreconcurrencySendable + +/// ``UnsafeTransfer`` can be used to make non-`Sendable` values `Sendable`. +/// As the name implies, the usage of this is unsafe because it disables the sendable checking of the compiler. +/// It can be used similar to `@unsafe Sendable` but for values instead of types. +@usableFromInline +struct UnsafeTransfer { + @usableFromInline + var wrappedValue: Wrapped + + @inlinable + init(_ wrappedValue: Wrapped) { + self.wrappedValue = wrappedValue + } +} + +extension UnsafeTransfer: @unchecked Sendable {} + +extension UnsafeTransfer: Equatable where Wrapped: Equatable {} +extension UnsafeTransfer: Hashable where Wrapped: Hashable {} + +/// ``UnsafeMutableTransferBox`` can be used to make non-`Sendable` values `Sendable` and mutable. +/// It can be used to capture local mutable values in a `@Sendable` closure and mutate them from within the closure. +/// As the name implies, the usage of this is unsafe because it disables the sendable checking of the compiler and does not add any synchronisation. +@usableFromInline +final class UnsafeMutableTransferBox { + @usableFromInline + var wrappedValue: Wrapped + + @inlinable + init(_ wrappedValue: Wrapped) { + self.wrappedValue = wrappedValue + } +} + +extension UnsafeMutableTransferBox: @unchecked Sendable {} diff --git a/Tests/HummingbirdFluentTests/FluentTests.swift b/Tests/HummingbirdFluentTests/FluentTests.swift index b9e9ec6..b21c543 100644 --- a/Tests/HummingbirdFluentTests/FluentTests.swift +++ b/Tests/HummingbirdFluentTests/FluentTests.swift @@ -20,6 +20,7 @@ import Hummingbird import HummingbirdFluent import HummingbirdFoundation import HummingbirdXCT +import Logging import XCTest @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) @@ -61,123 +62,58 @@ final class FluentTests: XCTestCase { } } - func createApplication() throws -> HBApplication { - let app = HBApplication(testing: .live) - app.decoder = JSONDecoder() - app.encoder = JSONEncoder() - // add Fluent - app.addFluent() - // add sqlite database - app.fluent.databases.use(.sqlite(.memory), as: .sqlite) - // app.fluent.databases.use(.postgres(hostname: "localhost", username: "postgres", password: "vapor", database: "vapor"), as: .psql) - /* app.fluent.databases.use(.mysql( - hostname: "localhost", - username: "root", - password: "vapor", - database: "vapor", - tlsConfiguration: .forClient(certificateVerification: .none) - ), as: .mysql) */ - // add migration - app.fluent.migrations.add(CreatePlanet()) - // run migrations - try app.fluent.migrate().wait() - - return app - } - struct CreateResponse: HBResponseCodable { let id: UUID } func testPutGet() async throws { - let app = try createApplication() - app.router.put("planet") { request in - let planet = try request.decode(as: Planet.self) - try await planet.create(on: request.db) - return CreateResponse(id: planet.id!) - } - app.router.get("planet/:id") { request in - let id = try request.parameters.require("id", as: UUID.self) - return try await Planet.query(on: request.db) - .filter(\.$id == id) - .first() - } - - try app.XCTStart() - defer { app.XCTStop() } - - let planet = Planet(name: "Saturn") - let id = try app.XCTExecute( - uri: "/planet", - method: .PUT, - body: JSONEncoder().encodeAsByteBuffer(planet, allocator: ByteBufferAllocator()) - ) { response in - let buffer = try XCTUnwrap(response.body) - let createResponse = try JSONDecoder().decode(CreateResponse.self, from: buffer) - return createResponse.id - } - - let planet2 = try app.XCTExecute( - uri: "/planet/\(id.uuidString)", - method: .GET - ) { response in - let buffer = try XCTUnwrap(response.body) - return try JSONDecoder().decode(Planet.self, from: buffer) - } - XCTAssertEqual(planet2.name, "Saturn") - } - - func testPutGetOutsidApplication() async throws { - let app = HBApplication(testing: .live) - app.decoder = JSONDecoder() - app.encoder = JSONEncoder() - + let logger = Logger(label: "FluentTests") let fluent = HBFluent( - eventLoopGroup: app.eventLoopGroup, threadPool: app.threadPool, logger: app.logger + logger: logger ) - // add sqlite database fluent.databases.use(.sqlite(.memory), as: .sqlite) + // fluent.databases.use(.postgres(hostname: "localhost", username: "postgres", password: "vapor", database: "vapor"), as: .psql) // add migration - fluent.migrations.add(CreatePlanet()) + await fluent.migrations.add(CreatePlanet()) // run migrations try await fluent.migrate() - app.router.put("planet") { request in - let planet = try request.decode(as: Planet.self) - try await planet.create(on: fluent.db(on: request.eventLoop)) + + let router = HBRouter() + router.middlewares.add(HBSetCodableMiddleware(decoder: JSONDecoder(), encoder: JSONEncoder())) + router.put("planet") { request, context in + let planet = try await request.decode(as: Planet.self, using: context) + try await planet.create(on: fluent.db()) return CreateResponse(id: planet.id!) } - app.router.get("planet/:id") { request in - let id = try request.parameters.require("id", as: UUID.self) - return try await Planet.query(on: fluent.db(on: request.eventLoop)) + router.get("planet/:id") { _, context in + let id = try context.parameters.require("id", as: UUID.self) + return try await Planet.query(on: fluent.db()) .filter(\.$id == id) .first() } - - try app.XCTStart() - defer { - fluent.shutdown() - app.XCTStop() - } - - let planet = Planet(name: "Saturn") - let id = try app.XCTExecute( - uri: "/planet", - method: .PUT, - body: JSONEncoder().encodeAsByteBuffer(planet, allocator: ByteBufferAllocator()) - ) { response in - let buffer = try XCTUnwrap(response.body) - let createResponse = try JSONDecoder().decode(CreateResponse.self, from: buffer) - return createResponse.id - } - - let planet2 = try app.XCTExecute( - uri: "/planet/\(id.uuidString)", - method: .GET - ) { response in - let buffer = try XCTUnwrap(response.body) - return try JSONDecoder().decode(Planet.self, from: buffer) + var app = HBApplication(responder: router.buildResponder()) + app.addServices(fluent) + try await app.test(.live) { client in + let planet = Planet(name: "Saturn") + let id = try await client.XCTExecute( + uri: "/planet", + method: .put, + body: JSONEncoder().encodeAsByteBuffer(planet, allocator: ByteBufferAllocator()) + ) { response in + let buffer = try XCTUnwrap(response.body) + let createResponse = try JSONDecoder().decode(CreateResponse.self, from: buffer) + return createResponse.id + } + + let planet2 = try await client.XCTExecute( + uri: "/planet/\(id.uuidString)", + method: .get + ) { response in + let buffer = try XCTUnwrap(response.body) + return try JSONDecoder().decode(Planet.self, from: buffer) + } + XCTAssertEqual(planet2.name, "Saturn") } - XCTAssertEqual(planet2.name, "Saturn") } } diff --git a/Tests/HummingbirdFluentTests/PersistTests.swift b/Tests/HummingbirdFluentTests/PersistTests.swift index d0b07ee..3425b8e 100644 --- a/Tests/HummingbirdFluentTests/PersistTests.swift +++ b/Tests/HummingbirdFluentTests/PersistTests.swift @@ -20,235 +20,197 @@ import HummingbirdFluent import XCTest final class PersistTests: XCTestCase { - func createApplication() throws -> HBApplication { - let app = HBApplication(testing: .live) - // add Fluent - app.addFluent() + func createApplication(_ updateRouter: (HBRouter, HBPersistDriver) -> Void = { _, _ in }) async throws -> some HBApplicationProtocol { + var logger = Logger(label: "FluentTests") + logger.logLevel = .trace + let fluent = HBFluent(logger: logger) // add sqlite database - app.fluent.databases.use(.sqlite(.memory), as: .sqlite) - // app.fluent.databases.use(.postgres(hostname: "localhost", username: "postgres", password: "vapor", database: "vapor"), as: .psql) - /* app.fluent.databases.use(.mysql( - hostname: "localhost", - username: "root", - password: "vapor", - database: "vapor", - tlsConfiguration: .forClient(certificateVerification: .none) - ), as: .mysql) */ - // add persist - app.addPersist(using: .fluent) + fluent.databases.use(.sqlite(.memory), as: .sqlite) + // fluent.databases.use(.postgres(hostname: "localhost", username: "postgres", password: "vapor", database: "vapor"), as: .psql) + let persist = await HBFluentPersistDriver(fluent: fluent) // run migrations - try app.fluent.migrate().wait() - - app.router.put("/persist/:tag") { request -> EventLoopFuture in - guard let tag = request.parameters.get("tag") else { return request.failure(.badRequest) } - guard let buffer = request.body.buffer else { return request.failure(.badRequest) } - return request.persist.set(key: tag, value: String(buffer: buffer)) - .map { _ in .ok } - } - app.router.put("/persist/:tag/:time") { request -> EventLoopFuture in - guard let time = request.parameters.get("time", as: Int.self) else { return request.failure(.badRequest) } - guard let tag = request.parameters.get("tag") else { return request.failure(.badRequest) } - guard let buffer = request.body.buffer else { return request.failure(.badRequest) } - return request.persist.set(key: tag, value: String(buffer: buffer), expires: .seconds(numericCast(time))) - .map { _ in .ok } - } - app.router.get("/persist/:tag") { request -> EventLoopFuture in - guard let tag = request.parameters.get("tag", as: String.self) else { return request.failure(.badRequest) } - return request.persist.get(key: tag, as: String.self) - } - app.router.delete("/persist/:tag") { request -> EventLoopFuture in - guard let tag = request.parameters.get("tag", as: String.self) else { return request.failure(.badRequest) } - return request.persist.remove(key: tag) - .map { _ in .noContent } + try await fluent.migrate() + + let router = HBRouter() + + router.put("/persist/:tag") { request, context -> HTTPResponse.Status in + let buffer = try await request.body.collect(upTo: .max) + let tag = try context.parameters.require("tag") + try await persist.set(key: tag, value: String(buffer: buffer)) + return .ok + } + router.put("/persist/:tag/:time") { request, context -> HTTPResponse.Status in + guard let time = context.parameters.get("time", as: Int.self) else { throw HBHTTPError(.badRequest) } + let buffer = try await request.body.collect(upTo: .max) + let tag = try context.parameters.require("tag") + try await persist.set(key: tag, value: String(buffer: buffer), expires: .seconds(time)) + return .ok } + router.get("/persist/:tag") { _, context -> String? in + guard let tag = context.parameters.get("tag", as: String.self) else { throw HBHTTPError(.badRequest) } + return try await persist.get(key: tag, as: String.self) + } + router.delete("/persist/:tag") { _, context -> HTTPResponse.Status in + guard let tag = context.parameters.get("tag", as: String.self) else { throw HBHTTPError(.badRequest) } + try await persist.remove(key: tag) + return .noContent + } + updateRouter(router, persist) + var app = HBApplication(responder: router.buildResponder()) + app.addServices(fluent, persist) + return app } - func testSetGet() throws { - let app = try createApplication() - try app.XCTStart() - defer { app.XCTStop() } - let tag = UUID().uuidString - try app.XCTExecute(uri: "/persist/\(tag)", method: .PUT, body: ByteBufferAllocator().buffer(string: "Persist")) { _ in } - try app.XCTExecute(uri: "/persist/\(tag)", method: .GET) { response in - let body = try XCTUnwrap(response.body) - XCTAssertEqual(String(buffer: body), "Persist") + func testSetGet() async throws { + let app = try await self.createApplication() + try await app.test(.live) { client in + let tag = UUID().uuidString + try await client.XCTExecute(uri: "/persist/\(tag)", method: .put, body: ByteBufferAllocator().buffer(string: "Persist")) { _ in } + try await client.XCTExecute(uri: "/persist/\(tag)", method: .get) { response in + let body = try XCTUnwrap(response.body) + XCTAssertEqual(String(buffer: body), "Persist") + } } } - func testCreateGet() throws { - let app = try createApplication() - app.router.put("/create/:tag") { request -> EventLoopFuture in - guard let tag = request.parameters.get("tag") else { return request.failure(.badRequest) } - guard let buffer = request.body.buffer else { return request.failure(.badRequest) } - return request.persist.create(key: tag, value: String(buffer: buffer)) - .map { _ in .ok } - } - try app.XCTStart() - defer { app.XCTStop() } - let tag = UUID().uuidString - try app.XCTExecute(uri: "/create/\(tag)", method: .PUT, body: ByteBufferAllocator().buffer(string: "Persist")) { _ in } - try app.XCTExecute(uri: "/persist/\(tag)", method: .GET) { response in - let body = try XCTUnwrap(response.body) - XCTAssertEqual(String(buffer: body), "Persist") + func testCreateGet() async throws { + let app = try await self.createApplication { router, persist in + router.put("/create/:tag") { request, context -> HTTPResponse.Status in + let buffer = try await request.body.collect(upTo: .max) + let tag = try context.parameters.require("tag") + try await persist.create(key: tag, value: String(buffer: buffer)) + return .ok + } + } + try await app.test(.live) { client in + let tag = UUID().uuidString + try await client.XCTExecute(uri: "/create/\(tag)", method: .put, body: ByteBufferAllocator().buffer(string: "Persist")) { _ in } + try await client.XCTExecute(uri: "/persist/\(tag)", method: .get) { response in + let body = try XCTUnwrap(response.body) + XCTAssertEqual(String(buffer: body), "Persist") + } } } - func testDoubleCreateFail() throws { - let app = try createApplication() - app.router.put("/create/:tag") { request -> EventLoopFuture in - guard let tag = request.parameters.get("tag") else { return request.failure(.badRequest) } - guard let buffer = request.body.buffer else { return request.failure(.badRequest) } - return request.persist.create(key: tag, value: String(buffer: buffer)) - .flatMapErrorThrowing { error in - if let error = error as? HBPersistError, error == .duplicate { throw HBHTTPError(.conflict) } - throw error + func testDoubleCreateFail() async throws { + let app = try await self.createApplication { router, persist in + router.put("/create/:tag") { request, context -> HTTPResponse.Status in + let buffer = try await request.body.collect(upTo: .max) + let tag = try context.parameters.require("tag") + do { + try await persist.create(key: tag, value: String(buffer: buffer)) + } catch let error as HBPersistError where error == .duplicate { + throw HBHTTPError(.conflict) } - .map { _ in .ok } - } - try app.XCTStart() - defer { app.XCTStop() } - let tag = UUID().uuidString - try app.XCTExecute(uri: "/create/\(tag)", method: .PUT, body: ByteBufferAllocator().buffer(string: "Persist")) { response in - XCTAssertEqual(response.status, .ok) - } - try app.XCTExecute(uri: "/create/\(tag)", method: .PUT, body: ByteBufferAllocator().buffer(string: "Persist")) { response in - XCTAssertEqual(response.status, .conflict) + return .ok + } + } + try await app.test(.live) { client in + let tag = UUID().uuidString + try await client.XCTExecute(uri: "/create/\(tag)", method: .put, body: ByteBufferAllocator().buffer(string: "Persist")) { response in + XCTAssertEqual(response.status, .ok) + } + try await client.XCTExecute(uri: "/create/\(tag)", method: .put, body: ByteBufferAllocator().buffer(string: "Persist")) { response in + XCTAssertEqual(response.status, .conflict) + } } } - func testSetTwice() throws { - let app = try createApplication() - try app.XCTStart() - defer { app.XCTStop() } + func testSetTwice() async throws { + let app = try await self.createApplication() + try await app.test(.live) { client in - let tag = UUID().uuidString - try app.XCTExecute(uri: "/persist/\(tag)", method: .PUT, body: ByteBufferAllocator().buffer(string: "test1")) { _ in } - try app.XCTExecute(uri: "/persist/\(tag)", method: .PUT, body: ByteBufferAllocator().buffer(string: "test2")) { response in - XCTAssertEqual(response.status, .ok) - } - try app.XCTExecute(uri: "/persist/\(tag)", method: .GET) { response in - let body = try XCTUnwrap(response.body) - XCTAssertEqual(String(buffer: body), "test2") + let tag = UUID().uuidString + try await client.XCTExecute(uri: "/persist/\(tag)", method: .put, body: ByteBufferAllocator().buffer(string: "test1")) { _ in } + try await client.XCTExecute(uri: "/persist/\(tag)", method: .put, body: ByteBufferAllocator().buffer(string: "test2")) { response in + XCTAssertEqual(response.status, .ok) + } + try await client.XCTExecute(uri: "/persist/\(tag)", method: .get) { response in + let body = try XCTUnwrap(response.body) + XCTAssertEqual(String(buffer: body), "test2") + } } } - func testExpires() throws { - let app = try createApplication() - try app.XCTStart() - defer { app.XCTStop() } + func testExpires() async throws { + let app = try await self.createApplication() + try await app.test(.live) { client in - let tag1 = UUID().uuidString - let tag2 = UUID().uuidString + let tag1 = UUID().uuidString + let tag2 = UUID().uuidString - try app.XCTExecute(uri: "/persist/\(tag1)/0", method: .PUT, body: ByteBufferAllocator().buffer(string: "ThisIsTest1")) { _ in } - try app.XCTExecute(uri: "/persist/\(tag2)/100", method: .PUT, body: ByteBufferAllocator().buffer(string: "ThisIsTest2")) { _ in } - Thread.sleep(forTimeInterval: 1) - try app.XCTExecute(uri: "/persist/\(tag1)", method: .GET) { response in - XCTAssertEqual(response.status, .noContent) - } - try app.XCTExecute(uri: "/persist/\(tag2)", method: .GET) { response in - let body = try XCTUnwrap(response.body) - XCTAssertEqual(String(buffer: body), "ThisIsTest2") + try await client.XCTExecute(uri: "/persist/\(tag1)/0", method: .put, body: ByteBufferAllocator().buffer(string: "ThisIsTest1")) { _ in } + try await client.XCTExecute(uri: "/persist/\(tag2)/10", method: .put, body: ByteBufferAllocator().buffer(string: "ThisIsTest2")) { _ in } + try await Task.sleep(nanoseconds: 1_000_000_000) + try await client.XCTExecute(uri: "/persist/\(tag1)", method: .get) { response in + XCTAssertEqual(response.status, .noContent) + } + try await client.XCTExecute(uri: "/persist/\(tag2)", method: .get) { response in + let body = try XCTUnwrap(response.body) + XCTAssertEqual(String(buffer: body), "ThisIsTest2") + } } } - func testCodable() throws { + func testCodable() async throws { struct TestCodable: Codable { let buffer: String } - let app = try createApplication() - - app.router.put("/codable/:tag") { request -> EventLoopFuture in - guard let tag = request.parameters.get("tag") else { return request.failure(.badRequest) } - guard let buffer = request.body.buffer else { return request.failure(.badRequest) } - return request.persist.set(key: tag, value: TestCodable(buffer: String(buffer: buffer))) - .map { _ in .ok } - } - app.router.get("/codable/:tag") { request -> EventLoopFuture in - guard let tag = request.parameters.get("tag") else { return request.failure(.badRequest) } - return request.persist.get(key: tag, as: TestCodable.self).map { $0.map(\.buffer) } - } - try app.XCTStart() - defer { app.XCTStop() } - - let tag = UUID().uuidString - try app.XCTExecute(uri: "/codable/\(tag)", method: .PUT, body: ByteBufferAllocator().buffer(string: "Persist")) { _ in } - try app.XCTExecute(uri: "/codable/\(tag)", method: .GET) { response in - let body = try XCTUnwrap(response.body) - XCTAssertEqual(String(buffer: body), "Persist") + let app = try await self.createApplication { router, persist in + router.put("/codable/:tag") { request, context -> HTTPResponse.Status in + guard let tag = context.parameters.get("tag") else { throw HBHTTPError(.badRequest) } + let buffer = try await request.body.collect(upTo: .max) + try await persist.set(key: tag, value: TestCodable(buffer: String(buffer: buffer))) + return .ok + } + router.get("/codable/:tag") { _, context -> String? in + guard let tag = context.parameters.get("tag") else { throw HBHTTPError(.badRequest) } + let value = try await persist.get(key: tag, as: TestCodable.self) + return value?.buffer + } + } + try await app.test(.live) { client in + + let tag = UUID().uuidString + try await client.XCTExecute(uri: "/codable/\(tag)", method: .put, body: ByteBufferAllocator().buffer(string: "Persist")) { _ in } + try await client.XCTExecute(uri: "/codable/\(tag)", method: .get) { response in + let body = try XCTUnwrap(response.body) + XCTAssertEqual(String(buffer: body), "Persist") + } } } - func testRemove() throws { - let app = try createApplication() - try app.XCTStart() - defer { app.XCTStop() } - - let tag = UUID().uuidString - try app.XCTExecute(uri: "/persist/\(tag)", method: .PUT, body: ByteBufferAllocator().buffer(string: "ThisIsTest1")) { _ in } - try app.XCTExecute(uri: "/persist/\(tag)", method: .DELETE) { _ in } - try app.XCTExecute(uri: "/persist/\(tag)", method: .GET) { response in - XCTAssertEqual(response.status, .noContent) + func testRemove() async throws { + let app = try await self.createApplication() + try await app.test(.live) { client in + let tag = UUID().uuidString + try await client.XCTExecute(uri: "/persist/\(tag)", method: .put, body: ByteBufferAllocator().buffer(string: "ThisIsTest1")) { _ in } + try await client.XCTExecute(uri: "/persist/\(tag)", method: .delete) { _ in } + try await client.XCTExecute(uri: "/persist/\(tag)", method: .get) { response in + XCTAssertEqual(response.status, .noContent) + } } } - func testExpireAndAdd() throws { - let app = try createApplication() - try app.XCTStart() - defer { app.XCTStop() } - - let tag = UUID().uuidString - try app.XCTExecute(uri: "/persist/\(tag)/0", method: .PUT, body: ByteBufferAllocator().buffer(string: "ThisIsTest1")) { _ in } - Thread.sleep(forTimeInterval: 1) - try app.XCTExecute(uri: "/persist/\(tag)", method: .GET) { response in - XCTAssertEqual(response.status, .noContent) - } - try app.XCTExecute(uri: "/persist/\(tag)/10", method: .PUT, body: ByteBufferAllocator().buffer(string: "ThisIsTest2")) { response in - XCTAssertEqual(response.status, .ok) - } - try app.XCTExecute(uri: "/persist/\(tag)", method: .GET) { response in - XCTAssertEqual(response.status, .ok) - let body = try XCTUnwrap(response.body) - XCTAssertEqual(String(buffer: body), "ThisIsTest2") - } - } - - func testSetGetOutsideApp() throws { - let app = HBApplication(testing: .live) - // add Fluent - let fluent = HBFluent( - eventLoopGroup: app.eventLoopGroup, threadPool: app.threadPool, logger: app.logger - ) - - // add sqlite database - fluent.databases.use(.sqlite(.memory), as: .sqlite) - // setup persist - let persist = HBFluentPersistDriver(fluent: fluent) - // run migrations - try fluent.migrate().wait() - - app.router.put("/persist/:tag") { request -> EventLoopFuture in - guard let tag = request.parameters.get("tag") else { return request.failure(.badRequest) } - guard let buffer = request.body.buffer else { return request.failure(.badRequest) } - return persist.set(key: tag, value: String(buffer: buffer), expires: nil, request: request) - .map { _ in .ok } - } - app.router.get("/persist/:tag") { request -> EventLoopFuture in - guard let tag = request.parameters.get("tag", as: String.self) else { return request.failure(.badRequest) } - return persist.get(key: tag, as: String.self, request: request) - } - try app.XCTStart() - defer { - persist.shutdown() - fluent.shutdown() - app.XCTStop() - } - let tag = UUID().uuidString - try app.XCTExecute(uri: "/persist/\(tag)", method: .PUT, body: ByteBufferAllocator().buffer(string: "Persist")) { _ in } - try app.XCTExecute(uri: "/persist/\(tag)", method: .GET) { response in - let body = try XCTUnwrap(response.body) - XCTAssertEqual(String(buffer: body), "Persist") + func testExpireAndAdd() async throws { + let app = try await self.createApplication() + try await app.test(.live) { client in + + let tag = UUID().uuidString + try await client.XCTExecute(uri: "/persist/\(tag)/0", method: .put, body: ByteBufferAllocator().buffer(string: "ThisIsTest1")) { _ in } + try await Task.sleep(nanoseconds: 1_000_000_000) + try await client.XCTExecute(uri: "/persist/\(tag)", method: .get) { response in + XCTAssertEqual(response.status, .noContent) + } + try await client.XCTExecute(uri: "/persist/\(tag)/10", method: .put, body: ByteBufferAllocator().buffer(string: "ThisIsTest1")) { response in + XCTAssertEqual(response.status, .ok) + } + try await client.XCTExecute(uri: "/persist/\(tag)", method: .get) { response in + XCTAssertEqual(response.status, .ok) + let body = try XCTUnwrap(response.body) + XCTAssertEqual(String(buffer: body), "ThisIsTest1") + } } } }