From 8de70a269a1f976d4ec24a7910e8213fa25c9cb0 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 17 Dec 2023 12:18:58 -0800 Subject: [PATCH 01/55] Add example executable Update Logger --- Alchemy/Environment/Environment.swift | 5 +- Alchemy/HTTP/Application+HTTPUpgrades.swift | 4 +- Alchemy/HTTP/Commands/ServeCommand.swift | 104 +++++++++++++++++--- Alchemy/Logging/Logger+Utilities.swift | 7 +- Example/App.swift | 14 +++ Package.swift | 8 ++ 6 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 Example/App.swift diff --git a/Alchemy/Environment/Environment.swift b/Alchemy/Environment/Environment.swift index 08e9c849..f6f3b414 100644 --- a/Alchemy/Environment/Environment.swift +++ b/Alchemy/Environment/Environment.swift @@ -157,7 +157,10 @@ public final class Environment: ExpressibleByStringLiteral { } public static var isXcode: Bool { - CommandLine.arguments.contains { $0.contains("/Xcode/DerivedData") || $0.contains("/Xcode/Agents") } + CommandLine.arguments.contains { + $0.contains("/Xcode/DerivedData") || + $0.contains("/Xcode/Agents") + } } public static func createDefault() -> Environment { diff --git a/Alchemy/HTTP/Application+HTTPUpgrades.swift b/Alchemy/HTTP/Application+HTTPUpgrades.swift index 4986fdc7..82a25064 100644 --- a/Alchemy/HTTP/Application+HTTPUpgrades.swift +++ b/Alchemy/HTTP/Application+HTTPUpgrades.swift @@ -20,8 +20,8 @@ extension Application { /// Use HTTP/2 when serving, over TLS with the given tls config. /// /// - Parameter tlsConfig: A raw NIO `TLSConfiguration` to use. - public func useHTTP2(tlsConfig: TLSConfiguration) throws { - try server.addHTTP2Upgrade(tlsConfiguration: tlsConfig) + public func useHTTP2(tlsConfig: TLSConfiguration, idleReadTimeout: TimeAmount = .seconds(20)) throws { + try server.addHTTP2Upgrade(tlsConfiguration: tlsConfig, idleReadTimeout: idleReadTimeout) } // MARK: HTTPS diff --git a/Alchemy/HTTP/Commands/ServeCommand.swift b/Alchemy/HTTP/Commands/ServeCommand.swift index 53279795..8288bd62 100644 --- a/Alchemy/HTTP/Commands/ServeCommand.swift +++ b/Alchemy/HTTP/Commands/ServeCommand.swift @@ -75,9 +75,10 @@ struct ServeCommand: Command { Log.info("Server running on \(link).") } - let stop = Env.isXcode ? "Cmd+Period" : "Ctrl+C" - Log.comment("Press \(stop) to stop the server".yellow) - if !Env.isXcode { + if Env.isXcode { + Log.comment("Press Cmd+Period to stop the server") + } else { + Log.comment("Press Ctrl+C to stop the server".yellow) print() } } @@ -120,10 +121,17 @@ private struct HTTPResponder: HBHTTPResponder { }() static let time: DateFormatter = { let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" + formatter.dateFormat = "HH:mm:ss" return formatter }() } + + enum Status { + case success + case warning + case error + case other + } let finishedAt = Date() let dateString = Formatters.date.string(from: finishedAt) @@ -131,20 +139,44 @@ private struct HTTPResponder: HBHTTPResponder { let left = "\(dateString) \(timeString) \(req.path)" let right = "\(startedAt.elapsedString) \(res.status.code)" let dots = Log.dots(left: left, right: right) - let code: String = { + let status: Status = { switch res.status.code { case 200...299: - return "\(res.status.code)".green + return .success case 400...499: - return "\(res.status.code)".yellow + return .warning case 500...599: - return "\(res.status.code)".red + return .error default: - return "\(res.status.code)".white + return .other } }() - - Log.comment("\(dateString.lightBlack) \(timeString) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(code)") + + if Env.isXcode { + let logString = "\(dateString.lightBlack) \(timeString) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(res.status.code)" + switch status { + case .success, .other: + Log.comment(logString) + case .warning: + Log.warning(logString) + case .error: + Log.critical(logString) + } + } else { + var code = "\(res.status.code)" + switch status { + case .success: + code = code.green + case .warning: + code = code.yellow + case .error: + code = code.red + case .other: + code = code.white + } + + Log.comment("\(dateString.lightBlack) \(timeString) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(code)") + } } } @@ -192,3 +224,53 @@ extension Bytes? { } } } + +extension String { + /// String with black text. + public var black: String { Env.isXcode ? self : applyingColor(.black) } + /// String with red text. + public var red: String { Env.isXcode ? self : applyingColor(.red) } + /// String with green text. + public var green: String { Env.isXcode ? self : applyingColor(.green) } + /// String with yellow text. + public var yellow: String { Env.isXcode ? self : applyingColor(.yellow) } + /// String with blue text. + public var blue: String { Env.isXcode ? self : applyingColor(.blue) } + /// String with magenta text. + public var magenta: String { Env.isXcode ? self : applyingColor(.magenta) } + /// String with cyan text. + public var cyan: String { Env.isXcode ? self : applyingColor(.cyan) } + /// String with white text. + public var white: String { Env.isXcode ? self : applyingColor(.white) } + /// String with light black text. Generally speaking, it means dark grey in some consoles. + public var lightBlack: String { Env.isXcode ? self : applyingColor(.lightBlack) } + /// String with light red text. + public var lightRed: String { Env.isXcode ? self : applyingColor(.lightRed) } + /// String with light green text. + public var lightGreen: String { Env.isXcode ? self : applyingColor(.lightGreen) } + /// String with light yellow text. + public var lightYellow: String { Env.isXcode ? self : applyingColor(.lightYellow) } + /// String with light blue text. + public var lightBlue: String { Env.isXcode ? self : applyingColor(.lightBlue) } + /// String with light magenta text. + public var lightMagenta: String { Env.isXcode ? self : applyingColor(.lightMagenta) } + /// String with light cyan text. + public var lightCyan: String { Env.isXcode ? self : applyingColor(.lightCyan) } + /// String with light white text. Generally speaking, it means light grey in some consoles. + public var lightWhite: String { Env.isXcode ? self : applyingColor(.lightWhite) } +} + +extension String { + /// String with bold style. + public var bold: String { Env.isXcode ? self : applyingStyle(.bold) } + /// String with dim style. This is not widely supported in all terminals. Use it carefully. + public var dim: String { Env.isXcode ? self : applyingStyle(.dim) } + /// String with italic style. This depends on whether an italic existing for the font family of terminals. + public var italic: String { Env.isXcode ? self : applyingStyle(.italic) } + /// String with underline style. + public var underline: String { Env.isXcode ? self : applyingStyle(.underline) } + /// String with blink style. This is not widely supported in all terminals, or need additional setting. Use it carefully. + public var blink: String { Env.isXcode ? self : applyingStyle(.blink) } + /// String with text color and background color swapped. + public var swap: String { Env.isXcode ? self : applyingStyle(.swap) } +} diff --git a/Alchemy/Logging/Logger+Utilities.swift b/Alchemy/Logging/Logger+Utilities.swift index 5a558a24..6935fdf9 100644 --- a/Alchemy/Logging/Logger+Utilities.swift +++ b/Alchemy/Logging/Logger+Utilities.swift @@ -98,8 +98,11 @@ extension Logger: Service { /// local dev only. func comment(_ message: String) { if !Env.isTesting && Env.isDebug { - let padding = Env.isXcode ? "" : " " - print("\(padding)\(message)") + if Env.isXcode { + Log.info("\(message)") + } else { + print(" \(message)") + } } } diff --git a/Example/App.swift b/Example/App.swift new file mode 100644 index 00000000..9338e04a --- /dev/null +++ b/Example/App.swift @@ -0,0 +1,14 @@ +import Alchemy + +@main +struct App: Application { + func boot() throws { + get("/200", use: get200) + get("/400", use: get400) + get("/500", use: get500) + } + + func get200(req: Request) {} + func get400(req: Request) throws { throw HTTPError(.badRequest) } + func get500(req: Request) throws { throw HTTPError(.internalServerError) } +} diff --git a/Package.swift b/Package.swift index e166070d..e31d8679 100644 --- a/Package.swift +++ b/Package.swift @@ -7,6 +7,7 @@ let package = Package( .macOS(.v13), ], products: [ + .executable(name: "Example", targets: ["Example"]), .library(name: "Alchemy", targets: ["Alchemy"]), .library(name: "AlchemyTest", targets: ["AlchemyTest"]), ], @@ -29,6 +30,13 @@ let package = Package( .package(url: "https://github.com/vadymmarkov/Fakery", from: "5.0.0"), ], targets: [ + .executableTarget( + name: "Example", + dependencies: [ + .byName(name: "Alchemy"), + ], + path: "Example" + ), .target( name: "Alchemy", dependencies: [ From 9b4a64944058e8e3947e95945433be3d23612806 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 29 Dec 2023 10:16:23 -0500 Subject: [PATCH 02/55] WIP --- Example/App.swift | 114 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/Example/App.swift b/Example/App.swift index 9338e04a..f456ed93 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,5 +1,44 @@ import Alchemy +/* + + GOAL: use macros to cut down on boilerplate + + Crush: + 0. dependencies / configuration + 1. routing + 2. middleware + 3. validation + 4. SQL access + 5. background work + 6. http + 7. testing + 8. logging / debugging + + Reduce + 1. number of types + + Repetitive Tasks + - validate type and content + - match database models + - fire off jobs + - multiple types verifying and mapping + + Foundational Tasks + - map Request to function input + - map output to Response + - turn request fields into work based on function parameters + + Macros + - CON: Need a top level macro to parse the body. + - CON: Hard to gracefuly override + - PRO: isolated + - PRO: Composable + - PRO: Easy overloads / hint what you want + Closures + - CON: can overload, but a lot of work + */ + @main struct App: Application { func boot() throws { @@ -12,3 +51,78 @@ struct App: Application { func get400(req: Request) throws { throw HTTPError(.badRequest) } func get500(req: Request) throws { throw HTTPError(.internalServerError) } } + +@Routes +struct UserController { + var middlewares: [Middleware] = [ + AuthMiddleware(), + RateLimitMiddleware(), + RequireUserMiddleware(), + SanitizingMiddleware(), + ] + + @GET("/users/:id") + func getUser(user: User) async throws -> Fields { + // fire off background work + // access type from middleware + // perform some arbitrary validation + // hit 3rd party endpoint + throw HTTPError(.notImplemented) + } + + @POST("/users") + func createUser(username: String, password: String) async throws -> Fields { + User(username: username, password: password) + .insertReturn() + .without(\.password) + } + + @PATCH("/users/:id") + func updateUser(user: Int, name: String) async throws -> User { + throw HTTPError(.notImplemented) + } + + // MARK: Generated + + func _route(_ router: Router) { + router + .use(middlewares) + .use(routes) + } + + var routes: [Middleware.Handler] = [ + $createUser, + $getUser, + $updateUser, + ] + + func _createUser(request: Request, next: Middleware.Next) async throws -> Response { + guard request.method == .GET, request.path == "/users" else { + return try await next(request) + } + + let username = try request["username"].stringThrowing + let password = try request["password"].stringThrowing + let fields = createUser(username: username, password: password) + return fields.response() + } +} + +@Model +struct User { + var id: Int? + let username: String + let password: String +} + +/* + + Can I generate code that will run each time the app starts? + + - register commands + - register macro'd jobs + - register migrations + - register macro'd routes + - register service configuration (change app to class - should solve it) + + */ From fd49656c765fcc8d537c00da8785dcf9b4299cf1 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 31 Dec 2023 19:05:08 -0500 Subject: [PATCH 03/55] Cleanup --- Alchemy/Application/Application+Config.swift | 71 -------- Alchemy/Application/Application.swift | 167 +++++++++--------- .../Plugins/{CorePlugin.swift => Core.swift} | 19 +- .../{CommandsPlugin.swift => Commands.swift} | 16 +- ...{EventsPlugin.swift => EventStreams.swift} | 2 +- Alchemy/HTTP/Commands/ServeCommand.swift | 8 +- Alchemy/HTTP/Plugins/HTTPConfiguration.swift | 102 +++++++++++ Alchemy/HTTP/Plugins/HTTPPlugin.swift | 49 ----- Alchemy/Logging/Loggers.swift | 24 +-- Alchemy/Queue/Commands/WorkCommand.swift | 2 - .../Scheduler/Commands/ScheduleCommand.swift | 3 - ...SchedulingPlugin.swift => Schedules.swift} | 3 +- Alchemy/Scheduler/Scheduler.swift | 1 + Alchemy/Services/Plugin.swift | 24 ++- AlchemyTest/Fixtures/TestApp.swift | 10 +- AlchemyTest/TestCase/TestCase.swift | 2 +- Example/App.swift | 5 + 17 files changed, 254 insertions(+), 254 deletions(-) delete mode 100644 Alchemy/Application/Application+Config.swift rename Alchemy/Application/Plugins/{CorePlugin.swift => Core.swift} (87%) rename Alchemy/Command/Plugins/{CommandsPlugin.swift => Commands.swift} (71%) rename Alchemy/Events/Plugins/{EventsPlugin.swift => EventStreams.swift} (78%) create mode 100644 Alchemy/HTTP/Plugins/HTTPConfiguration.swift delete mode 100644 Alchemy/HTTP/Plugins/HTTPPlugin.swift rename Alchemy/Scheduler/Plugins/{SchedulingPlugin.swift => Schedules.swift} (85%) diff --git a/Alchemy/Application/Application+Config.swift b/Alchemy/Application/Application+Config.swift deleted file mode 100644 index 637ee053..00000000 --- a/Alchemy/Application/Application+Config.swift +++ /dev/null @@ -1,71 +0,0 @@ -extension Application { - public typealias Configuration = ApplicationConfiguration -} - -public struct ApplicationConfiguration { - /// Application plugins. - public let plugins: () -> [Plugin] - /// The default plugins that will be loaded on your app. You won't typically - /// override this unless you want to prevent default Alchemy plugins from - /// loading. Add additional plugins to your app through `plugins`. - public let defaultPlugins: (Application) -> [Plugin] - /// Application commands. - public let commands: [Command.Type] - /// The default hashing algorithm. - public let defaultHashAlgorithm: HashAlgorithm - /// Maximum upload size allowed. - public let maxUploadSize: Int - /// Maximum size of data in flight while streaming request payloads before back pressure is applied. - public let maxStreamingBufferSize: Int - /// Defines the maximum length for the queue of pending connections - public let backlog: Int - /// Disables the Nagle algorithm for send coalescing. - public let tcpNoDelay: Bool - /// Pipelining ensures that only one http request is processed at one time. - public let withPipeliningAssistance: Bool - /// Timeout when reading a request. - public let readTimeout: TimeAmount - /// Timeout when writing a response. - public let writeTimeout: TimeAmount - - public init( - plugins: @escaping @autoclosure () -> [Plugin] = [], - defaultPlugins: @escaping (Application) -> [Plugin] = defaultPlugins, - commands: [Command.Type] = [], - defaultHashAlgorithm: HashAlgorithm = .bcrypt, - maxUploadSize: Int = 2 * 1024 * 1024, - maxStreamingBufferSize: Int = 1 * 1024 * 1024, - backlog: Int = 256, - tcpNoDelay: Bool = true, - withPipeliningAssistance: Bool = true, - readTimeout: TimeAmount = .seconds(30), - writeTimeout: TimeAmount = .minutes(3) - ) { - self.plugins = plugins - self.defaultPlugins = defaultPlugins - self.commands = commands - self.defaultHashAlgorithm = defaultHashAlgorithm - self.maxUploadSize = maxUploadSize - self.maxStreamingBufferSize = maxStreamingBufferSize - self.backlog = backlog - self.tcpNoDelay = tcpNoDelay - self.withPipeliningAssistance = withPipeliningAssistance - self.readTimeout = readTimeout - self.writeTimeout = writeTimeout - } - - /// The default plugins that will be loaded on an Alchemy app, in addition - /// to user defined plugins. - public static func defaultPlugins(for app: Application) -> [Plugin] { - [ - HTTPPlugin(), - CommandsPlugin(), - SchedulingPlugin(), - EventsPlugin(), - app.filesystems, - app.databases, - app.caches, - app.queues, - ] - } -} diff --git a/Alchemy/Application/Application.swift b/Alchemy/Application/Application.swift index 56a0e6c7..d30ad5cf 100644 --- a/Alchemy/Application/Application.swift +++ b/Alchemy/Application/Application.swift @@ -3,7 +3,7 @@ /// the entrypoint for your application. /// /// @main -/// struct App: Application { +/// final class App: Application { /// func boot() { /// get("/hello") { _ in /// "Hello, world!" @@ -14,97 +14,96 @@ public protocol Application: Router { /// The container in which all services of this application are registered. var container: Container { get } - - /// Create an instance of this Application. + /// Any custom plugins of this application. + var plugins: [Plugin] { get } + init() - + + /// Boots the app's dependencies. Don't override the default for this unless + /// you want to prevent default Alchemy services from loading. + func bootPlugins() /// Setup your application here. Called after all services are registered. func boot() throws - + + // MARK: Default Plugin Configurations + + /// This application's HTTP configuration. + var http: HTTPConfiguration { get } + /// This application's filesystems. + var filesystems: Filesystems { get } + /// This application's databases. + var databases: Databases { get } + /// The application's caches. + var caches: Caches { get } + /// The application's job queues. + var queues: Queues { get } + /// The application's custom commands. + var commands: Commands { get } + /// The application's loggers. + var loggers: Loggers { get } + /// Setup any scheduled tasks in your application here. func schedule(on schedule: Scheduler) +} - // MARK: Configuration - - /// The core configuration of the application. - var configuration: Configuration { get } - - /// The cache configuration of the application. - var caches: Caches { get } - - /// The database configuration of the application. - var databases: Databases { get } - - /// The filesystem configuration of the application. - var filesystems: Filesystems { get } - - /// The loggers of you application. - var loggers: Loggers { get } +// MARK: Defaults + +public extension Application { + var container: Container { .main } + var plugins: [Plugin] { [] } + + func bootPlugins() { + let alchemyPlugins: [Plugin] = [ + Core(), + Schedules(), + EventStreams(), + http, + commands, + filesystems, + databases, + caches, + queues, + ] + + for plugin in alchemyPlugins + plugins { + plugin.register(in: self) + } + } - /// The queue configuration of the application. - var queues: Queues { get } + func boot() throws { + // + } + + // MARK: Plugin Defaults + + var http: HTTPConfiguration { HTTPConfiguration() } + var commands: Commands { [] } + var databases: Databases { Databases() } + var caches: Caches { Caches() } + var queues: Queues { Queues() } + var filesystems: Filesystems { Filesystems() } + var loggers: Loggers { Loggers() } + + func schedule(on schedule: Scheduler) { + // + } } -extension Application { - /// The main application container. - public var container: Container { .main } - public var caches: Caches { Caches() } - public var configuration: Configuration { Configuration() } - public var databases: Databases { Databases() } - public var filesystems: Filesystems { Filesystems() } - public var loggers: Loggers { Loggers() } - public var queues: Queues { Queues() } - - public func boot() { /* default to no-op */ } - public func schedule(on schedule: Scheduler) { /* default to no-op */ } +// MARK: Running - public func run() async throws { +public extension Application { + func run() async throws { do { - setup() + bootPlugins() try boot() try await start() } catch { commander.exit(error: error) } } - - public func setup() { - - // 0. Register the Application - - container.register(self).singleton() - container.register(self as Application).singleton() - - // 1. Register core Plugin services. - - let core = CorePlugin() - core.registerServices(in: self) - - // 2. Register other Plugin services. - - let plugins = configuration.defaultPlugins(self) + configuration.plugins() - for plugin in plugins { - plugin.registerServices(in: self) - } - - // 3. Register all Plugins with lifecycle. - - for plugin in [core] + plugins { - lifecycle.register( - label: plugin.label, - start: .async { - try await plugin.boot(app: self) - }, - shutdown: .async { - try await plugin.shutdownServices(in: self) - }, - shutdownIfNotStarted: true - ) - } - } - + /// Starts the application with the given arguments. - public func start(_ args: String..., waitOrShutdown: Bool = true) async throws { + func start(_ args: String..., waitOrShutdown: Bool = true) async throws { try await start(args: args.isEmpty ? nil : args, waitOrShutdown: waitOrShutdown) } @@ -112,7 +111,7 @@ extension Application { /// /// @MainActor ensures that calls to `wait()` doesn't block an `EventLoop`. @MainActor - public func start(args: [String]? = nil, waitOrShutdown: Bool = true) async throws { + func start(args: [String]? = nil, waitOrShutdown: Bool = true) async throws { // 0. Start the application lifecycle. @@ -124,8 +123,8 @@ extension Application { guard waitOrShutdown else { return } // 2. Wait for lifecycle or immediately shut down depending on if the - // command should run indefinitely. - + // command should run indefinitely. + if command.runUntilStopped { wait() } else { @@ -133,22 +132,24 @@ extension Application { } } - public func wait() { + /// Waits indefinitely for the application to be stopped. + func wait() { lifecycle.wait() } - public func stop() async throws { + /// Stops the application. + func stop() async throws { try await lifecycle.shutdown() } - // For @main support - public static func main() async throws { + // @main support + static func main() async throws { try await Self().run() } } -extension ParsableCommand { - fileprivate var runUntilStopped: Bool { +fileprivate extension ParsableCommand { + var runUntilStopped: Bool { (Self.self as? Command.Type)?.runUntilStopped ?? false } } diff --git a/Alchemy/Application/Plugins/CorePlugin.swift b/Alchemy/Application/Plugins/Core.swift similarity index 87% rename from Alchemy/Application/Plugins/CorePlugin.swift rename to Alchemy/Application/Plugins/Core.swift index 3a8a7a9c..8e301629 100644 --- a/Alchemy/Application/Plugins/CorePlugin.swift +++ b/Alchemy/Application/Plugins/Core.swift @@ -1,21 +1,22 @@ import NIO -/// Sets up core services that other services may depend on. -struct CorePlugin: Plugin { +/// Registers core Alchemy services to an application. +struct Core: Plugin { func registerServices(in app: Application) { - - // 0. Register Environment + + // 0. Register Application + + app.container.register(app).singleton() + app.container.register(app as Application).singleton() + + // 1. Register Environment app.container.register { Environment.createDefault() }.singleton() - // 1. Register Loggers + // 2. Register Loggers app.loggers.registerServices(in: app) - // 2. Register Hasher - - app.container.register(Hasher(algorithm: app.configuration.defaultHashAlgorithm)).singleton() - // 3. Register NIO services app.container.register { MultiThreadedEventLoopGroup(numberOfThreads: $0.coreCount) as EventLoopGroup }.singleton() diff --git a/Alchemy/Command/Plugins/CommandsPlugin.swift b/Alchemy/Command/Plugins/Commands.swift similarity index 71% rename from Alchemy/Command/Plugins/CommandsPlugin.swift rename to Alchemy/Command/Plugins/Commands.swift index 070f38e3..c6e3ad08 100644 --- a/Alchemy/Command/Plugins/CommandsPlugin.swift +++ b/Alchemy/Command/Plugins/Commands.swift @@ -1,13 +1,19 @@ -struct CommandsPlugin: Plugin { - func registerServices(in app: Application) { +public struct Commands: Plugin, ExpressibleByArrayLiteral { + private let commands: [Command.Type] + + public init(arrayLiteral elements: Command.Type...) { + self.commands = elements + } + + public func registerServices(in app: Application) { app.container.register(Commander()).singleton() } - func boot(app: Application) { - for command in app.configuration.commands { + public func boot(app: Application) { + for command in commands { app.registerCommand(command) } - + app.registerCommand(ControllerMakeCommand.self) app.registerCommand(MiddlewareMakeCommand.self) app.registerCommand(MigrationMakeCommand.self) diff --git a/Alchemy/Events/Plugins/EventsPlugin.swift b/Alchemy/Events/Plugins/EventStreams.swift similarity index 78% rename from Alchemy/Events/Plugins/EventsPlugin.swift rename to Alchemy/Events/Plugins/EventStreams.swift index dd5b3069..b1804d6b 100644 --- a/Alchemy/Events/Plugins/EventsPlugin.swift +++ b/Alchemy/Events/Plugins/EventStreams.swift @@ -1,4 +1,4 @@ -struct EventsPlugin: Plugin { +struct EventStreams: Plugin { func registerServices(in app: Application) { app.container.register(EventBus()).singleton() } diff --git a/Alchemy/HTTP/Commands/ServeCommand.swift b/Alchemy/HTTP/Commands/ServeCommand.swift index 8288bd62..8f4fe218 100644 --- a/Alchemy/HTTP/Commands/ServeCommand.swift +++ b/Alchemy/HTTP/Commands/ServeCommand.swift @@ -4,18 +4,15 @@ import NIOHTTP1 import NIOHTTP2 import HummingbirdCore -let kDefaultHost = "127.0.0.1" -let kDefaultPort = 3000 - struct ServeCommand: Command { static let name = "serve" static var runUntilStopped: Bool = true /// The host to serve at. Defaults to `127.0.0.1`. - @Option var host = kDefaultHost + @Option var host = HTTPConfiguration.defaultHost /// The port to serve at. Defaults to `3000`. - @Option var port = kDefaultPort + @Option var port = HTTPConfiguration.defaultPort /// The unix socket to serve at. If this is provided, the host and /// port will be ignored. @@ -56,7 +53,6 @@ struct ServeCommand: Command { } if schedule { - app.schedule(on: Schedule) Schedule.start() } diff --git a/Alchemy/HTTP/Plugins/HTTPConfiguration.swift b/Alchemy/HTTP/Plugins/HTTPConfiguration.swift new file mode 100644 index 00000000..fad8718a --- /dev/null +++ b/Alchemy/HTTP/Plugins/HTTPConfiguration.swift @@ -0,0 +1,102 @@ +import HummingbirdCore + +public struct HTTPConfiguration: Plugin { + static let defaultHost = "127.0.0.1" + static let defaultPort = 3000 + + /// The default hashing algorithm. + public let defaultHashAlgorithm: HashAlgorithm + /// Maximum upload size allowed. + public let maxUploadSize: Int + /// Maximum size of data in flight while streaming request payloads before back pressure is applied. + public let maxStreamingBufferSize: Int + /// Defines the maximum length for the queue of pending connections + public let backlog: Int + /// Disables the Nagle algorithm for send coalescing. + public let tcpNoDelay: Bool + /// Pipelining ensures that only one http request is processed at one time. + public let withPipeliningAssistance: Bool + /// Timeout when reading a request. + public let readTimeout: TimeAmount + /// Timeout when writing a response. + public let writeTimeout: TimeAmount + + public init( + defaultHashAlgorithm: HashAlgorithm = .bcrypt, + maxUploadSize: Int = 2 * 1024 * 1024, + maxStreamingBufferSize: Int = 1 * 1024 * 1024, + backlog: Int = 256, + tcpNoDelay: Bool = true, + withPipeliningAssistance: Bool = true, + readTimeout: TimeAmount = .seconds(30), + writeTimeout: TimeAmount = .minutes(3) + ) { + self.defaultHashAlgorithm = defaultHashAlgorithm + self.maxUploadSize = maxUploadSize + self.maxStreamingBufferSize = maxStreamingBufferSize + self.backlog = backlog + self.tcpNoDelay = tcpNoDelay + self.withPipeliningAssistance = withPipeliningAssistance + self.readTimeout = readTimeout + self.writeTimeout = writeTimeout + } + + public func registerServices(in app: Application) { + + // 0. Register Server + + app.container.register { HBHTTPServer(group: $0.require(), configuration: hummingbirdConfiguration()) }.singleton() + + // 1. Register Router + + app.container.register(HTTPRouter()).singleton() + app.container.register { $0.require() as HTTPRouter as Router } + + // 2. Register Handler + + app.container.register { HTTPHandler(maxUploadSize: maxUploadSize, router: $0.require() as HTTPRouter) }.singleton() + app.container.register { $0.require() as HTTPHandler as RequestHandler } + + // 3. Register Client + + app.container.register(Client()).singleton() + + // 4. Register Hasher + + app.container.register(Hasher(algorithm: defaultHashAlgorithm)).singleton() + } + + public func shutdownServices(in app: Application) async throws { + try app.container.resolve(Client.self)?.shutdown() + try await app.container.resolve(HBHTTPServer.self)?.stop().get() + } + + private func hummingbirdConfiguration() -> HBHTTPServer.Configuration { + HBHTTPServer.Configuration( + address: { + if let socket = CommandLine.value(for: "--socket") { + return .unixDomainSocket(path: socket) + } else { + let host = CommandLine.value(for: "--host") ?? HTTPConfiguration.defaultHost + let port = (CommandLine.value(for: "--port").map { Int($0) } ?? nil) ?? HTTPConfiguration.defaultPort + return .hostname(host, port: port) + } + }(), + maxUploadSize: maxUploadSize, + maxStreamingBufferSize: maxStreamingBufferSize, + backlog: backlog, + tcpNoDelay: tcpNoDelay, + withPipeliningAssistance: withPipeliningAssistance, + idleTimeoutConfiguration: HBHTTPServer.IdleStateHandlerConfiguration( + readTimeout: readTimeout, + writeTimeout: writeTimeout + ) + ) + } +} + +extension Application { + public var server: HBHTTPServer { + Container.require() + } +} diff --git a/Alchemy/HTTP/Plugins/HTTPPlugin.swift b/Alchemy/HTTP/Plugins/HTTPPlugin.swift deleted file mode 100644 index 006eb6f2..00000000 --- a/Alchemy/HTTP/Plugins/HTTPPlugin.swift +++ /dev/null @@ -1,49 +0,0 @@ -import HummingbirdCore - -struct HTTPPlugin: Plugin { - func registerServices(in app: Application) { - let configuration = app.configuration - let hbConfiguration = hummingbirdConfiguration(for: configuration) - app.container.register { HBHTTPServer(group: $0.require(), configuration: hbConfiguration) }.singleton() - app.container.register(HTTPRouter()).singleton() - app.container.register { HTTPHandler(maxUploadSize: configuration.maxUploadSize, router: $0.require() as HTTPRouter) }.singleton() - app.container.register { $0.require() as HTTPRouter as Router } - app.container.register { $0.require() as HTTPHandler as RequestHandler } - app.container.register(Client()).singleton() - } - - func shutdownServices(in app: Application) async throws { - try app.container.resolve(Client.self)?.shutdown() - try await app.container.resolve(HBHTTPServer.self)?.stop().get() - } - - private func hummingbirdConfiguration(for configuration: Application.Configuration) -> HBHTTPServer.Configuration { - let socket = CommandLine.value(for: "--socket") ?? nil - let host = CommandLine.value(for: "--host") ?? kDefaultHost - let port = (CommandLine.value(for: "--port").map { Int($0) } ?? nil) ?? kDefaultPort - return HBHTTPServer.Configuration( - address: { - if let socket { - return .unixDomainSocket(path: socket) - } else { - return .hostname(host, port: port) - } - }(), - maxUploadSize: configuration.maxUploadSize, - maxStreamingBufferSize: configuration.maxStreamingBufferSize, - backlog: configuration.backlog, - tcpNoDelay: configuration.tcpNoDelay, - withPipeliningAssistance: configuration.withPipeliningAssistance, - idleTimeoutConfiguration: HBHTTPServer.IdleStateHandlerConfiguration( - readTimeout: configuration.readTimeout, - writeTimeout: configuration.writeTimeout - ) - ) - } -} - -extension Application { - public var server: HBHTTPServer { - Container.require() - } -} diff --git a/Alchemy/Logging/Loggers.swift b/Alchemy/Logging/Loggers.swift index 38d82806..ede9ba99 100644 --- a/Alchemy/Logging/Loggers.swift +++ b/Alchemy/Logging/Loggers.swift @@ -8,15 +8,7 @@ public struct Loggers: Plugin { } public func registerServices(in app: Application) { - let logLevel: Logger.Level? - if let value = CommandLine.value(for: "--log") ?? CommandLine.value(for: "-l"), let level = Logger.Level(rawValue: value) { - logLevel = level - } else if let value = ProcessInfo.processInfo.environment["LOG_LEVEL"], let level = Logger.Level(rawValue: value) { - logLevel = level - } else { - logLevel = nil - } - + let logLevel = app.env.logLevel for (id, logger) in loggers { var logger = logger if let logLevel { @@ -29,9 +21,21 @@ public struct Loggers: Plugin { if let _default = `default` ?? loggers.keys.first { app.container.register(Log(_default)).singleton() } - + if !Env.isXcode && Env.isDebug && !Env.isTesting { print() // Clear out the console on boot. } } } + +extension Environment { + var logLevel: Logger.Level? { + if let value = CommandLine.value(for: "--log") ?? CommandLine.value(for: "-l"), let level = Logger.Level(rawValue: value) { + return level + } else if let value = ProcessInfo.processInfo.environment["LOG_LEVEL"], let level = Logger.Level(rawValue: value) { + return level + } else { + return nil + } + } +} diff --git a/Alchemy/Queue/Commands/WorkCommand.swift b/Alchemy/Queue/Commands/WorkCommand.swift index 26b62679..4164acd4 100644 --- a/Alchemy/Queue/Commands/WorkCommand.swift +++ b/Alchemy/Queue/Commands/WorkCommand.swift @@ -34,8 +34,6 @@ struct WorkCommand: Command { } if schedule { - @Inject var app: Application - app.schedule(on: Schedule) Schedule.start() } diff --git a/Alchemy/Scheduler/Commands/ScheduleCommand.swift b/Alchemy/Scheduler/Commands/ScheduleCommand.swift index 09b590e5..d577a1f3 100644 --- a/Alchemy/Scheduler/Commands/ScheduleCommand.swift +++ b/Alchemy/Scheduler/Commands/ScheduleCommand.swift @@ -6,9 +6,6 @@ struct ScheduleCommand: Command { // MARK: Command func run() throws { - @Inject var app: Application - app.schedule(on: Schedule) Schedule.start() - Log.info("Started scheduler.") } } diff --git a/Alchemy/Scheduler/Plugins/SchedulingPlugin.swift b/Alchemy/Scheduler/Plugins/Schedules.swift similarity index 85% rename from Alchemy/Scheduler/Plugins/SchedulingPlugin.swift rename to Alchemy/Scheduler/Plugins/Schedules.swift index 936c0915..4453df71 100644 --- a/Alchemy/Scheduler/Plugins/SchedulingPlugin.swift +++ b/Alchemy/Scheduler/Plugins/Schedules.swift @@ -1,9 +1,10 @@ -struct SchedulingPlugin: Plugin { +struct Schedules: Plugin { func registerServices(in app: Application) { app.container.register(Scheduler()).singleton() } func boot(app: Application) async throws { + app.schedule(on: Schedule) app.registerCommand(ScheduleCommand.self) } diff --git a/Alchemy/Scheduler/Scheduler.swift b/Alchemy/Scheduler/Scheduler.swift index 47dfd804..3a6a5d36 100644 --- a/Alchemy/Scheduler/Scheduler.swift +++ b/Alchemy/Scheduler/Scheduler.swift @@ -29,6 +29,7 @@ public final class Scheduler { return } + Log.info("Scheduling \(tasks.count) tasks.") for task in tasks { schedule(task: task, on: scheduleLoop) } diff --git a/Alchemy/Services/Plugin.swift b/Alchemy/Services/Plugin.swift index 1812add2..ae8e6ab7 100644 --- a/Alchemy/Services/Plugin.swift +++ b/Alchemy/Services/Plugin.swift @@ -15,18 +15,32 @@ public protocol Plugin { func shutdownServices(in app: Application) async throws } -extension Plugin { - public var label: String { name(of: Self.self) } +public extension Plugin { + var label: String { name(of: Self.self) } - public func registerServices(in app: Application) { + func registerServices(in app: Application) { // } - public func boot(app: Application) async throws { + func boot(app: Application) async throws { // } - public func shutdownServices(in app: Application) async throws { + func shutdownServices(in app: Application) async throws { // } + + internal func register(in app: Application) { + registerServices(in: app) + app.lifecycle.register( + label: label, + start: .async { + try await boot(app: app) + }, + shutdown: .async { + try await shutdownServices(in: app) + }, + shutdownIfNotStarted: true + ) + } } diff --git a/AlchemyTest/Fixtures/TestApp.swift b/AlchemyTest/Fixtures/TestApp.swift index 3622f0b0..3a4a2dbb 100644 --- a/AlchemyTest/Fixtures/TestApp.swift +++ b/AlchemyTest/Fixtures/TestApp.swift @@ -1,12 +1,6 @@ import Alchemy /// An app that does nothing, for testing. -public struct TestApp: Application { - public init() { - // - } - - public func boot() throws { - // - } +public final class TestApp: Application { + public init() {} } diff --git a/AlchemyTest/TestCase/TestCase.swift b/AlchemyTest/TestCase/TestCase.swift index ccc2567b..4bc8d248 100644 --- a/AlchemyTest/TestCase/TestCase.swift +++ b/AlchemyTest/TestCase/TestCase.swift @@ -52,7 +52,7 @@ open class TestCase: XCTestCase { open override func setUp() async throws { try await super.setUp() app = A() - app.setup() + app.bootPlugins() try app.boot() } diff --git a/Example/App.swift b/Example/App.swift index f456ed93..1704157c 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -37,6 +37,7 @@ import Alchemy - PRO: Easy overloads / hint what you want Closures - CON: can overload, but a lot of work + */ @main @@ -52,6 +53,8 @@ struct App: Application { func get500(req: Request) throws { throw HTTPError(.internalServerError) } } +/* + @Routes struct UserController { var middlewares: [Middleware] = [ @@ -114,6 +117,8 @@ struct User { let username: String let password: String } + + */ /* From ceae4074466b5153d1522c53328c0c32e6ab72c9 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 18 May 2024 15:27:35 -0700 Subject: [PATCH 04/55] WIP --- Alchemy/AlchemyX/DTO+AlchemyX.swift | 81 ++++++++++++++++++++++++ Alchemy/Database/Plugins/Databases.swift | 17 +++-- Alchemy/Database/Query/Query.swift | 12 ++-- Alchemy/Database/SQL/SQLRow.swift | 2 +- Alchemy/HTTP/Commands/ServeCommand.swift | 4 +- Alchemy/HTTP/Content/Content.swift | 31 +++++++++ Alchemy/Routing/Controller.swift | 2 +- Alchemy/Routing/Router.swift | 4 ++ Package.swift | 9 ++- 9 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 Alchemy/AlchemyX/DTO+AlchemyX.swift diff --git a/Alchemy/AlchemyX/DTO+AlchemyX.swift b/Alchemy/AlchemyX/DTO+AlchemyX.swift new file mode 100644 index 00000000..4a4a9005 --- /dev/null +++ b/Alchemy/AlchemyX/DTO+AlchemyX.swift @@ -0,0 +1,81 @@ +import AlchemyX + +public struct DTOController: Controller where D.Identifier: SQLValueConvertible & LosslessStringConvertible { + let db: Database + let tableName: String + + var table: Query { + db.table(tableName) + } + + public func route(_ router: Router) { + let pathWithId = D.path + "/:id" + router + .get(D.path, use: getAll) + .get(pathWithId, use: getOne) + .post(D.path, use: create) + .patch(pathWithId, use: update) + .delete(pathWithId, use: delete) + } + + private func getAll(req: Request) async throws -> [D] { + return try await table.get().decodeEach(keyMapping: db.keyMapping) + } + + private func getOne(req: Request) async throws -> D { + let id: D.ID = try req.requireParameter("id") + guard let row = try await table.where("id" == id).first() else { + throw HTTPError(.notFound) + } + + return try row.decode(keyMapping: db.keyMapping) + } + + private func create(req: Request) async throws -> D { + let dto = try req.decode(D.self) + return try await table.insertReturn(dto).decode(keyMapping: db.keyMapping) + } + + private func update(req: Request) async throws -> D { + let id: D.ID = try req.requireParameter("id") + + guard req.content.error == nil else { + throw HTTPError(.badRequest) + } + + // 0. update the row with req fields + + let query = table.where("id" == id) + try await query.update(req.content) + + // 1. return the updated row + + guard let first = try await query.first() else { + throw HTTPError(.notFound) + } + + return try first.decode(keyMapping: db.keyMapping) + } + + private func delete(req: Request) async throws { + let id: D.ID = try req.requireParameter("id") + guard try await table.where("id" == id).exists() else { + throw HTTPError(.notFound) + } + + try await table.where("id" == id).delete() + } +} + +public extension Router { + @discardableResult + func useResource(_ type: D.Type) -> Self where D.Identifier: SQLValueConvertible & LosslessStringConvertible { + use(type.controller()) + } +} + +fileprivate extension Resource where Identifier: SQLValueConvertible & LosslessStringConvertible { + static func controller(db: Database = DB, table: String = "\(Self.self)".lowercased().pluralized) -> Controller { + DTOController(db: db, tableName: table) + } +} diff --git a/Alchemy/Database/Plugins/Databases.swift b/Alchemy/Database/Plugins/Databases.swift index 68787a77..308b7228 100644 --- a/Alchemy/Database/Plugins/Databases.swift +++ b/Alchemy/Database/Plugins/Databases.swift @@ -1,6 +1,7 @@ -public struct Databases: Plugin { +public final class Databases: Plugin { private let `default`: Database.Identifier? - private let databases: [Database.Identifier: Database] + private let databases: () -> [Database.Identifier: Database] + private var _databases: [Database.Identifier: Database]? private let migrations: [Migration] private let seeders: [Seeder] private let defaultRedis: RedisClient.Identifier? @@ -8,7 +9,7 @@ public struct Databases: Plugin { public init( default: Database.Identifier? = nil, - databases: [Database.Identifier: Database] = [:], + databases: @escaping @autoclosure () -> [Database.Identifier: Database] = [:], migrations: [Migration] = [], seeders: [Seeder] = [], defaultRedis: RedisClient.Identifier? = nil, @@ -23,13 +24,16 @@ public struct Databases: Plugin { } public func registerServices(in app: Application) { - for (id, db) in databases { + _databases = databases() + guard let _databases else { return } + + for (id, db) in _databases { db.migrations = migrations db.seeders = seeders app.container.register(db, id: id).singleton() } - if let _default = `default` ?? databases.keys.first { + if let _default = `default` ?? _databases.keys.first { app.container.register(DB(_default)).singleton() } @@ -51,7 +55,8 @@ public struct Databases: Plugin { } public func shutdownServices(in app: Application) async throws { - for id in databases.keys { + guard let _databases else { return } + for id in _databases.keys { try await app.container.resolve(Database.self, id: id)?.shutdown() } diff --git a/Alchemy/Database/Query/Query.swift b/Alchemy/Database/Query/Query.swift index 8097418e..31a2e01f 100644 --- a/Alchemy/Database/Query/Query.swift +++ b/Alchemy/Database/Query/Query.swift @@ -169,8 +169,12 @@ open class Query: SQLConvertible { try await insert(try encodables.map { try $0.sqlFields(keyMapping: keyMapping, jsonEncoder: jsonEncoder) }) } - public func insertReturn(_ values: [String: SQLConvertible]) async throws -> [SQLRow] { - try await insertReturn([values]) + public func insertReturn(_ values: [String: SQLConvertible]) async throws -> SQLRow { + guard let first = try await insertReturn([values]).first else { + throw DatabaseError("INSERT didn't return any rows.") + } + + return first } /// Perform an insert and return the inserted records. @@ -199,7 +203,7 @@ open class Query: SQLConvertible { } } - public func insertReturn(_ encodable: E, keyMapping: KeyMapping = .snakeCase, jsonEncoder: JSONEncoder = JSONEncoder()) async throws -> [SQLRow] { + public func insertReturn(_ encodable: E, keyMapping: KeyMapping = .snakeCase, jsonEncoder: JSONEncoder = JSONEncoder()) async throws -> SQLRow { try await insertReturn(try encodable.sqlFields(keyMapping: keyMapping, jsonEncoder: jsonEncoder)) } @@ -309,7 +313,7 @@ open class Query: SQLConvertible { } public func update(_ encodable: E, keyMapping: KeyMapping = .snakeCase, jsonEncoder: JSONEncoder = JSONEncoder()) async throws { - try await update(encodable.sqlFields(keyMapping: keyMapping, jsonEncoder: jsonEncoder)) + try await update(try encodable.sqlFields(keyMapping: keyMapping, jsonEncoder: jsonEncoder)) } public func increment(_ column: String, by amount: Int = 1) async throws { diff --git a/Alchemy/Database/SQL/SQLRow.swift b/Alchemy/Database/SQL/SQLRow.swift index 60160831..62efbf2c 100644 --- a/Alchemy/Database/SQL/SQLRow.swift +++ b/Alchemy/Database/SQL/SQLRow.swift @@ -53,7 +53,7 @@ public struct SQLRow: ExpressibleByDictionaryLiteral { } extension Array { - public func decodeEach(_ type: D.Type, + public func decodeEach(_ type: D.Type = D.self, keyMapping: KeyMapping = .useDefaultKeys, jsonDecoder: JSONDecoder = JSONDecoder()) throws -> [D] { try map { try $0.decode(type, keyMapping: keyMapping, jsonDecoder: jsonDecoder) } diff --git a/Alchemy/HTTP/Commands/ServeCommand.swift b/Alchemy/HTTP/Commands/ServeCommand.swift index 8f4fe218..70076432 100644 --- a/Alchemy/HTTP/Commands/ServeCommand.swift +++ b/Alchemy/HTTP/Commands/ServeCommand.swift @@ -132,7 +132,7 @@ private struct HTTPResponder: HBHTTPResponder { let finishedAt = Date() let dateString = Formatters.date.string(from: finishedAt) let timeString = Formatters.time.string(from: finishedAt) - let left = "\(dateString) \(timeString) \(req.path)" + let left = "\(dateString) \(timeString) \(req.method) \(req.path)" let right = "\(startedAt.elapsedString) \(res.status.code)" let dots = Log.dots(left: left, right: right) let status: Status = { @@ -171,7 +171,7 @@ private struct HTTPResponder: HBHTTPResponder { code = code.white } - Log.comment("\(dateString.lightBlack) \(timeString) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(code)") + Log.comment("\(dateString.lightBlack) \(timeString) \(req.method) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(code)") } } } diff --git a/Alchemy/HTTP/Content/Content.swift b/Alchemy/HTTP/Content/Content.swift index 0c678a58..d289947e 100644 --- a/Alchemy/HTTP/Content/Content.swift +++ b/Alchemy/HTTP/Content/Content.swift @@ -437,6 +437,37 @@ extension Content.Value: Encodable { } } +extension Content.Value: ModelProperty { + public init(key: String, on row: SQLRowReader) throws { + throw ContentError.notSupported("Reading content from database models isn't supported, yet.") + } + + public func store(key: String, on row: inout SQLRowWriter) throws { + switch self { + case .array(let values): + try row.put(json: values, at: key) + case .dictionary(let dict): + try row.put(json: dict, at: key) + case .bool(let value): + try value.store(key: key, on: &row) + case .string(let value): + try value.store(key: key, on: &row) + case .int(let value): + try value.store(key: key, on: &row) + case .double(let double): + try double.store(key: key, on: &row) + case .file(let file): + if let buffer = file.content?.buffer { + row.put(SQLValue.bytes(buffer), at: key) + } else { + row.put(SQLValue.null, at: key) + } + case .null: + row.put(SQLValue.null, at: key) + } + } +} + // MARK: Array Extensions extension Array where Element == Content { diff --git a/Alchemy/Routing/Controller.swift b/Alchemy/Routing/Controller.swift index 125d0352..2a6ac058 100644 --- a/Alchemy/Routing/Controller.swift +++ b/Alchemy/Routing/Controller.swift @@ -10,7 +10,7 @@ extension Router { /// /// - Parameter controller: The controller to handle routes on this router. @discardableResult - public func controller(_ controllers: Controller...) -> Self { + public func use(_ controllers: Controller...) -> Self { controllers.forEach { $0.route(group()) } diff --git a/Alchemy/Routing/Router.swift b/Alchemy/Routing/Router.swift index 8ac7fbb5..6aabcef7 100644 --- a/Alchemy/Routing/Router.swift +++ b/Alchemy/Routing/Router.swift @@ -42,6 +42,10 @@ public struct RouteMatcher: Buildable { private mutating func matchPath(_ path: String) -> Bool { parameters = [] let parts = RouteMatcher.tokenize(path) + guard parts.count == pathTokens.count else { + return false + } + for (index, token) in pathTokens.enumerated() { guard let part = parts[safe: index] else { return false diff --git a/Package.swift b/Package.swift index e31d8679..5a803da8 100644 --- a/Package.swift +++ b/Package.swift @@ -5,13 +5,15 @@ let package = Package( name: "alchemy", platforms: [ .macOS(.v13), + .iOS(.v16), ], products: [ - .executable(name: "Example", targets: ["Example"]), + .executable(name: "Demo", targets: ["AlchemyExample"]), .library(name: "Alchemy", targets: ["Alchemy"]), .library(name: "AlchemyTest", targets: ["AlchemyTest"]), ], dependencies: [ + .package(path: "../AlchemyX"), .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "1.8.1"), .package(url: "https://github.com/hummingbird-project/hummingbird-core.git", from: "1.3.1"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), @@ -31,7 +33,7 @@ let package = Package( ], targets: [ .executableTarget( - name: "Example", + name: "AlchemyExample", dependencies: [ .byName(name: "Alchemy"), ], @@ -40,6 +42,9 @@ let package = Package( .target( name: "Alchemy", dependencies: [ + /// Experimental + + .product(name: "AlchemyX", package: "AlchemyX"), /// Core From 810f2f6392086eac4d07c1b9b89a5935625ac95b Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 31 May 2024 10:20:34 -0700 Subject: [PATCH 05/55] working alchemyx query --- Alchemy/AlchemyX/DTO+AlchemyX.swift | 81 ----------------- Alchemy/AlchemyX/Router+Resource.swift | 116 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 81 deletions(-) delete mode 100644 Alchemy/AlchemyX/DTO+AlchemyX.swift create mode 100644 Alchemy/AlchemyX/Router+Resource.swift diff --git a/Alchemy/AlchemyX/DTO+AlchemyX.swift b/Alchemy/AlchemyX/DTO+AlchemyX.swift deleted file mode 100644 index 4a4a9005..00000000 --- a/Alchemy/AlchemyX/DTO+AlchemyX.swift +++ /dev/null @@ -1,81 +0,0 @@ -import AlchemyX - -public struct DTOController: Controller where D.Identifier: SQLValueConvertible & LosslessStringConvertible { - let db: Database - let tableName: String - - var table: Query { - db.table(tableName) - } - - public func route(_ router: Router) { - let pathWithId = D.path + "/:id" - router - .get(D.path, use: getAll) - .get(pathWithId, use: getOne) - .post(D.path, use: create) - .patch(pathWithId, use: update) - .delete(pathWithId, use: delete) - } - - private func getAll(req: Request) async throws -> [D] { - return try await table.get().decodeEach(keyMapping: db.keyMapping) - } - - private func getOne(req: Request) async throws -> D { - let id: D.ID = try req.requireParameter("id") - guard let row = try await table.where("id" == id).first() else { - throw HTTPError(.notFound) - } - - return try row.decode(keyMapping: db.keyMapping) - } - - private func create(req: Request) async throws -> D { - let dto = try req.decode(D.self) - return try await table.insertReturn(dto).decode(keyMapping: db.keyMapping) - } - - private func update(req: Request) async throws -> D { - let id: D.ID = try req.requireParameter("id") - - guard req.content.error == nil else { - throw HTTPError(.badRequest) - } - - // 0. update the row with req fields - - let query = table.where("id" == id) - try await query.update(req.content) - - // 1. return the updated row - - guard let first = try await query.first() else { - throw HTTPError(.notFound) - } - - return try first.decode(keyMapping: db.keyMapping) - } - - private func delete(req: Request) async throws { - let id: D.ID = try req.requireParameter("id") - guard try await table.where("id" == id).exists() else { - throw HTTPError(.notFound) - } - - try await table.where("id" == id).delete() - } -} - -public extension Router { - @discardableResult - func useResource(_ type: D.Type) -> Self where D.Identifier: SQLValueConvertible & LosslessStringConvertible { - use(type.controller()) - } -} - -fileprivate extension Resource where Identifier: SQLValueConvertible & LosslessStringConvertible { - static func controller(db: Database = DB, table: String = "\(Self.self)".lowercased().pluralized) -> Controller { - DTOController(db: db, tableName: table) - } -} diff --git a/Alchemy/AlchemyX/Router+Resource.swift b/Alchemy/AlchemyX/Router+Resource.swift new file mode 100644 index 00000000..f2082667 --- /dev/null +++ b/Alchemy/AlchemyX/Router+Resource.swift @@ -0,0 +1,116 @@ +import AlchemyX +import Pluralize + +extension Router { + @discardableResult + public func useResource( + _ type: R.Type, + db: Database = DB, + table: String = "\(R.self)".lowercased().pluralized + ) -> Self where R.Identifier: SQLValueConvertible & LosslessStringConvertible { + use(ResourceController(db: db, tableName: table)) + } +} + +private struct ResourceController: Controller + where R.Identifier: SQLValueConvertible & LosslessStringConvertible +{ + let db: Database + let tableName: String + + private var table: Query { + db.table(tableName) + } + + public func route(_ router: Router) { + router + .post(R.path + "/create", use: create) + .post(R.path, use: getAll) + .get(R.path + "/:id", use: getOne) + .patch(R.path + "/:id", use: update) + .delete(R.path + "/:id", use: delete) + } + + private func getAll(req: Request) async throws -> [R] { + var query = table + if let queryParameters = try req.decode(QueryParameters?.self) { + for filter in queryParameters.filters { + query = query.filter(filter, keyMapping: db.keyMapping) + } + + for sort in queryParameters.sorts { + query = query.sort(sort, keyMapping: db.keyMapping) + } + } + + return try await query.get().decodeEach(keyMapping: db.keyMapping) + } + + private func getOne(req: Request) async throws -> R { + let id: R.ID = try req.requireParameter("id") + guard let row = try await model(id).first() else { + throw HTTPError(.notFound) + } + + return try row.decode(keyMapping: db.keyMapping) + } + + private func create(req: Request) async throws -> R { + let resource = try req.decode(R.self) + return try await table.insertReturn(resource).decode(keyMapping: db.keyMapping) + } + + private func update(req: Request) async throws -> R { + let id: R.ID = try req.requireParameter("id") + + guard req.content.error == nil else { + throw HTTPError(.badRequest) + } + + // 0. update the row with req fields + + try await model(id).update(req.content) + + // 1. return the updated row + + guard let first = try await model(id).first() else { + throw HTTPError(.notFound) + } + + return try first.decode(keyMapping: db.keyMapping) + } + + private func delete(req: Request) async throws { + let id: R.ID = try req.requireParameter("id") + guard try await model(id).exists() else { + throw HTTPError(.notFound) + } + + try await model(id).delete() + } + + private func model(_ id: R.Identifier?) -> Query { + table.where("id" == id) + } +} + +extension Query { + fileprivate func filter(_ filter: QueryParameters.Filter, keyMapping: KeyMapping) -> Self { + let op: SQLWhere.Operator = switch filter.op { + case .equals: .equals + case .greaterThan: .greaterThan + case .greaterThanEquals: .greaterThanOrEqualTo + case .lessThan: .lessThan + case .lessThanEquals: .lessThanOrEqualTo + case .notEquals: .notEqualTo + } + + let field = keyMapping.encode(filter.field) + return `where`(field, op, filter.value) + } + + fileprivate func sort(_ sort: QueryParameters.Sort, keyMapping: KeyMapping) -> Self { + let field = keyMapping.encode(sort.field) + return orderBy(field, direction: sort.ascending ? .asc : .desc) + } +} From eb51f3cd4eaf98adc795431d339d2e168b1d65a7 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 1 Jun 2024 12:49:46 -0700 Subject: [PATCH 06/55] fix up logging --- Alchemy/Database/Database.swift | 29 ++++++++++++++++++++++++++--- Alchemy/Database/Query/Query.swift | 17 +---------------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/Alchemy/Database/Database.swift b/Alchemy/Database/Database.swift index a8054b09..5543cfc2 100644 --- a/Alchemy/Database/Database.swift +++ b/Alchemy/Database/Database.swift @@ -19,7 +19,7 @@ public final class Database: Service { public var keyMapping: KeyMapping = .snakeCase /// Whether this database should log all queries at the `debug` level. - var logging: QueryLogging? = nil + private var logging: QueryLogging? = nil /// Create a database backed by the given provider. /// @@ -77,7 +77,12 @@ public final class Database: Service { /// - Returns: The database rows returned by the query. @discardableResult public func query(_ sql: String, parameters: [SQLValue] = []) async throws -> [SQLRow] { - try await provider.query(sql, parameters: parameters) + try await _query(sql, parameters: parameters, logging: nil) + } + + func _query(_ sql: String, parameters: [SQLValue] = [], logging: QueryLogging?) async throws -> [SQLRow] { + log(SQL(sql, parameters: parameters), loggingOverride: logging) + return try await provider.query(sql, parameters: parameters) } /// Run a raw, not parametrized SQL string. @@ -85,7 +90,8 @@ public final class Database: Service { /// - Returns: The rows returned by the query. @discardableResult public func raw(_ sql: String) async throws -> [SQLRow] { - try await provider.raw(sql) + log(SQL(sql, parameters: [])) + return try await provider.raw(sql) } /// Runs a transaction on the database, using the given closure. @@ -108,4 +114,21 @@ public final class Database: Service { public func shutdown() async throws { try await provider.shutdown() } + + private func log(_ sql: SQL, loggingOverride: QueryLogging? = nil) { + if let logging = logging ?? self.logging { + switch logging { + case .log: + Log.info(sql.description) + case .logRawSQL: + Log.info(sql.rawSQLString + ";") + case .logFatal: + Log.info(sql.description) + fatalError("logf") + case .logFatalRawSQL: + Log.info(sql.rawSQLString + ";") + fatalError("logf") + } + } + } } diff --git a/Alchemy/Database/Query/Query.swift b/Alchemy/Database/Query/Query.swift index 31a2e01f..4668c6e0 100644 --- a/Alchemy/Database/Query/Query.swift +++ b/Alchemy/Database/Query/Query.swift @@ -383,21 +383,6 @@ extension Database { @discardableResult func query(sql: SQL, logging: QueryLogging? = nil) async throws -> [SQLRow] { - if let logging = logging ?? self.logging { - switch logging { - case .log: - Log.info(sql.description) - case .logRawSQL: - Log.info(sql.rawSQLString + ";") - case .logFatal: - Log.info(sql.description) - fatalError("logf") - case .logFatalRawSQL: - Log.info(sql.rawSQLString + ";") - fatalError("logf") - } - } - - return try await query(sql.statement, parameters: sql.parameters) + try await _query(sql.statement, parameters: sql.parameters, logging: logging) } } From aadee49b3071cfe976304dff27cdeb4a2075384b Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 2 Jun 2024 08:46:00 -0700 Subject: [PATCH 07/55] add ResourceMigration --- Alchemy/AlchemyX/ResourceMigration.swift | 204 +++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 Alchemy/AlchemyX/ResourceMigration.swift diff --git a/Alchemy/AlchemyX/ResourceMigration.swift b/Alchemy/AlchemyX/ResourceMigration.swift new file mode 100644 index 00000000..30cc929b --- /dev/null +++ b/Alchemy/AlchemyX/ResourceMigration.swift @@ -0,0 +1,204 @@ +import AlchemyX +import Collections + +extension Resource { + static var table: String { + "\(Self.self)".lowercased().pluralized + } +} + +public struct ResourceMigration: Migration { + public var name: String { + let type = KeyMapping.snakeCase.encode("\(R.self)") + return "resource_migration_\(type)" + "_\(Int(Date().timeIntervalSince1970))" + } + + public init() {} + + public func up(db: Database) async throws { + let table = db.keyMapping.encode(R.table) + let resourceSchema = R.schema(keyMapping: db.keyMapping) + if try await db.hasTable(table) { + + // add new and drop old keys + let tableSchema = try await db.schema(for: table) + let adds = resourceSchema.keys.subtracting(tableSchema.keys) + let drops = tableSchema.keys.subtracting(resourceSchema.keys) + + Log.info("Adding \(adds) and dropping \(drops) from Resource \(R.self)") + + try await db.alterTable(table) { + for add in adds { + if let field = resourceSchema[add] { + $0.column(add, field: field) + } + } + + for drop in drops { + $0.drop(column: drop) + } + } + } else { + + Log.info("Creating table \(table)") + + // create the table from scratch + try await db.createTable(table) { + for (column, field) in resourceSchema { + $0.column(column, field: field) + } + } + } + } + + public func down(db: Database) async throws { + // ignore + } +} + +extension CreateColumnBuilder { + @discardableResult func `default`(any: Any?) -> Self { + guard let any else { return self } + guard let value = any as? Default else { return self } + return `default`(val: value) + } +} + +extension CreateTableBuilder { + fileprivate func column(_ name: String, field: ResourceField) { + switch field.columnType() { + case .increments: + increments(name) + .notNull(if: !field.isOptional) + .primary(if: name == "id") + .default(any: field.default) + case .int: + int(name) + .notNull(if: !field.isOptional) + .primary(if: name == "id") + .default(any: field.default) + case .bigInt: + bigInt(name) + .notNull(if: !field.isOptional) + .primary(if: name == "id") + .default(any: field.default) + case .double: + double(name) + .notNull(if: !field.isOptional) + .primary(if: name == "id") + .default(any: field.default) + case .string(let length): + string(name, length: length) + .notNull(if: !field.isOptional) + .primary(if: name == "id") + .default(any: field.default) + case .uuid: + uuid(name) + .notNull(if: !field.isOptional) + .primary(if: name == "id") + .default(any: field.default) + case .bool: + bool(name) + .notNull(if: !field.isOptional) + .primary(if: name == "id") + .default(any: field.default) + case .date: + date(name) + .notNull(if: !field.isOptional) + .primary(if: name == "id") + .default(any: field.default) + case .json: + json(name) + .notNull(if: !field.isOptional) + .primary(if: name == "id") + .default(any: field.default) + } + } +} + +extension CreateColumnBuilder { + @discardableResult fileprivate func notNull(if value: Bool) -> Self { + value ? notNull() : self + } + + @discardableResult fileprivate func primary(if value: Bool) -> Self { + value ? primary() : self + } +} + +extension Database { + fileprivate func schema(for table: String) async throws -> OrderedDictionary { + let rows = try await raw("PRAGMA table_info(\(table))") + return OrderedDictionary( + try rows.map { + let name = try $0.require("name").string() + let typeString = try $0.require("type").string() + guard let type = SQLiteType.parse(typeString) else { + throw DatabaseError("Unable to decode SQLite type \(typeString)") + } + + return (name, type) + }, + uniquingKeysWith: { a, _ in a } + ) + } + + enum SQLiteType: String { + case null = "NULL" + case integer = "INTEGER" + case real = "REAL" + case text = "TEXT" + case blob = "BLOB" + case numeric = "NUMERIC" + + static func parse(_ sqliteType: String) -> SQLiteType? { + switch sqliteType.lowercased() { + case "double": .real + case "bigint": .integer + case "blob": .blob + case "datetime": .numeric + case "int": .integer + case "integer": .integer + case "text": .text + case "varchar": .text + case "null": .null + case "real": .real + case "numeric": .numeric + default: nil + } + } + } +} + +extension Resource { + fileprivate static func schema(keyMapping: KeyMapping) -> OrderedDictionary { + OrderedDictionary( + fields.map { _, field in + (keyMapping.encode(field.name), field) + }, + uniquingKeysWith: { a, _ in a } + ) + } +} + +extension ResourceField { + fileprivate func columnType() -> ColumnType { + if type == String.self { + .string(.unlimited) + } else if type == Int.self { + name == "id" ? .increments : .bigInt + } else if type == Double.self { + .double + } else if type == Bool.self { + .bool + } else if type == Date.self { + .date + } else if type == UUID.self { + .uuid + } else if type is Encodable.Type && type is Decodable.Type { + .json + } else { + preconditionFailure("unable to convert type \(type) to an SQL column type, try using a Codable type") + } + } +} From cc8d460ab3d147dedbc583383dc9747728625a48 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 2 Jun 2024 09:22:51 -0700 Subject: [PATCH 08/55] add database type --- Alchemy/AlchemyX/ResourceMigration.swift | 29 +++++++++++-------- Alchemy/Database/Database.swift | 11 +++++++ Alchemy/Database/DatabaseProvider.swift | 3 ++ .../MySQL/MySQLDatabaseProvider.swift | 4 +++ .../Postgres/PostgresDatabaseProvider.swift | 4 +++ .../SQLite/SQLiteDatabaseProvider.swift | 4 +++ 6 files changed, 43 insertions(+), 12 deletions(-) diff --git a/Alchemy/AlchemyX/ResourceMigration.swift b/Alchemy/AlchemyX/ResourceMigration.swift index 30cc929b..5a4ed5eb 100644 --- a/Alchemy/AlchemyX/ResourceMigration.swift +++ b/Alchemy/AlchemyX/ResourceMigration.swift @@ -128,19 +128,24 @@ extension CreateColumnBuilder { extension Database { fileprivate func schema(for table: String) async throws -> OrderedDictionary { - let rows = try await raw("PRAGMA table_info(\(table))") - return OrderedDictionary( - try rows.map { - let name = try $0.require("name").string() - let typeString = try $0.require("type").string() - guard let type = SQLiteType.parse(typeString) else { - throw DatabaseError("Unable to decode SQLite type \(typeString)") - } + switch type { + case .sqlite: + let rows = try await raw("PRAGMA table_info(\(table))") + return OrderedDictionary( + try rows.map { + let name = try $0.require("name").string() + let typeString = try $0.require("type").string() + guard let type = SQLiteType.parse(typeString) else { + throw DatabaseError("Unable to decode SQLite type \(typeString)") + } - return (name, type) - }, - uniquingKeysWith: { a, _ in a } - ) + return (name, type) + }, + uniquingKeysWith: { a, _ in a } + ) + default: + preconditionFailure("pulling schemas isn't supported on \(type.name) yet") + } } enum SQLiteType: String { diff --git a/Alchemy/Database/Database.swift b/Alchemy/Database/Database.swift index 5543cfc2..1f0246f9 100644 --- a/Alchemy/Database/Database.swift +++ b/Alchemy/Database/Database.swift @@ -5,6 +5,9 @@ public final class Database: Service { /// The provider of this database. public let provider: DatabaseProvider + /// The underlying DBMS type (i.e. PostgreSQL, SQLite, etc...) + public var type: DatabaseType { provider.type } + /// Functions around compiling SQL statments for this database's /// SQL dialect when using the QueryBuilder or Rune. public var grammar: SQLGrammar @@ -132,3 +135,11 @@ public final class Database: Service { } } } + +public struct DatabaseType: Equatable { + public let name: String + + public static let sqlite = DatabaseType(name: "SQLite") + public static let postgres = DatabaseType(name: "PostgreSQL") + public static let mysql = DatabaseType(name: "MySQL") +} diff --git a/Alchemy/Database/DatabaseProvider.swift b/Alchemy/Database/DatabaseProvider.swift index 723f316b..eb3c29ca 100644 --- a/Alchemy/Database/DatabaseProvider.swift +++ b/Alchemy/Database/DatabaseProvider.swift @@ -1,5 +1,8 @@ /// A generic type to represent any database you might be interacting with. public protocol DatabaseProvider { + /// The type of DBMS (i.e. PostgreSQL, SQLite, MySQL) + var type: DatabaseType { get } + /// Run a parameterized query on the database. Parameterization /// helps protect against SQL injection. /// diff --git a/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift b/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift index e5f10bc6..4be12fbf 100644 --- a/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift +++ b/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift @@ -4,6 +4,8 @@ import MySQLNIO @_implementationOnly import NIOPosix // for inet_pton() public final class MySQLDatabaseProvider: DatabaseProvider { + public var type: DatabaseType { .mysql } + /// The connection pool from which to make connections to the /// database with. public let pool: EventLoopGroupConnectionPool @@ -44,6 +46,8 @@ public final class MySQLDatabaseProvider: DatabaseProvider { } extension MySQLConnection: DatabaseProvider, ConnectionPoolItem { + public var type: DatabaseType { .mysql } + @discardableResult public func query(_ sql: String, parameters: [SQLValue]) async throws -> [SQLRow] { let binds = parameters.map(MySQLData.init) diff --git a/Alchemy/Database/Providers/Postgres/PostgresDatabaseProvider.swift b/Alchemy/Database/Providers/Postgres/PostgresDatabaseProvider.swift index 422fcec7..135ed885 100644 --- a/Alchemy/Database/Providers/Postgres/PostgresDatabaseProvider.swift +++ b/Alchemy/Database/Providers/Postgres/PostgresDatabaseProvider.swift @@ -4,6 +4,8 @@ import PostgresNIO /// A concrete `Database` for connecting to and querying a PostgreSQL /// database. public final class PostgresDatabaseProvider: DatabaseProvider { + public var type: DatabaseType { .postgres } + /// The connection pool from which to make connections to the /// database with. public let pool: EventLoopGroupConnectionPool @@ -47,6 +49,8 @@ public final class PostgresDatabaseProvider: DatabaseProvider { } extension PostgresConnection: DatabaseProvider, ConnectionPoolItem { + public var type: DatabaseType { .postgres } + @discardableResult public func query(_ sql: String, parameters: [SQLValue]) async throws -> [SQLRow] { let statement = sql.positionPostgresBinds() diff --git a/Alchemy/Database/Providers/SQLite/SQLiteDatabaseProvider.swift b/Alchemy/Database/Providers/SQLite/SQLiteDatabaseProvider.swift index d2a71b1f..346668ff 100644 --- a/Alchemy/Database/Providers/SQLite/SQLiteDatabaseProvider.swift +++ b/Alchemy/Database/Providers/SQLite/SQLiteDatabaseProvider.swift @@ -2,6 +2,8 @@ import AsyncKit import SQLiteNIO public final class SQLiteDatabaseProvider: DatabaseProvider { + public var type: DatabaseType { .sqlite } + /// The connection pool from which to make connections to the /// database with. public let pool: EventLoopGroupConnectionPool @@ -42,6 +44,8 @@ public final class SQLiteDatabaseProvider: DatabaseProvider { } extension SQLiteConnection: DatabaseProvider, ConnectionPoolItem { + public var type: DatabaseType { .sqlite } + public func query(_ sql: String, parameters: [SQLValue]) async throws -> [SQLRow] { let parameters = parameters.map(SQLiteData.init) return try await query(sql, parameters).get().map(\._row) From 8fe65fa179d0874132a99dcc9c3ad691c4735f09 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 3 Jun 2024 17:05:38 -0700 Subject: [PATCH 09/55] add resource postgres support --- Alchemy/AlchemyX/ResourceMigration.swift | 146 ++++++++---------- .../Postgres/Postgres+SQLValue.swift | 2 +- Alchemy/Database/SQL/SQLValue.swift | 4 +- 3 files changed, 67 insertions(+), 85 deletions(-) diff --git a/Alchemy/AlchemyX/ResourceMigration.swift b/Alchemy/AlchemyX/ResourceMigration.swift index 5a4ed5eb..a8395a1b 100644 --- a/Alchemy/AlchemyX/ResourceMigration.swift +++ b/Alchemy/AlchemyX/ResourceMigration.swift @@ -1,12 +1,6 @@ import AlchemyX import Collections -extension Resource { - static var table: String { - "\(Self.self)".lowercased().pluralized - } -} - public struct ResourceMigration: Migration { public var name: String { let type = KeyMapping.snakeCase.encode("\(R.self)") @@ -21,9 +15,9 @@ public struct ResourceMigration: Migration { if try await db.hasTable(table) { // add new and drop old keys - let tableSchema = try await db.schema(for: table) - let adds = resourceSchema.keys.subtracting(tableSchema.keys) - let drops = tableSchema.keys.subtracting(resourceSchema.keys) + let columns = OrderedSet(try await db.columns(of: table)) + let adds = resourceSchema.keys.subtracting(columns) + let drops = columns.subtracting(resourceSchema.keys) Log.info("Adding \(adds) and dropping \(drops) from Resource \(R.self)") @@ -56,6 +50,58 @@ public struct ResourceMigration: Migration { } } +extension Resource { + fileprivate static var table: String { + "\(Self.self)".lowercased().pluralized + } + + fileprivate static func schema(keyMapping: KeyMapping) -> OrderedDictionary { + OrderedDictionary( + fields.map { _, field in + (keyMapping.encode(field.name), field) + }, + uniquingKeysWith: { a, _ in a } + ) + } +} + +extension ResourceField { + fileprivate func columnType() -> ColumnType { + let type = (type as? AnyOptional.Type)?.wrappedType ?? type + if type == String.self { + return .string(.unlimited) + } else if type == Int.self { + return name == "id" ? .increments : .bigInt + } else if type == Double.self { + return .double + } else if type == Bool.self { + return .bool + } else if type == Date.self { + return .date + } else if type == UUID.self { + return .uuid + } else if type is Encodable.Type && type is Decodable.Type { + return .json + } else { + preconditionFailure("unable to convert type \(type) to an SQL column type, try using a Codable type.") + } + } + + fileprivate var isOptional: Bool { + (type as? AnyOptional.Type) != nil + } +} + +private protocol AnyOptional { + static var wrappedType: Any.Type { get } +} + +extension Optional: AnyOptional { + static fileprivate var wrappedType: Any.Type { + Wrapped.self + } +} + extension CreateColumnBuilder { @discardableResult func `default`(any: Any?) -> Self { guard let any else { return self } @@ -127,83 +173,19 @@ extension CreateColumnBuilder { } extension Database { - fileprivate func schema(for table: String) async throws -> OrderedDictionary { + fileprivate func columns(of table: String) async throws -> [String] { switch type { case .sqlite: - let rows = try await raw("PRAGMA table_info(\(table))") - return OrderedDictionary( - try rows.map { - let name = try $0.require("name").string() - let typeString = try $0.require("type").string() - guard let type = SQLiteType.parse(typeString) else { - throw DatabaseError("Unable to decode SQLite type \(typeString)") - } - - return (name, type) - }, - uniquingKeysWith: { a, _ in a } - ) + try await raw("PRAGMA table_info(\(table))") + .map { try $0.require("name").string() } + case .postgres: + try await self.table("information_schema.columns") + .select("column_name", "data_type") + .where("table_name" == table) + .get() + .map { try $0.require("column_name").string() } default: preconditionFailure("pulling schemas isn't supported on \(type.name) yet") } } - - enum SQLiteType: String { - case null = "NULL" - case integer = "INTEGER" - case real = "REAL" - case text = "TEXT" - case blob = "BLOB" - case numeric = "NUMERIC" - - static func parse(_ sqliteType: String) -> SQLiteType? { - switch sqliteType.lowercased() { - case "double": .real - case "bigint": .integer - case "blob": .blob - case "datetime": .numeric - case "int": .integer - case "integer": .integer - case "text": .text - case "varchar": .text - case "null": .null - case "real": .real - case "numeric": .numeric - default: nil - } - } - } -} - -extension Resource { - fileprivate static func schema(keyMapping: KeyMapping) -> OrderedDictionary { - OrderedDictionary( - fields.map { _, field in - (keyMapping.encode(field.name), field) - }, - uniquingKeysWith: { a, _ in a } - ) - } -} - -extension ResourceField { - fileprivate func columnType() -> ColumnType { - if type == String.self { - .string(.unlimited) - } else if type == Int.self { - name == "id" ? .increments : .bigInt - } else if type == Double.self { - .double - } else if type == Bool.self { - .bool - } else if type == Date.self { - .date - } else if type == UUID.self { - .uuid - } else if type is Encodable.Type && type is Decodable.Type { - .json - } else { - preconditionFailure("unable to convert type \(type) to an SQL column type, try using a Codable type") - } - } } diff --git a/Alchemy/Database/Providers/Postgres/Postgres+SQLValue.swift b/Alchemy/Database/Providers/Postgres/Postgres+SQLValue.swift index def80b5a..94724680 100644 --- a/Alchemy/Database/Providers/Postgres/Postgres+SQLValue.swift +++ b/Alchemy/Database/Providers/Postgres/Postgres+SQLValue.swift @@ -56,7 +56,7 @@ extension PostgresCell: SQLValueConvertible { return (try? .int(decode(Int.self))) ?? .null case .bool: return (try? .bool(decode(Bool.self))) ?? .null - case .varchar, .text: + case .varchar, .text, .name: return (try? .string(decode(String.self))) ?? .null case .date, .timestamptz, .timestamp: return (try? .date(decode(Date.self))) ?? .null diff --git a/Alchemy/Database/SQL/SQLValue.swift b/Alchemy/Database/SQL/SQLValue.swift index 6c092d00..544c2c51 100644 --- a/Alchemy/Database/SQL/SQLValue.swift +++ b/Alchemy/Database/SQL/SQLValue.swift @@ -86,10 +86,10 @@ public enum SQLValue: Hashable, CustomStringConvertible { return value.uuidString case .json(let bytes): return bytes.string + case .bytes(let bytes): + return bytes.string case .null: throw nullError(columnName) - default: - throw typeError("String", columnName: columnName) } } From 87eb6b3e9ae2a5d5b5acb448b11df30ef97b2b7b Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 3 Jun 2024 17:39:25 -0700 Subject: [PATCH 10/55] cleanup --- ...igration.swift => Database+Resource.swift} | 41 ++++++++++--------- Alchemy/AlchemyX/Router+Resource.swift | 21 ++++++++++ .../Migrations/Database+Migration.swift | 2 +- .../Extensions/String+Utilities.swift | 6 +++ 4 files changed, 49 insertions(+), 21 deletions(-) rename Alchemy/AlchemyX/{ResourceMigration.swift => Database+Resource.swift} (85%) diff --git a/Alchemy/AlchemyX/ResourceMigration.swift b/Alchemy/AlchemyX/Database+Resource.swift similarity index 85% rename from Alchemy/AlchemyX/ResourceMigration.swift rename to Alchemy/AlchemyX/Database+Resource.swift index a8395a1b..fc4a7cd4 100644 --- a/Alchemy/AlchemyX/ResourceMigration.swift +++ b/Alchemy/AlchemyX/Database+Resource.swift @@ -1,27 +1,32 @@ import AlchemyX import Collections -public struct ResourceMigration: Migration { - public var name: String { - let type = KeyMapping.snakeCase.encode("\(R.self)") - return "resource_migration_\(type)" + "_\(Int(Date().timeIntervalSince1970))" - } - - public init() {} - - public func up(db: Database) async throws { - let table = db.keyMapping.encode(R.table) - let resourceSchema = R.schema(keyMapping: db.keyMapping) - if try await db.hasTable(table) { +extension Database { + /// Adds or alters a database table to match the schema of the Resource. + func updateSchema(_ resource: (some Resource).Type) async throws { + let table = keyMapping.encode(resource.table) + let resourceSchema = resource.schema(keyMapping: keyMapping) + if try await hasTable(table) { // add new and drop old keys - let columns = OrderedSet(try await db.columns(of: table)) + let columns = OrderedSet(try await columns(of: table)) let adds = resourceSchema.keys.subtracting(columns) let drops = columns.subtracting(resourceSchema.keys) - Log.info("Adding \(adds) and dropping \(drops) from Resource \(R.self)") + guard !adds.isEmpty || !drops.isEmpty else { + Log.info("Resource '\(resource)' is up to date.".green) + return + } - try await db.alterTable(table) { + if !adds.isEmpty { + Log.info("Adding \(adds.commaJoined) to resource '\(resource)'...") + } + + if !drops.isEmpty { + Log.info("Dropping \(drops.commaJoined) from '\(resource)'...") + } + + try await alterTable(table) { for add in adds { if let field = resourceSchema[add] { $0.column(add, field: field) @@ -37,17 +42,13 @@ public struct ResourceMigration: Migration { Log.info("Creating table \(table)") // create the table from scratch - try await db.createTable(table) { + try await createTable(table) { for (column, field) in resourceSchema { $0.column(column, field: field) } } } } - - public func down(db: Database) async throws { - // ignore - } } extension Resource { diff --git a/Alchemy/AlchemyX/Router+Resource.swift b/Alchemy/AlchemyX/Router+Resource.swift index f2082667..9e2d6705 100644 --- a/Alchemy/AlchemyX/Router+Resource.swift +++ b/Alchemy/AlchemyX/Router+Resource.swift @@ -1,6 +1,27 @@ import AlchemyX import Pluralize +extension Application { + @discardableResult + public func useResource( + _ type: R.Type, + db: Database = DB, + table: String = "\(R.self)".lowercased().pluralized, + updateTable: Bool = false + ) -> Self where R.Identifier: SQLValueConvertible & LosslessStringConvertible { + use(ResourceController(db: db, tableName: table)) + if updateTable { + Lifecycle.register( + label: "Migrate_\(R.self)", + start: .async { try await db.updateSchema(R.self) }, + shutdown: .none + ) + } + + return self + } +} + extension Router { @discardableResult public func useResource( diff --git a/Alchemy/Database/Migrations/Database+Migration.swift b/Alchemy/Database/Migrations/Database+Migration.swift index 11830033..5f481643 100644 --- a/Alchemy/Database/Migrations/Database+Migration.swift +++ b/Alchemy/Database/Migrations/Database+Migration.swift @@ -37,7 +37,6 @@ extension Database { /// Applies all outstanding migrations to the database in a single /// batch. Migrations are read from `database.migrations`. public func migrate() async throws { - Log.info("Running migrations.") let applied = try await getAppliedMigrations().map(\.name) let toApply = migrations.filter { !applied.contains($0.name) } try await migrate(toApply) @@ -65,6 +64,7 @@ extension Database { return } + Log.info("Running migrations.") let lastBatch = try await getLastBatch() for m in migrations { let start = Date() diff --git a/Alchemy/Utilities/Extensions/String+Utilities.swift b/Alchemy/Utilities/Extensions/String+Utilities.swift index e7ddf63f..5c1df61d 100644 --- a/Alchemy/Utilities/Extensions/String+Utilities.swift +++ b/Alchemy/Utilities/Extensions/String+Utilities.swift @@ -35,3 +35,9 @@ extension String { } } } + +extension Collection { + var commaJoined: String { + joined(separator: ", ") + } +} From ce87dfec953653c902dce56457fdec0f9e7fba4d Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 3 Jun 2024 19:21:03 -0700 Subject: [PATCH 11/55] PapyrusRouter --- Alchemy/AlchemyX/Router+Papyrus.swift | 42 +++++++++++++++++++++++++++ Alchemy/Routing/Router.swift | 4 ++- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 Alchemy/AlchemyX/Router+Papyrus.swift diff --git a/Alchemy/AlchemyX/Router+Papyrus.swift b/Alchemy/AlchemyX/Router+Papyrus.swift new file mode 100644 index 00000000..9cf69fe9 --- /dev/null +++ b/Alchemy/AlchemyX/Router+Papyrus.swift @@ -0,0 +1,42 @@ +import Papyrus + +// MARK: Alchemy + +extension Router { + public func register( + method: String, + path: String, + action: @escaping (RouterRequest) async throws -> RouterResponse + ) { + let method = HTTPMethod(rawValue: method) + on(method, at: path) { + let req = $0.routerRequest() + let res = try await action(req) + return res.response() + } + } +} + +extension RouterResponse { + fileprivate func response() -> Alchemy.Response { + Response( + status: .init(statusCode: status), + headers: .init(headers.map { $0 }), + body: body.map { .data($0) } + ) + } +} + +extension Alchemy.Request { + fileprivate func routerRequest() -> RouterRequest { + RouterRequest( + url: url, + method: method.rawValue, + headers: Dictionary( + headers.map { $0 }, + uniquingKeysWith: { first, _ in first } + ), + body: body?.data + ) + } +} diff --git a/Alchemy/Routing/Router.swift b/Alchemy/Routing/Router.swift index 6aabcef7..290239ea 100644 --- a/Alchemy/Routing/Router.swift +++ b/Alchemy/Routing/Router.swift @@ -1,5 +1,7 @@ +import Papyrus + /// Something that handlers, middleware, and groups can be defined on. -public protocol Router { +public protocol Router: PapyrusRouter { typealias Handler = (Request) async throws -> ResponseConvertible typealias ErrorHandler = (Request, Error) async throws -> ResponseConvertible From b5b42713349a6552644f94c2f3f110693469ec9e Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 3 Jun 2024 19:54:32 -0700 Subject: [PATCH 12/55] cleanup --- Alchemy/AlchemyX/Database+Resource.swift | 2 +- Alchemy/AlchemyX/Router+Resource.swift | 4 +++- Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift | 1 - Package.swift | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Alchemy/AlchemyX/Database+Resource.swift b/Alchemy/AlchemyX/Database+Resource.swift index fc4a7cd4..b64ea2f8 100644 --- a/Alchemy/AlchemyX/Database+Resource.swift +++ b/Alchemy/AlchemyX/Database+Resource.swift @@ -11,7 +11,7 @@ extension Database { // add new and drop old keys let columns = OrderedSet(try await columns(of: table)) let adds = resourceSchema.keys.subtracting(columns) - let drops = columns.subtracting(resourceSchema.keys) + let drops = type == .sqlite ? [] : columns.subtracting(resourceSchema.keys) guard !adds.isEmpty || !drops.isEmpty else { Log.info("Resource '\(resource)' is up to date.".green) diff --git a/Alchemy/AlchemyX/Router+Resource.swift b/Alchemy/AlchemyX/Router+Resource.swift index 9e2d6705..f8dec743 100644 --- a/Alchemy/AlchemyX/Router+Resource.swift +++ b/Alchemy/AlchemyX/Router+Resource.swift @@ -118,6 +118,7 @@ private struct ResourceController: Controller extension Query { fileprivate func filter(_ filter: QueryParameters.Filter, keyMapping: KeyMapping) -> Self { let op: SQLWhere.Operator = switch filter.op { + case .contains: .like case .equals: .equals case .greaterThan: .greaterThan case .greaterThanEquals: .greaterThanOrEqualTo @@ -127,7 +128,8 @@ extension Query { } let field = keyMapping.encode(filter.field) - return `where`(field, op, filter.value) + let value = filter.op == .contains ? "%\(filter.value)%" : filter.value + return `where`(field, op, value) } fileprivate func sort(_ sort: QueryParameters.Sort, keyMapping: KeyMapping) -> Self { diff --git a/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift b/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift index 4be12fbf..ff6222bd 100644 --- a/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift +++ b/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift @@ -1,7 +1,6 @@ import AsyncKit import NIOSSL import MySQLNIO -@_implementationOnly import NIOPosix // for inet_pton() public final class MySQLDatabaseProvider: DatabaseProvider { public var type: DatabaseType { .mysql } diff --git a/Package.swift b/Package.swift index 5a803da8..0ea8f3c3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.8 +// swift-tools-version:5.10 import PackageDescription let package = Package( From 6a5a5052f014f3b8747179cf1896402f80eb3faf Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 5 Jun 2024 13:43:18 -0700 Subject: [PATCH 13/55] auth and access control --- Alchemy/AlchemyX/Application+AlchemyX.swift | 123 ++++++++++++++++++++ Alchemy/AlchemyX/Database+Resource.swift | 14 +-- Alchemy/AlchemyX/Router+Papyrus.swift | 18 ++- Alchemy/AlchemyX/Router+Resource.swift | 27 ++++- Alchemy/Auth/BasicAuthable.swift | 8 +- Alchemy/Hashing/Hasher.swift | 12 +- 6 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 Alchemy/AlchemyX/Application+AlchemyX.swift diff --git a/Alchemy/AlchemyX/Application+AlchemyX.swift b/Alchemy/AlchemyX/Application+AlchemyX.swift new file mode 100644 index 00000000..630b19ec --- /dev/null +++ b/Alchemy/AlchemyX/Application+AlchemyX.swift @@ -0,0 +1,123 @@ +import AlchemyX + +extension Application { + @discardableResult + public func useAlchemyX(db: Database = DB) -> Self { + // 1. users table + + db.migrations.append(UserMigration()) + db.migrations.append(TokenMigration()) + + // 2. users endpoint + + return use(AuthController()) + } +} + +private struct AuthController: Controller, AuthAPI { + func route(_ router: Router) { + registerHandlers(on: router) + } + + func signUp(email: String, password: String) async throws -> AuthResponse { + let password = try await Hash.make(password) + let user = try await User(email: email, password: password).insertReturn() + let token = try await Token(userId: user.id()).insertReturn() + return .init(token: token.value, user: user.dto) + } + + func signIn(email: String, password: String) async throws -> AuthResponse { + guard let user = try await User.firstWhere("email" == email) else { + throw HTTPError(.notFound) + } + + guard try await Hash.verify(password, hash: user.password) else { + throw HTTPError(.unauthorized) + } + + let token = try await Token(userId: user.id()).insertReturn() + return .init(token: token.value, user: user.dto) + } + + func signOut() async throws { + try await token.delete() + } + + func getUser() async throws -> AlchemyX.User { + try user.dto + } + + func updateUser(email: String?, phone: String?, password: String?) async throws -> AlchemyX.User { + var user = try user + if let email { user.email = email } + if let phone { user.phone = phone } + if let password { user.password = try await Hash.make(password) } + return try await user.save().dto + } +} + +extension Controller { + var req: Request { .current } + fileprivate var user: User { get throws { try req.get() } } + fileprivate var token: Token { get throws { try req.get() } } +} + +struct Token: Model, Codable, TokenAuthable { + typealias Authorizes = User + + var id: PK = .new + var value: String = UUID().uuidString + let userId: UUID + + var user: BelongsTo { + belongsTo() + } +} + +struct User: Model, Codable { + var id: PK = .new + var email: String + var password: String + var phone: String? + + var tokens: HasMany { + hasMany() + } + + var dto: AlchemyX.User { + AlchemyX.User( + id: id(), + email: email, + phone: phone + ) + } +} + +struct TokenMigration: Migration { + func up(db: Database) async throws { + try await db.createTable("tokens") { + $0.uuid("id").primary() + $0.string("value").notNull() + $0.uuid("user_id").references("id", on: "users").notNull() + } + } + + func down(db: Database) async throws { + try await db.dropTable("tokens") + } +} + +struct UserMigration: Migration { + func up(db: Database) async throws { + try await db.createTable("users") { + $0.uuid("id").primary() + $0.string("email").notNull() + $0.string("password").notNull() + $0.string("phone") + } + } + + func down(db: Database) async throws { + try await db.dropTable("users") + } +} diff --git a/Alchemy/AlchemyX/Database+Resource.swift b/Alchemy/AlchemyX/Database+Resource.swift index b64ea2f8..2efe7502 100644 --- a/Alchemy/AlchemyX/Database+Resource.swift +++ b/Alchemy/AlchemyX/Database+Resource.swift @@ -7,8 +7,6 @@ extension Database { let table = keyMapping.encode(resource.table) let resourceSchema = resource.schema(keyMapping: keyMapping) if try await hasTable(table) { - - // add new and drop old keys let columns = OrderedSet(try await columns(of: table)) let adds = resourceSchema.keys.subtracting(columns) let drops = type == .sqlite ? [] : columns.subtracting(resourceSchema.keys) @@ -38,10 +36,7 @@ extension Database { } } } else { - Log.info("Creating table \(table)") - - // create the table from scratch try await createTable(table) { for (column, field) in resourceSchema { $0.column(column, field: field) @@ -58,9 +53,8 @@ extension Resource { fileprivate static func schema(keyMapping: KeyMapping) -> OrderedDictionary { OrderedDictionary( - fields.map { _, field in - (keyMapping.encode(field.name), field) - }, + (fields.values + [.userId]) + .map { (keyMapping.encode($0.name), $0) }, uniquingKeysWith: { a, _ in a } ) } @@ -91,6 +85,10 @@ extension ResourceField { fileprivate var isOptional: Bool { (type as? AnyOptional.Type) != nil } + + fileprivate static var userId: ResourceField { + .init("userId", type: UUID.self) + } } private protocol AnyOptional { diff --git a/Alchemy/AlchemyX/Router+Papyrus.swift b/Alchemy/AlchemyX/Router+Papyrus.swift index 9cf69fe9..78c94d6e 100644 --- a/Alchemy/AlchemyX/Router+Papyrus.swift +++ b/Alchemy/AlchemyX/Router+Papyrus.swift @@ -9,14 +9,24 @@ extension Router { action: @escaping (RouterRequest) async throws -> RouterResponse ) { let method = HTTPMethod(rawValue: method) - on(method, at: path) { - let req = $0.routerRequest() - let res = try await action(req) - return res.response() + on(method, at: path) { req in + try await Request.$current + .withValue(req) { + try await action(req.routerRequest()) + } + .response() } } } +extension Request { + /// The current request. This can only be accessed inside of a route + /// handler. + @TaskLocal static var current: Request = { + preconditionFailure("`Request.current` can only be accessed inside of a route handler task") + }() +} + extension RouterResponse { fileprivate func response() -> Alchemy.Response { Response( diff --git a/Alchemy/AlchemyX/Router+Resource.swift b/Alchemy/AlchemyX/Router+Resource.swift index f8dec743..749bfb33 100644 --- a/Alchemy/AlchemyX/Router+Resource.swift +++ b/Alchemy/AlchemyX/Router+Resource.swift @@ -45,6 +45,7 @@ private struct ResourceController: Controller public func route(_ router: Router) { router + .use(Token.tokenAuthMiddleware()) .post(R.path + "/create", use: create) .post(R.path, use: getAll) .get(R.path + "/:id", use: getOne) @@ -64,7 +65,10 @@ private struct ResourceController: Controller } } - return try await query.get().decodeEach(keyMapping: db.keyMapping) + return try await query + .ownedBy(req.user) + .get() + .decodeEach(keyMapping: db.keyMapping) } private func getOne(req: Request) async throws -> R { @@ -78,7 +82,11 @@ private struct ResourceController: Controller private func create(req: Request) async throws -> R { let resource = try req.decode(R.self) - return try await table.insertReturn(resource).decode(keyMapping: db.keyMapping) + var fields = try resource.sqlFields() + fields["user_id"] = try SQLValue.uuid(req.user.id()) + return try await table + .insertReturn(fields) + .decode(keyMapping: db.keyMapping) } private func update(req: Request) async throws -> R { @@ -110,8 +118,8 @@ private struct ResourceController: Controller try await model(id).delete() } - private func model(_ id: R.Identifier?) -> Query { - table.where("id" == id) + private func model(_ id: R.Identifier?) throws -> Query { + try table.where("id" == id).ownedBy(req.user) } } @@ -137,3 +145,14 @@ extension Query { return orderBy(field, direction: sort.ascending ? .asc : .desc) } } + +extension Request { + fileprivate var user: User { get throws { try get() } } +} + + +extension Query { + fileprivate func ownedBy(_ user: User) throws -> Self { + return `where`("user_id" == user.id()) + } +} diff --git a/Alchemy/Auth/BasicAuthable.swift b/Alchemy/Auth/BasicAuthable.swift index c9aaf31a..fd58925d 100644 --- a/Alchemy/Auth/BasicAuthable.swift +++ b/Alchemy/Auth/BasicAuthable.swift @@ -50,7 +50,7 @@ public protocol BasicAuthable: Model { /// Technically doesn't need to be a hashed value if /// `passwordHashKeyString` points to an unhashed value, but /// that wouldn't be very secure, would it? - static func verify(password: String, passwordHash: String) throws -> Bool + static func verify(password: String, passwordHash: String) async throws -> Bool } extension BasicAuthable { @@ -67,8 +67,8 @@ extension BasicAuthable { /// Rune model. /// - Returns: A `Bool` indicating if `password` matched /// `passwordHash`. - public static func verify(password: String, passwordHash: String) throws -> Bool { - try Hash.verify(password, hash: passwordHash) + public static func verify(password: String, passwordHash: String) async throws -> Bool { + try await Hash.verify(password, hash: passwordHash) } /// A `Middleware` configured to validate the @@ -106,7 +106,7 @@ extension BasicAuthable { throw DatabaseError("Missing column \(passwordKeyString) on row of type \(name(of: Self.self))") } - guard try verify(password: password, passwordHash: passwordHash) else { + guard try await verify(password: password, passwordHash: passwordHash) else { throw error } diff --git a/Alchemy/Hashing/Hasher.swift b/Alchemy/Hashing/Hasher.swift index 9b985e1b..c7770274 100644 --- a/Alchemy/Hashing/Hasher.swift +++ b/Alchemy/Hashing/Hasher.swift @@ -7,21 +7,21 @@ public struct Hasher: Service { self.algorithm = algorithm } - public func make(_ value: String) throws -> String { + public func makeSync(_ value: String) throws -> String { try algorithm.make(value) } - public func verify(_ plaintext: String, hash: String) throws -> Bool { + public func verifySync(_ plaintext: String, hash: String) throws -> Bool { try algorithm.verify(plaintext, hash: hash) } // MARK: Async Support - public func makeAsync(_ value: String) async throws -> String { - try await Thread.run { try make(value) } + public func make(_ value: String) async throws -> String { + try await Thread.run { try makeSync(value) } } - public func verifyAsync(_ plaintext: String, hash: String) async throws -> Bool { - try await Thread.run { try verify(plaintext, hash: hash) } + public func verify(_ plaintext: String, hash: String) async throws -> Bool { + try await Thread.run { try verifySync(plaintext, hash: hash) } } } From c4d72be3da53f726c0da7ee2708499a376c6406d Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 11 Jun 2024 22:48:06 -0700 Subject: [PATCH 14/55] plugins and such --- Alchemy/AlchemyMacros.swift | 2 + Alchemy/HTTP/Application+HTTPUpgrades.swift | 4 +- .../Coding/GenericDecoderDelegate.swift | 2 +- .../HTTP/{ => Middleware}/Middleware.swift | 0 AlchemyPlugin/Sources/AlchemyPlugin.swift | 33 +++ AlchemyPlugin/Sources/JobMacro.swift | 27 ++ AlchemyPlugin/Sources/ModelMacro.swift | 12 + .../Sources/Utilities/Declaration.swift | 275 ++++++++++++++++++ .../Sources/Utilities/String+Utilities.swift | 6 + Example/App.swift | 5 + Package.swift | 42 ++- 11 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 Alchemy/AlchemyMacros.swift rename Alchemy/HTTP/{ => Middleware}/Middleware.swift (100%) create mode 100644 AlchemyPlugin/Sources/AlchemyPlugin.swift create mode 100644 AlchemyPlugin/Sources/JobMacro.swift create mode 100644 AlchemyPlugin/Sources/ModelMacro.swift create mode 100644 AlchemyPlugin/Sources/Utilities/Declaration.swift create mode 100644 AlchemyPlugin/Sources/Utilities/String+Utilities.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift new file mode 100644 index 00000000..86bf3226 --- /dev/null +++ b/Alchemy/AlchemyMacros.swift @@ -0,0 +1,2 @@ +@attached(peer, names: arbitrary) +public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") diff --git a/Alchemy/HTTP/Application+HTTPUpgrades.swift b/Alchemy/HTTP/Application+HTTPUpgrades.swift index 82a25064..4986fdc7 100644 --- a/Alchemy/HTTP/Application+HTTPUpgrades.swift +++ b/Alchemy/HTTP/Application+HTTPUpgrades.swift @@ -20,8 +20,8 @@ extension Application { /// Use HTTP/2 when serving, over TLS with the given tls config. /// /// - Parameter tlsConfig: A raw NIO `TLSConfiguration` to use. - public func useHTTP2(tlsConfig: TLSConfiguration, idleReadTimeout: TimeAmount = .seconds(20)) throws { - try server.addHTTP2Upgrade(tlsConfiguration: tlsConfig, idleReadTimeout: idleReadTimeout) + public func useHTTP2(tlsConfig: TLSConfiguration) throws { + try server.addHTTP2Upgrade(tlsConfiguration: tlsConfig) } // MARK: HTTPS diff --git a/Alchemy/HTTP/Content/Coding/GenericDecoderDelegate.swift b/Alchemy/HTTP/Content/Coding/GenericDecoderDelegate.swift index b73d3e46..c318d895 100644 --- a/Alchemy/HTTP/Content/Coding/GenericDecoderDelegate.swift +++ b/Alchemy/HTTP/Content/Coding/GenericDecoderDelegate.swift @@ -1,7 +1,7 @@ protocol GenericDecoderDelegate { var allKeys: [String] { get } - // MARK: Primatives + // MARK: Primitives func decodeString(for key: CodingKey?) throws -> String func decodeDouble(for key: CodingKey?) throws -> Double diff --git a/Alchemy/HTTP/Middleware.swift b/Alchemy/HTTP/Middleware/Middleware.swift similarity index 100% rename from Alchemy/HTTP/Middleware.swift rename to Alchemy/HTTP/Middleware/Middleware.swift diff --git a/AlchemyPlugin/Sources/AlchemyPlugin.swift b/AlchemyPlugin/Sources/AlchemyPlugin.swift new file mode 100644 index 00000000..8de56ea0 --- /dev/null +++ b/AlchemyPlugin/Sources/AlchemyPlugin.swift @@ -0,0 +1,33 @@ +#if canImport(SwiftCompilerPlugin) + +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct AlchemyPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + JobMacro.self, + ModelMacro.self, + ] +} + +#endif + +/* + + # Routes + + - @Routes at top level searches for REST annotated functions + - constructs + + # Jobs + + # Model + + + + */ +// @Model +// @Routes +// @Application +// @Controller diff --git a/AlchemyPlugin/Sources/JobMacro.swift b/AlchemyPlugin/Sources/JobMacro.swift new file mode 100644 index 00000000..418c612f --- /dev/null +++ b/AlchemyPlugin/Sources/JobMacro.swift @@ -0,0 +1,27 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +struct JobMacro: PeerMacro { + static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let name = declaration.as(FunctionDeclSyntax.self)?.name.text else { + fatalError("function only") + } + + return [ + Declaration("struct \(name.capitalizeFirst)Job: Job, Codable") { + Declaration("func handle(context: Context) async throws") { + "print(\"hello from job\")" + } + }, + + Declaration("func $\(name)() async throws") { + "try await \(name.capitalizeFirst)Job().dispatch()" + }, + ] + .map { $0.declSyntax() } + } +} diff --git a/AlchemyPlugin/Sources/ModelMacro.swift b/AlchemyPlugin/Sources/ModelMacro.swift new file mode 100644 index 00000000..884db41b --- /dev/null +++ b/AlchemyPlugin/Sources/ModelMacro.swift @@ -0,0 +1,12 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +struct ModelMacro: PeerMacro { + static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} diff --git a/AlchemyPlugin/Sources/Utilities/Declaration.swift b/AlchemyPlugin/Sources/Utilities/Declaration.swift new file mode 100644 index 00000000..f3259e99 --- /dev/null +++ b/AlchemyPlugin/Sources/Utilities/Declaration.swift @@ -0,0 +1,275 @@ +import SwiftSyntax + +struct Declaration: ExpressibleByStringLiteral { + var text: String + let closureParameters: String? + /// Declarations inside a closure following `text`. + let nested: [Declaration]? + + init(stringLiteral value: String) { + self.init(value) + } + + init(_ text: String, _ closureParameters: String? = nil, nested: [Declaration]? = nil) { + self.text = text + self.closureParameters = closureParameters + self.nested = nested + } + + init( + _ text: String, + _ closureParameters: String? = nil, + @DeclarationsBuilder nested: () throws -> [Declaration] + ) rethrows { + self.text = text + self.closureParameters = closureParameters + self.nested = try nested() + } + + func formattedString() -> String { + guard let nested else { + return text + } + + let nestedOrdered = isType ? nested.organized() : nested + let nestedFormatted = nestedOrdered + .map { declaration in + declaration + .formattedString() + .replacingOccurrences(of: "\n", with: "\n\t") + } + + let closureParamterText = closureParameters.map { " \($0) in" } ?? "" + let nestedText = nestedFormatted.joined(separator: "\n\t") + return """ + \(text) {\(closureParamterText) + \t\(nestedText) + } + """ + // Using \t screws up macro syntax highlighting + .replacingOccurrences(of: "\t", with: " ") + } + + // MARK: Access Levels + + func access(_ level: String?) -> Declaration { + guard let level else { + return self + } + + var copy = self + copy.text = "\(level) \(text)" + return copy + } + + func `private`() -> Declaration { + access("private") + } + + func `public`() -> Declaration { + access("public") + } + + func `internal`() -> Declaration { + access("internal") + } + + func `package`() -> Declaration { + access("package") + } + + func `fileprivate`() -> Declaration { + access("fileprivate") + } + + // MARK: SwiftSyntax conversion + + func declSyntax() -> DeclSyntax { + DeclSyntax(stringLiteral: formattedString()) + } + + func extensionDeclSyntax() throws -> ExtensionDeclSyntax { + try ExtensionDeclSyntax( + .init(stringLiteral: formattedString()) + ) + } +} + +extension [Declaration] { + /// Reorders declarations in the following manner: + /// + /// 1. Properties (public -> private) + /// 2. initializers (public -> private) + /// 3. functions (public -> private) + /// + /// Properties have no newlines between them, functions have a single, blank + /// newline between them. + fileprivate func organized() -> [Declaration] { + self + .sorted() + .spaced() + } + + private func sorted() -> [Declaration] { + sorted { $0.sortValue < $1.sortValue } + } + + private func spaced() -> [Declaration] { + var declarations: [Declaration] = [] + for declaration in self { + defer { declarations.append(declaration) } + + guard let last = declarations.last else { + continue + } + + if last.isType { + declarations.append(.newline) + } else if last.isProperty && !declaration.isProperty { + declarations.append(.newline) + } else if last.isFunction || last.isInit { + declarations.append(.newline) + } + } + + return declarations + } +} + +extension Declaration { + fileprivate var sortValue: Int { + if isType { + 0 + accessSortValue + } else if isProperty { + 10 + accessSortValue + } else if isInit { + 20 + accessSortValue + } else if !isStaticFunction { + 40 + accessSortValue + } else { + 50 + accessSortValue + } + } + + var accessSortValue: Int { + if text.contains("open") { + 0 + } else if text.contains("public") { + 1 + } else if text.contains("package") { + 2 + } else if text.contains("fileprivate") { + 4 + } else if text.contains("private") { + 5 + } else { + 3 // internal (either explicit or implicit) + } + } + + fileprivate var isType: Bool { + text.contains("enum") || + text.contains("struct") || + text.contains("protocol") || + text.contains("actor") || + text.contains("class") || + text.contains("typealias") + } + + fileprivate var isProperty: Bool { + text.contains("let") || text.contains("var") + } + + fileprivate var isStaticFunction: Bool { + (text.contains("static") || text.contains("class")) && isFunction + } + + fileprivate var isFunction: Bool { + text.contains("func") && text.contains("(") && text.contains(")") + } + + fileprivate var isInit: Bool { + text.contains("init(") + } +} + +extension Declaration { + static let newline: Declaration = "" +} + +@resultBuilder +struct DeclarationsBuilder { + static func buildBlock(_ components: DeclarationBuilderBlock...) -> [Declaration] { + components.flatMap(\.declarations) + } + + // MARK: Declaration literals + + static func buildExpression(_ expression: Declaration) -> Declaration { + expression + } + + static func buildExpression(_ expression: [Declaration]) -> [Declaration] { + expression + } + + static func buildExpression(_ expression: [Declaration]?) -> [Declaration] { + expression ?? [] + } + + // MARK: `String` literals + + static func buildExpression(_ expression: String) -> Declaration { + Declaration(expression) + } + + static func buildExpression(_ expression: String?) -> [Declaration] { + expression.map { [Declaration($0)] } ?? [] + } + + static func buildExpression(_ expression: [String]) -> [Declaration] { + expression.map { Declaration($0) } + } + + // MARK: `for` + + static func buildArray(_ components: [String]) -> [Declaration] { + components.map { Declaration($0) } + } + + static func buildArray(_ components: [Declaration]) -> [Declaration] { + components + } + + static func buildArray(_ components: [[Declaration]]) -> [Declaration] { + components.flatMap { $0 } + } + + // MARK: `if` + + static func buildEither(first components: [Declaration]) -> [Declaration] { + components + } + + static func buildEither(second components: [Declaration]) -> [Declaration] { + components + } + + // MARK: `Optional` + + static func buildOptional(_ component: [Declaration]?) -> [Declaration] { + component ?? [] + } +} + +protocol DeclarationBuilderBlock { + var declarations: [Declaration] { get } +} + +extension Declaration: DeclarationBuilderBlock { + var declarations: [Declaration] { [self] } +} + +extension [Declaration]: DeclarationBuilderBlock { + var declarations: [Declaration] { self } +} diff --git a/AlchemyPlugin/Sources/Utilities/String+Utilities.swift b/AlchemyPlugin/Sources/Utilities/String+Utilities.swift new file mode 100644 index 00000000..bf1f5cf7 --- /dev/null +++ b/AlchemyPlugin/Sources/Utilities/String+Utilities.swift @@ -0,0 +1,6 @@ +extension String { + // Need this since `capitalized` lowercases everything else. + var capitalizeFirst: String { + prefix(1).capitalized + dropFirst() + } +} diff --git a/Example/App.swift b/Example/App.swift index 1704157c..6ca75724 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -51,6 +51,11 @@ struct App: Application { func get200(req: Request) {} func get400(req: Request) throws { throw HTTPError(.badRequest) } func get500(req: Request) throws { throw HTTPError(.internalServerError) } + + @Job + func expensive() { + + } } /* diff --git a/Package.swift b/Package.swift index 0ea8f3c3..5518e7a3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,6 @@ // swift-tools-version:5.10 + +import CompilerPluginSupport import PackageDescription let package = Package( @@ -19,6 +21,8 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "3.0.0"), + .package(url: "https://github.com/apple/swift-syntax", from: "510.0.0"), + .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.1.0"), .package(url: "https://github.com/vapor/postgres-nio.git", from: "1.17.0"), .package(url: "https://github.com/vapor/mysql-nio.git", from: "1.0.0"), .package(url: "https://github.com/vapor/sqlite-nio.git", from: "1.0.0"), @@ -32,6 +36,9 @@ let package = Package( .package(url: "https://github.com/vadymmarkov/Fakery", from: "5.0.0"), ], targets: [ + + // MARK: Demo + .executableTarget( name: "AlchemyExample", dependencies: [ @@ -39,6 +46,9 @@ let package = Package( ], path: "Example" ), + + // MARK: Libraries + .target( name: "Alchemy", dependencies: [ @@ -72,10 +82,14 @@ let package = Package( /// Internal dependencies "AlchemyC", + "AlchemyPlugin", ], path: "Alchemy" ), - .target(name: "AlchemyC", path: "AlchemyC"), + .target( + name: "AlchemyC", + path: "AlchemyC" + ), .target( name: "AlchemyTest", dependencies: [ @@ -83,6 +97,9 @@ let package = Package( ], path: "AlchemyTest" ), + + // MARK: Tests + .testTarget( name: "Tests", dependencies: [ @@ -91,5 +108,28 @@ let package = Package( ], path: "Tests" ), + + // MARK: Plugin + + .macro( + name: "AlchemyPlugin", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftOperators", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "SwiftParserDiagnostics", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + path: "AlchemyPlugin/Sources" + ), + .testTarget( + name: "AlchemyPluginTests", + dependencies: [ + "AlchemyPlugin", + .product(name: "MacroTesting", package: "swift-macro-testing"), + ], + path: "AlchemyPlugin/Tests" + ), ] ) From e9e603045ee05e8975e7f96933dae4f2a8ab3518 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 12 Jun 2024 09:07:44 -0700 Subject: [PATCH 15/55] JobMacro --- AlchemyPlugin/Sources/JobMacro.swift | 55 +++++++++++++++++-- .../Utilities/AlchemyPluginError.swift | 11 ++++ .../Sources/Utilities/String+Utilities.swift | 6 ++ Example/App.swift | 4 +- 4 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 AlchemyPlugin/Sources/Utilities/AlchemyPluginError.swift diff --git a/AlchemyPlugin/Sources/JobMacro.swift b/AlchemyPlugin/Sources/JobMacro.swift index 418c612f..4ea0afef 100644 --- a/AlchemyPlugin/Sources/JobMacro.swift +++ b/AlchemyPlugin/Sources/JobMacro.swift @@ -7,21 +7,66 @@ struct JobMacro: PeerMacro { providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard let name = declaration.as(FunctionDeclSyntax.self)?.name.text else { - fatalError("function only") + guard + let function = declaration.as(FunctionDeclSyntax.self), + function.isStatic + else { + throw AlchemyMacroError("@Job can only be applied to static functions") } + let name = function.name.text + let effects = [ + function.isAsync ? "async" : nil, + function.isThrows ? "throws" : nil, + ] + .compactMap { $0 } + + let effectsString = + if effects.isEmpty { + "" + } else { + " \(effects.joined(separator: " "))" + } + return [ Declaration("struct \(name.capitalizeFirst)Job: Job, Codable") { - Declaration("func handle(context: Context) async throws") { - "print(\"hello from job\")" + Declaration("func handle(context: Context) \(effectsString)") { + let name = function.name.text + let expressions = [ + function.isThrows ? "try" : nil, + function.isAsync ? "await" : nil, + ] + .compactMap { $0 } + + let expressionsString = + if expressions.isEmpty { + "" + } else { + "\(expressions.joined(separator: " ")) " + } + + "\(expressionsString)\(name)()" } }, - Declaration("func $\(name)() async throws") { + Declaration("static func $\(name)() async throws") { "try await \(name.capitalizeFirst)Job().dispatch()" }, ] .map { $0.declSyntax() } } } + +extension FunctionDeclSyntax { + fileprivate var isStatic: Bool { + modifiers.map(\.name.text).contains("static") + } + + fileprivate var isAsync: Bool { + signature.effectSpecifiers?.asyncSpecifier != nil + } + + fileprivate var isThrows: Bool { + signature.effectSpecifiers?.throwsSpecifier != nil + } +} diff --git a/AlchemyPlugin/Sources/Utilities/AlchemyPluginError.swift b/AlchemyPlugin/Sources/Utilities/AlchemyPluginError.swift new file mode 100644 index 00000000..a9c8b737 --- /dev/null +++ b/AlchemyPlugin/Sources/Utilities/AlchemyPluginError.swift @@ -0,0 +1,11 @@ +struct AlchemyMacroError: Error, CustomDebugStringConvertible { + let message: String + + init(_ message: String) { + self.message = message + } + + var debugDescription: String { + message + } +} diff --git a/AlchemyPlugin/Sources/Utilities/String+Utilities.swift b/AlchemyPlugin/Sources/Utilities/String+Utilities.swift index bf1f5cf7..a51c2d30 100644 --- a/AlchemyPlugin/Sources/Utilities/String+Utilities.swift +++ b/AlchemyPlugin/Sources/Utilities/String+Utilities.swift @@ -4,3 +4,9 @@ extension String { prefix(1).capitalized + dropFirst() } } + +extension Collection { + var nilIfEmpty: Self? { + isEmpty ? self : nil + } +} diff --git a/Example/App.swift b/Example/App.swift index 6ca75724..51546c5a 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -53,8 +53,8 @@ struct App: Application { func get500(req: Request) throws { throw HTTPError(.internalServerError) } @Job - func expensive() { - + static func expensive() async throws { + print("Hello") } } From 4a2e39bf00819b1832946a5c519b8f2980fb5887 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 12 Jun 2024 09:43:03 -0700 Subject: [PATCH 16/55] ApplicationMacro --- Alchemy/AlchemyMacros.swift | 7 ++ AlchemyPlugin/Sources/AlchemyPlugin.swift | 1 + AlchemyPlugin/Sources/ApplicationMacro.swift | 55 +++++++++ Example/App.swift | 123 +------------------ 4 files changed, 65 insertions(+), 121 deletions(-) create mode 100644 AlchemyPlugin/Sources/ApplicationMacro.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index 86bf3226..be955afa 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -1,2 +1,9 @@ @attached(peer, names: arbitrary) public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") + +@attached(member, names: arbitrary) +public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro") + +@attached(peer) +@attached(extension, conformances: Application) +public macro Application() = #externalMacro(module: "AlchemyPlugin", type: "ApplicationMacro") diff --git a/AlchemyPlugin/Sources/AlchemyPlugin.swift b/AlchemyPlugin/Sources/AlchemyPlugin.swift index 8de56ea0..c5ad1b65 100644 --- a/AlchemyPlugin/Sources/AlchemyPlugin.swift +++ b/AlchemyPlugin/Sources/AlchemyPlugin.swift @@ -8,6 +8,7 @@ struct AlchemyPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ JobMacro.self, ModelMacro.self, + ApplicationMacro.self, ] } diff --git a/AlchemyPlugin/Sources/ApplicationMacro.swift b/AlchemyPlugin/Sources/ApplicationMacro.swift new file mode 100644 index 00000000..9e4ec243 --- /dev/null +++ b/AlchemyPlugin/Sources/ApplicationMacro.swift @@ -0,0 +1,55 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +struct ApplicationMacro: PeerMacro, ExtensionMacro { + + /* + + 1. add @main + 2. add Application + 3. register routes + 4. register jobs? + + */ + + // MARK: PeerMacro + + static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let `struct` = declaration.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("@Application can only be applied to a struct") + } + + return [] + } + + // MARK: ExtensionMacro + + static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard let `struct` = declaration.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("@Application can only be applied to a struct") + } + + return try [ + Declaration("@main extension \(`struct`.name): Application") { + + } + .extensionDeclSyntax() + ] + } +} + +extension StructDeclSyntax { + fileprivate var attributeNames: [String] { + attributes.map(\.trimmedDescription) + } +} diff --git a/Example/App.swift b/Example/App.swift index 51546c5a..1aeb03b9 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,47 +1,7 @@ import Alchemy -/* - - GOAL: use macros to cut down on boilerplate - - Crush: - 0. dependencies / configuration - 1. routing - 2. middleware - 3. validation - 4. SQL access - 5. background work - 6. http - 7. testing - 8. logging / debugging - - Reduce - 1. number of types - - Repetitive Tasks - - validate type and content - - match database models - - fire off jobs - - multiple types verifying and mapping - - Foundational Tasks - - map Request to function input - - map output to Response - - turn request fields into work based on function parameters - - Macros - - CON: Need a top level macro to parse the body. - - CON: Hard to gracefuly override - - PRO: isolated - - PRO: Composable - - PRO: Easy overloads / hint what you want - Closures - - CON: can overload, but a lot of work - - */ - -@main -struct App: Application { +@Application +struct App { func boot() throws { get("/200", use: get200) get("/400", use: get400) @@ -57,82 +17,3 @@ struct App: Application { print("Hello") } } - -/* - -@Routes -struct UserController { - var middlewares: [Middleware] = [ - AuthMiddleware(), - RateLimitMiddleware(), - RequireUserMiddleware(), - SanitizingMiddleware(), - ] - - @GET("/users/:id") - func getUser(user: User) async throws -> Fields { - // fire off background work - // access type from middleware - // perform some arbitrary validation - // hit 3rd party endpoint - throw HTTPError(.notImplemented) - } - - @POST("/users") - func createUser(username: String, password: String) async throws -> Fields { - User(username: username, password: password) - .insertReturn() - .without(\.password) - } - - @PATCH("/users/:id") - func updateUser(user: Int, name: String) async throws -> User { - throw HTTPError(.notImplemented) - } - - // MARK: Generated - - func _route(_ router: Router) { - router - .use(middlewares) - .use(routes) - } - - var routes: [Middleware.Handler] = [ - $createUser, - $getUser, - $updateUser, - ] - - func _createUser(request: Request, next: Middleware.Next) async throws -> Response { - guard request.method == .GET, request.path == "/users" else { - return try await next(request) - } - - let username = try request["username"].stringThrowing - let password = try request["password"].stringThrowing - let fields = createUser(username: username, password: password) - return fields.response() - } -} - -@Model -struct User { - var id: Int? - let username: String - let password: String -} - - */ - -/* - - Can I generate code that will run each time the app starts? - - - register commands - - register macro'd jobs - - register migrations - - register macro'd routes - - register service configuration (change app to class - should solve it) - - */ From 40fe3e8edd12b2accf9fb04964d58f5080052eec Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 12 Jun 2024 11:21:54 -0700 Subject: [PATCH 17/55] Basic --- Alchemy/AlchemyMacros.swift | 13 +- AlchemyPlugin/Sources/ApplicationMacro.swift | 56 +++- AlchemyPlugin/Sources/JobMacro.swift | 6 +- AlchemyPlugin/Sources/Utilities/Routes.swift | 286 +++++++++++++++++++ Example/App.swift | 22 +- 5 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 AlchemyPlugin/Sources/Utilities/Routes.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index be955afa..2e341317 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -5,5 +5,16 @@ public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro") @attached(peer) -@attached(extension, conformances: Application) +@attached( + extension, + conformances: + Application, + RoutesGenerator, + names: + named(addGeneratedRoutes) +) public macro Application() = #externalMacro(module: "AlchemyPlugin", type: "ApplicationMacro") + +public protocol RoutesGenerator { + func addGeneratedRoutes() +} diff --git a/AlchemyPlugin/Sources/ApplicationMacro.swift b/AlchemyPlugin/Sources/ApplicationMacro.swift index 9e4ec243..d01ecd68 100644 --- a/AlchemyPlugin/Sources/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/ApplicationMacro.swift @@ -39,12 +39,43 @@ struct ApplicationMacro: PeerMacro, ExtensionMacro { throw AlchemyMacroError("@Application can only be applied to a struct") } + let routes = try Routes.parse(declaration) + return try [ - Declaration("@main extension \(`struct`.name): Application") { + Declaration("@main extension \(`struct`.name)") {}, + Declaration("extension \(`struct`.name): Application") { - } - .extensionDeclSyntax() + }, + Declaration("extension \(`struct`.name): RoutesGenerator") { + routes.generatedRoutesFunction() + }, ] + .map { try $0.extensionDeclSyntax() } + } +} + +extension Routes { + func generatedRoutesFunction() -> Declaration { + Declaration("func addGeneratedRoutes()") { + for route in routes { + route.handlerExpression() + } + } + } +} + +extension Routes.Route { + func handlerExpression() -> Declaration { + Declaration(method.lowercased() + path.inQuotes.inParentheses, "req") { + let arguments = parameters.map(\.argumentString).joined(separator: ", ").inParentheses + let effectsExpressions = [ + isThrows ? "try" : nil, + isAsync ? "await" : nil, + ].compactMap { $0 } + + let effectsExpressionString = effectsExpressions.isEmpty ? "" : effectsExpressions.joined(separator: " ") + " " + "\(effectsExpressionString)\(name)\(arguments)" + } } } @@ -53,3 +84,22 @@ extension StructDeclSyntax { attributes.map(\.trimmedDescription) } } + +extension EndpointParameter { + fileprivate var argumentString: String { + let argumentLabel = label == "_" ? nil : label ?? name + let label = argumentLabel.map { "\($0): " } ?? "" + + guard type != "Request" else { + return label + name + } + + let prefix = switch kind { + case .field: "body." + case .query: "query." + default: "" + } + return label + prefix + name + } +} + diff --git a/AlchemyPlugin/Sources/JobMacro.swift b/AlchemyPlugin/Sources/JobMacro.swift index 4ea0afef..58bf9f9a 100644 --- a/AlchemyPlugin/Sources/JobMacro.swift +++ b/AlchemyPlugin/Sources/JobMacro.swift @@ -58,15 +58,15 @@ struct JobMacro: PeerMacro { } extension FunctionDeclSyntax { - fileprivate var isStatic: Bool { + var isStatic: Bool { modifiers.map(\.name.text).contains("static") } - fileprivate var isAsync: Bool { + var isAsync: Bool { signature.effectSpecifiers?.asyncSpecifier != nil } - fileprivate var isThrows: Bool { + var isThrows: Bool { signature.effectSpecifiers?.throwsSpecifier != nil } } diff --git a/AlchemyPlugin/Sources/Utilities/Routes.swift b/AlchemyPlugin/Sources/Utilities/Routes.swift new file mode 100644 index 00000000..4d24fe03 --- /dev/null +++ b/AlchemyPlugin/Sources/Utilities/Routes.swift @@ -0,0 +1,286 @@ +import Foundation +import SwiftSyntax + +struct Routes { + struct Route { + /// Attributes to be applied to this endpoint. These take precedence + /// over attributes at the API scope. + let method: String + let path: String + let pathParameters: [String] + /// The name of the function defining this endpoint. + let name: String + let parameters: [EndpointParameter] + let isAsync: Bool + let isThrows: Bool + let responseType: String? + } + + /// The name of the type defining the API. + let name: String + /// Attributes to be applied to every endpoint of this API. + let routes: [Route] +} + +extension Routes { + static func parse(_ decl: some DeclSyntaxProtocol) throws -> Routes { + guard let type = decl.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("@Routes must be applied to structs for now") + } + + return Routes( + name: type.name.text, + routes: try type.functions.compactMap( { try parse($0) }) + ) + } + + private static func parse(_ function: FunctionDeclSyntax) throws -> Routes.Route? { + guard let (method, path, pathParameters) = parseMethodAndPath(function) else { + return nil + } + + return Routes.Route( + method: method, + path: path, + pathParameters: pathParameters, + name: function.functionName, + parameters: try function.parameters.compactMap { + EndpointParameter($0, httpMethod: method, pathParameters: pathParameters) + }.validated(), + isAsync: function.isAsync, + isThrows: function.isThrows, + responseType: function.returnType + ) + } + + private static func parseMethodAndPath( + _ function: FunctionDeclSyntax + ) -> (method: String, path: String, pathParameters: [String])? { + var method, path: String? + for attribute in function.functionAttributes { + if case let .argumentList(list) = attribute.arguments { + let name = attribute.attributeName.trimmedDescription + switch name { + case "GET", "DELETE", "PATCH", "POST", "PUT", "OPTIONS", "HEAD", "TRACE", "CONNECT": + method = name + path = list.first?.expression.description.withoutQuotes + case "HTTP": + method = list.first?.expression.description.withoutQuotes + path = list.dropFirst().first?.expression.description.withoutQuotes + default: + continue + } + } + } + + guard let method, let path else { + return nil + } + + return (method, path, path.papyrusPathParameters) + } +} + +extension Routes.Route { + var functionSignature: String { + let parameters = parameters.map { + let name = [$0.label, $0.name] + .compactMap { $0 } + .joined(separator: " ") + return "\(name): \($0.type)" + } + + let returnType = responseType.map { " -> \($0)" } ?? "" + return parameters.joined(separator: ", ").inParentheses + " async throws" + returnType + } +} + +extension [EndpointParameter] { + fileprivate func validated() throws -> [EndpointParameter] { + let bodies = filter { $0.kind == .body } + let fields = filter { $0.kind == .field } + + guard fields.count == 0 || bodies.count == 0 else { + throw AlchemyMacroError("Can't have Body and Field!") + } + + guard bodies.count <= 1 else { + throw AlchemyMacroError("Can only have one Body!") + } + + return self + } +} + +/// Parsed from function parameters; indicates parts of the request. +struct EndpointParameter { + enum Kind { + case body + case field + case query + case header + case path + } + + let label: String? + let name: String + let type: String + let kind: Kind + + init(_ parameter: FunctionParameterSyntax, httpMethod: String, pathParameters: [String]) { + self.label = parameter.label + self.name = parameter.name + self.type = parameter.typeName + self.kind = + if type.hasPrefix("Path<") { + .path + } else if type.hasPrefix("Body<") { + .body + } else if type.hasPrefix("Header<") { + .header + } else if type.hasPrefix("Field<") { + .field + } else if type.hasPrefix("Query<") { + .query + } else if pathParameters.contains(name) { + // if name matches a path param, infer this belongs in path + .path + } else if ["GET", "HEAD", "DELETE"].contains(httpMethod) { + // if method is GET, HEAD, DELETE, infer query + .query + } else { + // otherwise infer it's a body field + .field + } + } +} + +extension StructDeclSyntax { + var functions: [FunctionDeclSyntax] { + memberBlock + .members + .compactMap { $0.decl.as(FunctionDeclSyntax.self) } + } +} + +extension ProtocolDeclSyntax { + var protocolName: String { + name.text + } + + var access: String? { + modifiers.first?.trimmedDescription + } + + var functions: [FunctionDeclSyntax] { + memberBlock + .members + .compactMap { $0.decl.as(FunctionDeclSyntax.self) } + } + + var protocolAttributes: [AttributeSyntax] { + attributes.compactMap { $0.as(AttributeSyntax.self) } + } +} + +extension FunctionDeclSyntax { + + // MARK: Function effects & attributes + + var functionName: String { + name.text + } + + var effects: [String] { + [signature.effectSpecifiers?.asyncSpecifier, signature.effectSpecifiers?.throwsSpecifier] + .compactMap { $0 } + .map { $0.text } + } + + var parameters: [FunctionParameterSyntax] { + signature + .parameterClause + .parameters + .compactMap { FunctionParameterSyntax($0) } + } + + var functionAttributes: [AttributeSyntax] { + attributes.compactMap { $0.as(AttributeSyntax.self) } + } + + // MARK: Return Data + + var returnsResponse: Bool { + returnType == "Response" + } + + var returnType: String? { + signature.returnClause?.type.trimmedDescription + } + + var returnsVoid: Bool { + guard let returnType else { + return true + } + + return returnType == "Void" + } +} + +extension FunctionParameterSyntax { + var label: String? { + secondName != nil ? firstName.text : nil + } + + var name: String { + (secondName ?? firstName).text + } + + var typeName: String { + trimmed.type.description + } +} + +extension AttributeSyntax { + var name: String { + attributeName.trimmedDescription + } + + var labeledArguments: [(label: String?, value: String)] { + guard case let .argumentList(list) = arguments else { + return [] + } + + return list.map { + ($0.label?.text, $0.expression.description) + } + } +} + +extension String { + var withoutQuotes: String { + filter { $0 != "\"" } + } + + var inQuotes: String { + "\"\(self)\"" + } + + var inParentheses: String { + "(\(self))" + } + + var papyrusPathParameters: [String] { + components(separatedBy: "/").compactMap(\.extractParameter) + } + + private var extractParameter: String? { + if hasPrefix(":") { + String(dropFirst()) + } else if hasPrefix("{") && hasSuffix("}") { + String(dropFirst().dropLast()) + } else { + nil + } + } +} diff --git a/Example/App.swift b/Example/App.swift index 1aeb03b9..ea9ad10e 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,16 +1,26 @@ import Alchemy +import Papyrus @Application struct App { func boot() throws { - get("/200", use: get200) - get("/400", use: get400) - get("/500", use: get500) + addGeneratedRoutes() } - func get200(req: Request) {} - func get400(req: Request) throws { throw HTTPError(.badRequest) } - func get500(req: Request) throws { throw HTTPError(.internalServerError) } + @GET("/200") + func success() { + // + } + + @GET("/400") + func badRequest() throws { + throw HTTPError(.badRequest) + } + + @GET("/500") + func internalServerError() async throws { + throw HTTPError(.internalServerError) + } @Job static func expensive() async throws { From 96bf5c1e0f06ac96c35341c965a2ea7470441a6c Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 12 Jun 2024 11:31:23 -0700 Subject: [PATCH 18/55] auto --- Alchemy/Application/Application.swift | 7 +++++++ Example/App.swift | 5 +---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Alchemy/Application/Application.swift b/Alchemy/Application/Application.swift index d30ad5cf..31434b1f 100644 --- a/Alchemy/Application/Application.swift +++ b/Alchemy/Application/Application.swift @@ -22,6 +22,8 @@ public protocol Application: Router { /// Boots the app's dependencies. Don't override the default for this unless /// you want to prevent default Alchemy services from loading. func bootPlugins() + /// Setup generated routes. + func bootGeneratedRoutes() /// Setup your application here. Called after all services are registered. func boot() throws @@ -70,6 +72,10 @@ public extension Application { } } + func bootGeneratedRoutes() { + (self as? RoutesGenerator)?.addGeneratedRoutes() + } + func boot() throws { // } @@ -95,6 +101,7 @@ public extension Application { func run() async throws { do { bootPlugins() + bootGeneratedRoutes() try boot() try await start() } catch { diff --git a/Example/App.swift b/Example/App.swift index ea9ad10e..54681cf4 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -3,10 +3,7 @@ import Papyrus @Application struct App { - func boot() throws { - addGeneratedRoutes() - } - + @GET("/200") func success() { // From 1daaefa08cb73b9c76939012a42305ee116ceb5a Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 12 Jun 2024 15:58:32 -0700 Subject: [PATCH 19/55] wrap jobs --- Alchemy/Queue/Job.swift | 3 ++ AlchemyPlugin/Sources/JobMacro.swift | 71 ++++++++++++++++------------ Example/App.swift | 9 +++- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/Alchemy/Queue/Job.swift b/Alchemy/Queue/Job.swift index 80c5247a..5162c1bf 100644 --- a/Alchemy/Queue/Job.swift +++ b/Alchemy/Queue/Job.swift @@ -68,6 +68,9 @@ public enum JobRecoveryStrategy: Equatable, Codable { /// The context this job is running in. public struct JobContext { + /// The current context. This will be nil outside of a Job handler. + @TaskLocal public static var current: JobContext? = nil + /// The queue this job was queued on. public let queue: Queue /// The channel this job was queued on. diff --git a/AlchemyPlugin/Sources/JobMacro.swift b/AlchemyPlugin/Sources/JobMacro.swift index 58bf9f9a..f1b192f1 100644 --- a/AlchemyPlugin/Sources/JobMacro.swift +++ b/AlchemyPlugin/Sources/JobMacro.swift @@ -15,42 +15,27 @@ struct JobMacro: PeerMacro { } let name = function.name.text - let effects = [ - function.isAsync ? "async" : nil, - function.isThrows ? "throws" : nil, - ] - .compactMap { $0 } - - let effectsString = - if effects.isEmpty { - "" - } else { - " \(effects.joined(separator: " "))" - } - return [ Declaration("struct \(name.capitalizeFirst)Job: Job, Codable") { - Declaration("func handle(context: Context) \(effectsString)") { - let name = function.name.text - let expressions = [ - function.isThrows ? "try" : nil, - function.isAsync ? "await" : nil, - ] - .compactMap { $0 } - let expressionsString = - if expressions.isEmpty { - "" - } else { - "\(expressions.joined(separator: " ")) " - } + for parameter in function.parameters { + "let \(parameter.name): \(parameter.type)" + } - "\(expressionsString)\(name)()" + Declaration("func handle(context: Context) async throws") { + let name = function.name.text + let prefix = function.callPrefixes.isEmpty ? "" : function.callPrefixes.joined(separator: " ") + " " + """ + try await JobContext.$current + .withValue(context) { + \(prefix)\(name)(\(function.jobPassthroughParameterSyntax)) + } + """ } }, - Declaration("static func $\(name)() async throws") { - "try await \(name.capitalizeFirst)Job().dispatch()" + Declaration("static func $\(name)(\(function.jobParametersSignature)) async throws") { + "try await \(name.capitalizeFirst)Job(\(function.jobPassthroughParameterSyntax)).dispatch()" }, ] .map { $0.declSyntax() } @@ -69,4 +54,32 @@ extension FunctionDeclSyntax { var isThrows: Bool { signature.effectSpecifiers?.throwsSpecifier != nil } + + var jobParametersSignature: String { + parameters.map { + let name = [$0.label, $0.name] + .compactMap { $0 } + .joined(separator: " ") + return "\(name): \($0.type)" + } + .joined(separator: ", ") + } + + var jobPassthroughParameterSyntax: String { + parameters.map { + let name = [$0.label, $0.name] + .compactMap { $0 } + .joined(separator: " ") + return "\(name): \($0.name)" + } + .joined(separator: ", ") + } + + var callPrefixes: [String] { + [ + isThrows ? "try" : nil, + isAsync ? "await" : nil, + ] + .compactMap { $0 } + } } diff --git a/Example/App.swift b/Example/App.swift index 54681cf4..dba353a5 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -19,8 +19,13 @@ struct App { throw HTTPError(.internalServerError) } - @Job - static func expensive() async throws { + @GET("/job") + func job() async throws { + try await App.$expensive(one: "", two: 1) + } + + @Job + static func expensive(one: String, two: Int) async throws { print("Hello") } } From d676f992beefa7eef0a35f24f23b2443fc29795b Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 12 Jun 2024 17:22:55 -0700 Subject: [PATCH 20/55] parameter parsing --- Alchemy/HTTP/Coding/HTTPCoding+FormURL.swift | 2 +- Alchemy/HTTP/Coding/HTTPCoding+JSON.swift | 2 +- .../HTTP/Coding/HTTPCoding+Multipart.swift | 2 +- Alchemy/HTTP/Content/Content.swift | 27 +++++++++++--- .../HTTP/Content/Errors/ContentError.swift | 3 +- Alchemy/HTTP/Protocols/HTTPInspector.swift | 8 ++++ Alchemy/HTTP/Protocols/RequestInspector.swift | 12 ++++++ Alchemy/HTTP/Request.swift | 4 +- AlchemyPlugin/Sources/ApplicationMacro.swift | 30 +++++++++------ AlchemyPlugin/Sources/Utilities/Routes.swift | 2 +- Example/App.swift | 37 ++++++++++++++----- 11 files changed, 96 insertions(+), 33 deletions(-) diff --git a/Alchemy/HTTP/Coding/HTTPCoding+FormURL.swift b/Alchemy/HTTP/Coding/HTTPCoding+FormURL.swift index 941ef33a..c84c36ff 100644 --- a/Alchemy/HTTP/Coding/HTTPCoding+FormURL.swift +++ b/Alchemy/HTTP/Coding/HTTPCoding+FormURL.swift @@ -24,7 +24,7 @@ extension URLEncodedFormDecoder: HTTPDecoder { let topLevel = try decode(URLEncodedNode.self, from: buffer.string) return Content(value: parse(value: topLevel)) } catch { - return Content(error: error) + return Content(error: .misc(error)) } } diff --git a/Alchemy/HTTP/Coding/HTTPCoding+JSON.swift b/Alchemy/HTTP/Coding/HTTPCoding+JSON.swift index 316dd1a8..373bd066 100644 --- a/Alchemy/HTTP/Coding/HTTPCoding+JSON.swift +++ b/Alchemy/HTTP/Coding/HTTPCoding+JSON.swift @@ -22,7 +22,7 @@ extension JSONDecoder: HTTPDecoder { let topLevel = try JSONSerialization.jsonObject(with: buffer, options: .fragmentsAllowed) return Content(value: parse(val: topLevel)) } catch { - return Content(error: error) + return Content(error: .misc(error)) } } diff --git a/Alchemy/HTTP/Coding/HTTPCoding+Multipart.swift b/Alchemy/HTTP/Coding/HTTPCoding+Multipart.swift index d8e92973..9e269417 100644 --- a/Alchemy/HTTP/Coding/HTTPCoding+Multipart.swift +++ b/Alchemy/HTTP/Coding/HTTPCoding+Multipart.swift @@ -53,7 +53,7 @@ extension FormDataDecoder: HTTPDecoder { let dict = Dictionary(uniqueKeysWithValues: parts.compactMap { part in part.name.map { ($0, part) } }) return Content(value: .dictionary(dict.mapValues(\.value))) } catch { - return Content(error: error) + return Content(error: .misc(error)) } } } diff --git a/Alchemy/HTTP/Content/Content.swift b/Alchemy/HTTP/Content/Content.swift index d289947e..a3f487be 100644 --- a/Alchemy/HTTP/Content/Content.swift +++ b/Alchemy/HTTP/Content/Content.swift @@ -89,13 +89,21 @@ public final class Content: Buildable { public enum State { case value(Value) - case error(Error) + case error(ContentError) } - public enum Operator { + public enum Operator: CustomStringConvertible { case field(String) case index(Int) case flatten + + public var description: String { + switch self { + case .field(let field): field + case .index(let index): "\(index)" + case .flatten: "*" + } + } } /// The state of this node; either an error or a value. @@ -132,7 +140,7 @@ public final class Content: Buildable { } } - public var error: Error? { + public var error: ContentError? { guard case .error(let error) = state else { return nil } return error } @@ -147,13 +155,22 @@ public final class Content: Buildable { self.path = path } - public init(error: Error, path: [Operator] = []) { + public init(error: ContentError, path: [Operator] = []) { self.state = .error(error) self.path = path } public func decode(_ type: D.Type = D.self) throws -> D { - try D(from: GenericDecoder(delegate: self)) + do { + return try D(from: GenericDecoder(delegate: self)) + } catch { + if path.isEmpty { + throw ValidationError("Unable to decode \(D.self) from body.") + } else { + let pathString = path.map(\.description).joined(separator: ".") + throw ValidationError("Unable to decode \(D.self) from field \(pathString).") + } + } } private func unwrap(_ value: T?) throws -> T { diff --git a/Alchemy/HTTP/Content/Errors/ContentError.swift b/Alchemy/HTTP/Content/Errors/ContentError.swift index 2d85fa14..c925b82f 100644 --- a/Alchemy/HTTP/Content/Errors/ContentError.swift +++ b/Alchemy/HTTP/Content/Errors/ContentError.swift @@ -1,4 +1,4 @@ -enum ContentError: Error { +public enum ContentError: Error { case unknownContentType(ContentType?) case emptyBody case cantFlatten @@ -8,4 +8,5 @@ enum ContentError: Error { case wasNull case typeMismatch case notSupported(String) + case misc(Error) } diff --git a/Alchemy/HTTP/Protocols/HTTPInspector.swift b/Alchemy/HTTP/Protocols/HTTPInspector.swift index 220ac659..6fe7acb7 100644 --- a/Alchemy/HTTP/Protocols/HTTPInspector.swift +++ b/Alchemy/HTTP/Protocols/HTTPInspector.swift @@ -14,6 +14,14 @@ extension HTTPInspector { headers.first(name: name) } + public func requireHeader(_ name: String) throws -> String { + guard let header = header(name) else { + throw ValidationError("Missing header \(name).") + } + + return header + } + // MARK: Body /// The Foundation.Data of the body diff --git a/Alchemy/HTTP/Protocols/RequestInspector.swift b/Alchemy/HTTP/Protocols/RequestInspector.swift index 2e20a197..e2a1e3c1 100644 --- a/Alchemy/HTTP/Protocols/RequestInspector.swift +++ b/Alchemy/HTTP/Protocols/RequestInspector.swift @@ -11,4 +11,16 @@ extension RequestInspector { public func query(_ key: String, as: L.Type = L.self) -> L? { query(key).map { L($0) } ?? nil } + + public func requireQuery(_ key: String, as: L.Type = L.self) throws -> L { + guard let string = query(key) else { + throw ValidationError("Missing query \(key).") + } + + guard let value = L(string) else { + throw ValidationError("Invalid query \(key). Unable to convert \(string) to \(L.self).") + } + + return value + } } diff --git a/Alchemy/HTTP/Request.swift b/Alchemy/HTTP/Request.swift index 1565f361..eec89580 100644 --- a/Alchemy/HTTP/Request.swift +++ b/Alchemy/HTTP/Request.swift @@ -172,11 +172,11 @@ public final class Request: RequestInspector { /// ``` public func requireParameter(_ key: String, as: L.Type = L.self) throws -> L { guard let parameterString: String = parameters.first(where: { $0.key == key })?.value else { - throw ValidationError("expected parameter \(key)") + throw ValidationError("Missing path parameter \(key).") } guard let converted = L(parameterString) else { - throw ValidationError("parameter \(key) was \(parameterString) which couldn't be converted to \(name(of: L.self))") + throw ValidationError("Invalid path parameter \(key). Unable to convert \(parameterString) to \(L.self).") } return converted diff --git a/AlchemyPlugin/Sources/ApplicationMacro.swift b/AlchemyPlugin/Sources/ApplicationMacro.swift index d01ecd68..cdc814e0 100644 --- a/AlchemyPlugin/Sources/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/ApplicationMacro.swift @@ -43,9 +43,7 @@ struct ApplicationMacro: PeerMacro, ExtensionMacro { return try [ Declaration("@main extension \(`struct`.name)") {}, - Declaration("extension \(`struct`.name): Application") { - - }, + Declaration("extension \(`struct`.name): Application") {}, Declaration("extension \(`struct`.name): RoutesGenerator") { routes.generatedRoutesFunction() }, @@ -67,14 +65,18 @@ extension Routes { extension Routes.Route { func handlerExpression() -> Declaration { Declaration(method.lowercased() + path.inQuotes.inParentheses, "req") { - let arguments = parameters.map(\.argumentString).joined(separator: ", ").inParentheses + let arguments = parameters.map(\.argumentString).joined(separator: ",\n ") let effectsExpressions = [ isThrows ? "try" : nil, isAsync ? "await" : nil, ].compactMap { $0 } let effectsExpressionString = effectsExpressions.isEmpty ? "" : effectsExpressions.joined(separator: " ") + " " - "\(effectsExpressionString)\(name)\(arguments)" + """ + return \(effectsExpressionString)\(name)( + \(arguments) + ) + """ } } } @@ -91,15 +93,21 @@ extension EndpointParameter { let label = argumentLabel.map { "\($0): " } ?? "" guard type != "Request" else { - return label + name + return label + "req" } - let prefix = switch kind { - case .field: "body." - case .query: "query." - default: "" + switch kind { + case .field: + return label + "try req.content.\(name).decode(\(type).self)" + case .query: + return label + "try req.requireQuery(\(name.inQuotes), as: \(type).self)" + case .path: + return label + "try req.requireParameter(\(name.inQuotes), as: \(type).self)" + case .header: + return label + "try req.requireHeader(\(name.inQuotes))" + case .body: + return label + "try req.content.decode(\(type).self)" } - return label + prefix + name } } diff --git a/AlchemyPlugin/Sources/Utilities/Routes.swift b/AlchemyPlugin/Sources/Utilities/Routes.swift index 4d24fe03..e58b3680 100644 --- a/AlchemyPlugin/Sources/Utilities/Routes.swift +++ b/AlchemyPlugin/Sources/Utilities/Routes.swift @@ -237,7 +237,7 @@ extension FunctionParameterSyntax { } var typeName: String { - trimmed.type.description + trimmed.type.trimmedDescription } } diff --git a/Example/App.swift b/Example/App.swift index dba353a5..5aee7513 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -4,19 +4,25 @@ import Papyrus @Application struct App { - @GET("/200") - func success() { - // + @GET("/query") + func query(name: String) -> String { + "Hi, \(name)!" } - @GET("/400") - func badRequest() throws { - throw HTTPError(.badRequest) + @POST("/foo/:id") + func foo( + id: Int, + one: Header, + two: Int, + three: Bool, + request: Request + ) async throws -> String { + "Hello" } - @GET("/500") - func internalServerError() async throws { - throw HTTPError(.internalServerError) + @POST("/body") + func body(thing: Body) -> String { + thing } @GET("/job") @@ -26,6 +32,17 @@ struct App { @Job static func expensive(one: String, two: Int) async throws { - print("Hello") + print("Hello \(JobContext.current!.jobData.id)") + } +} + +extension Application { + var queues: Queues { + Queues( + default: "memory", + queues: [ + "memory": .memory, + ] + ) } } From b934fc4561fd54c3a96cca3e615a485c66977360 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 12 Jun 2024 17:46:11 -0700 Subject: [PATCH 21/55] jobs --- Alchemy/Queue/Job.swift | 2 +- AlchemyPlugin/Sources/JobMacro.swift | 6 +----- Example/App.swift | 4 ++-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Alchemy/Queue/Job.swift b/Alchemy/Queue/Job.swift index 5162c1bf..272c6dc9 100644 --- a/Alchemy/Queue/Job.swift +++ b/Alchemy/Queue/Job.swift @@ -87,7 +87,7 @@ public struct JobContext { // Default implementations. extension Job { - public static var name: String { Alchemy.name(of: Self.self) } + public static var name: String { String(reflecting: Self.self) } public var recoveryStrategy: RecoveryStrategy { .none } public var retryBackoff: TimeAmount { .zero } diff --git a/AlchemyPlugin/Sources/JobMacro.swift b/AlchemyPlugin/Sources/JobMacro.swift index f1b192f1..1561cae8 100644 --- a/AlchemyPlugin/Sources/JobMacro.swift +++ b/AlchemyPlugin/Sources/JobMacro.swift @@ -16,7 +16,7 @@ struct JobMacro: PeerMacro { let name = function.name.text return [ - Declaration("struct \(name.capitalizeFirst)Job: Job, Codable") { + Declaration("struct $\(name): Job, Codable") { for parameter in function.parameters { "let \(parameter.name): \(parameter.type)" @@ -33,10 +33,6 @@ struct JobMacro: PeerMacro { """ } }, - - Declaration("static func $\(name)(\(function.jobParametersSignature)) async throws") { - "try await \(name.capitalizeFirst)Job(\(function.jobPassthroughParameterSyntax)).dispatch()" - }, ] .map { $0.declSyntax() } } diff --git a/Example/App.swift b/Example/App.swift index 5aee7513..98e505d9 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -27,10 +27,10 @@ struct App { @GET("/job") func job() async throws { - try await App.$expensive(one: "", two: 1) + try await $expensive(one: "", two: 1).dispatch() } - @Job + @Job static func expensive(one: String, two: Int) async throws { print("Hello \(JobContext.current!.jobData.id)") } From 55bf0aaeea4231ba9a6ceaa411fff6fdd1a6a850 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 12 Jun 2024 18:19:52 -0700 Subject: [PATCH 22/55] controllers --- Alchemy/AlchemyMacros.swift | 15 ++++--- Alchemy/AlchemyX/RoutesGenerator.swift | 3 ++ AlchemyPlugin/Sources/AlchemyPlugin.swift | 1 + .../{ => Macros}/ApplicationMacro.swift | 43 +++---------------- .../Sources/Macros/ControllerMacro.swift | 38 ++++++++++++++++ .../Sources/{ => Macros}/JobMacro.swift | 0 .../Sources/{ => Macros}/ModelMacro.swift | 0 AlchemyPlugin/Tests/AlchemyPluginTests.swift | 1 + Example/App.swift | 10 +++++ 9 files changed, 70 insertions(+), 41 deletions(-) create mode 100644 Alchemy/AlchemyX/RoutesGenerator.swift rename AlchemyPlugin/Sources/{ => Macros}/ApplicationMacro.swift (67%) create mode 100644 AlchemyPlugin/Sources/Macros/ControllerMacro.swift rename AlchemyPlugin/Sources/{ => Macros}/JobMacro.swift (100%) rename AlchemyPlugin/Sources/{ => Macros}/ModelMacro.swift (100%) create mode 100644 AlchemyPlugin/Tests/AlchemyPluginTests.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index 2e341317..75b16204 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -1,10 +1,9 @@ -@attached(peer, names: arbitrary) +@attached(peer, names: prefixed(`$`)) public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") @attached(member, names: arbitrary) public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro") -@attached(peer) @attached( extension, conformances: @@ -15,6 +14,12 @@ public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro ) public macro Application() = #externalMacro(module: "AlchemyPlugin", type: "ApplicationMacro") -public protocol RoutesGenerator { - func addGeneratedRoutes() -} +@attached( + extension, + conformances: + Controller, + RoutesGenerator, + names: + named(route) +) +public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "ControllerMacro") diff --git a/Alchemy/AlchemyX/RoutesGenerator.swift b/Alchemy/AlchemyX/RoutesGenerator.swift new file mode 100644 index 00000000..0ba4e989 --- /dev/null +++ b/Alchemy/AlchemyX/RoutesGenerator.swift @@ -0,0 +1,3 @@ +public protocol RoutesGenerator { + func addGeneratedRoutes() +} diff --git a/AlchemyPlugin/Sources/AlchemyPlugin.swift b/AlchemyPlugin/Sources/AlchemyPlugin.swift index c5ad1b65..6e4e9e49 100644 --- a/AlchemyPlugin/Sources/AlchemyPlugin.swift +++ b/AlchemyPlugin/Sources/AlchemyPlugin.swift @@ -9,6 +9,7 @@ struct AlchemyPlugin: CompilerPlugin { JobMacro.self, ModelMacro.self, ApplicationMacro.self, + ControllerMacro.self, ] } diff --git a/AlchemyPlugin/Sources/ApplicationMacro.swift b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift similarity index 67% rename from AlchemyPlugin/Sources/ApplicationMacro.swift rename to AlchemyPlugin/Sources/Macros/ApplicationMacro.swift index cdc814e0..e88fd725 100644 --- a/AlchemyPlugin/Sources/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -1,30 +1,7 @@ import SwiftSyntax import SwiftSyntaxMacros -struct ApplicationMacro: PeerMacro, ExtensionMacro { - - /* - - 1. add @main - 2. add Application - 3. register routes - 4. register jobs? - - */ - - // MARK: PeerMacro - - static func expansion( - of node: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard let `struct` = declaration.as(StructDeclSyntax.self) else { - throw AlchemyMacroError("@Application can only be applied to a struct") - } - - return [] - } +struct ApplicationMacro: ExtensionMacro { // MARK: ExtensionMacro @@ -42,9 +19,9 @@ struct ApplicationMacro: PeerMacro, ExtensionMacro { let routes = try Routes.parse(declaration) return try [ - Declaration("@main extension \(`struct`.name)") {}, - Declaration("extension \(`struct`.name): Application") {}, - Declaration("extension \(`struct`.name): RoutesGenerator") { + Declaration("@main extension \(`struct`.name.trimmedDescription)") {}, + Declaration("extension \(`struct`.name.trimmedDescription): Application") {}, + Declaration("extension \(`struct`.name.trimmedDescription): RoutesGenerator") { routes.generatedRoutesFunction() }, ] @@ -63,8 +40,8 @@ extension Routes { } extension Routes.Route { - func handlerExpression() -> Declaration { - Declaration(method.lowercased() + path.inQuotes.inParentheses, "req") { + func handlerExpression(prefix: String = "") -> Declaration { + Declaration(prefix + method.lowercased() + path.inQuotes.inParentheses, "req") { let arguments = parameters.map(\.argumentString).joined(separator: ",\n ") let effectsExpressions = [ isThrows ? "try" : nil, @@ -81,14 +58,8 @@ extension Routes.Route { } } -extension StructDeclSyntax { - fileprivate var attributeNames: [String] { - attributes.map(\.trimmedDescription) - } -} - extension EndpointParameter { - fileprivate var argumentString: String { + var argumentString: String { let argumentLabel = label == "_" ? nil : label ?? name let label = argumentLabel.map { "\($0): " } ?? "" diff --git a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift new file mode 100644 index 00000000..e07a3703 --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift @@ -0,0 +1,38 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +struct ControllerMacro: ExtensionMacro { + + // MARK: ExtensionMacro + + static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard let `struct` = declaration.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("@Controller can only be applied to a struct") + } + + let routes = try Routes.parse(declaration) + + return try [ + Declaration("extension \(`struct`.name.trimmedDescription): Controller") { + routes.controllerRouteFunction() + }, + ] + .map { try $0.extensionDeclSyntax() } + } +} + +extension Routes { + func controllerRouteFunction() -> Declaration { + Declaration("func route(_ router: Router)") { + for route in routes { + route.handlerExpression(prefix: "router.") + } + } + } +} diff --git a/AlchemyPlugin/Sources/JobMacro.swift b/AlchemyPlugin/Sources/Macros/JobMacro.swift similarity index 100% rename from AlchemyPlugin/Sources/JobMacro.swift rename to AlchemyPlugin/Sources/Macros/JobMacro.swift diff --git a/AlchemyPlugin/Sources/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift similarity index 100% rename from AlchemyPlugin/Sources/ModelMacro.swift rename to AlchemyPlugin/Sources/Macros/ModelMacro.swift diff --git a/AlchemyPlugin/Tests/AlchemyPluginTests.swift b/AlchemyPlugin/Tests/AlchemyPluginTests.swift new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/AlchemyPlugin/Tests/AlchemyPluginTests.swift @@ -0,0 +1 @@ + diff --git a/Example/App.swift b/Example/App.swift index 98e505d9..90310d38 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -4,6 +4,10 @@ import Papyrus @Application struct App { + func boot() throws { + use(SomeController()) + } + @GET("/query") func query(name: String) -> String { "Hi, \(name)!" @@ -36,6 +40,12 @@ struct App { } } +@Controller +struct SomeController { + @GET("/test") + func test() -> String { "test" } +} + extension Application { var queues: Queues { Queues( From 0df3fb01cec50d8bda65bc7e9a531cc9a2ed4082 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 13 Jun 2024 19:35:22 -0700 Subject: [PATCH 23/55] validations --- .../Sources/Macros/ApplicationMacro.swift | 55 ++++++++++++------- AlchemyPlugin/Sources/Utilities/Routes.swift | 8 +++ Example/App.swift | 13 +++-- Example/Validation/Validate.swift | 18 ++++++ Example/Validation/Validator.swift | 55 +++++++++++++++++++ 5 files changed, 125 insertions(+), 24 deletions(-) create mode 100644 Example/Validation/Validate.swift create mode 100644 Example/Validation/Validator.swift diff --git a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift index e88fd725..de5977da 100644 --- a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -42,42 +42,57 @@ extension Routes { extension Routes.Route { func handlerExpression(prefix: String = "") -> Declaration { Declaration(prefix + method.lowercased() + path.inQuotes.inParentheses, "req") { - let arguments = parameters.map(\.argumentString).joined(separator: ",\n ") - let effectsExpressions = [ - isThrows ? "try" : nil, - isAsync ? "await" : nil, - ].compactMap { $0 } - - let effectsExpressionString = effectsExpressions.isEmpty ? "" : effectsExpressions.joined(separator: " ") + " " - """ - return \(effectsExpressionString)\(name)( - \(arguments) - ) - """ + for parameter in parameters { + if let validation = parameter.validation { + "\(validation) var \(parameter.name) = \(parameter.parseExpression)" + "try await $\(parameter.name).validate()" + "" + } else { + "let \(parameter.name) = \(parameter.parseExpression)" + } + } + + let arguments = parameters + .map { $0.argumentLabel + $0.name } + .joined(separator: ", ") + + + "return " + effectsExpression + name + arguments.inParentheses } } + + private var effectsExpression: String { + let effectsExpressions = [ + isThrows ? "try" : nil, + isAsync ? "await" : nil, + ].compactMap { $0 } + + return effectsExpressions.isEmpty ? "" : effectsExpressions.joined(separator: " ") + " " + } } extension EndpointParameter { - var argumentString: String { + var argumentLabel: String { let argumentLabel = label == "_" ? nil : label ?? name - let label = argumentLabel.map { "\($0): " } ?? "" + return argumentLabel.map { "\($0): " } ?? "" + } + var parseExpression: String { guard type != "Request" else { - return label + "req" + return "req" } switch kind { case .field: - return label + "try req.content.\(name).decode(\(type).self)" + return "try req.content.\(name).decode(\(type).self)" case .query: - return label + "try req.requireQuery(\(name.inQuotes), as: \(type).self)" + return "try req.requireQuery(\(name.inQuotes), as: \(type).self)" case .path: - return label + "try req.requireParameter(\(name.inQuotes), as: \(type).self)" + return "try req.requireParameter(\(name.inQuotes), as: \(type).self)" case .header: - return label + "try req.requireHeader(\(name.inQuotes))" + return "try req.requireHeader(\(name.inQuotes))" case .body: - return label + "try req.content.decode(\(type).self)" + return "try req.content.decode(\(type).self)" } } } diff --git a/AlchemyPlugin/Sources/Utilities/Routes.swift b/AlchemyPlugin/Sources/Utilities/Routes.swift index e58b3680..3cc64274 100644 --- a/AlchemyPlugin/Sources/Utilities/Routes.swift +++ b/AlchemyPlugin/Sources/Utilities/Routes.swift @@ -126,11 +126,15 @@ struct EndpointParameter { let name: String let type: String let kind: Kind + let validation: String? init(_ parameter: FunctionParameterSyntax, httpMethod: String, pathParameters: [String]) { self.label = parameter.label self.name = parameter.name self.type = parameter.typeName + self.validation = parameter.parameterAttributes + .first { $0.name == "Validate" } + .map { $0.trimmedDescription } self.kind = if type.hasPrefix("Path<") { .path @@ -239,6 +243,10 @@ extension FunctionParameterSyntax { var typeName: String { trimmed.type.trimmedDescription } + + var parameterAttributes: [AttributeSyntax] { + attributes.compactMap { $0.as(AttributeSyntax.self) } + } } extension AttributeSyntax { diff --git a/Example/App.swift b/Example/App.swift index 90310d38..437cdc2b 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -3,7 +3,6 @@ import Papyrus @Application struct App { - func boot() throws { use(SomeController()) } @@ -25,7 +24,9 @@ struct App { } @POST("/body") - func body(thing: Body) -> String { + func body( + @Validate(.email) thing: Body + ) -> String { thing } @@ -42,8 +43,12 @@ struct App { @Controller struct SomeController { - @GET("/test") - func test() -> String { "test" } + @POST("/user") + func test( + @Validate(.email) name: String, + @Validate(.between(18...99)) age: Int, + @Validate(.password) password: String + ) -> String { "test" } } extension Application { diff --git a/Example/Validation/Validate.swift b/Example/Validation/Validate.swift new file mode 100644 index 00000000..e56371db --- /dev/null +++ b/Example/Validation/Validate.swift @@ -0,0 +1,18 @@ +@propertyWrapper +public struct Validate { + public var wrappedValue: T + public var projectedValue: Validate { self } + + private let validators: [Validator] + + public init(wrappedValue: T, _ validators: Validator...) { + self.wrappedValue = wrappedValue + self.validators = validators + } + + public func validate() async throws { + for validator in validators { + try await validator.validate(wrappedValue) + } + } +} diff --git a/Example/Validation/Validator.swift b/Example/Validation/Validator.swift new file mode 100644 index 00000000..1ece1bff --- /dev/null +++ b/Example/Validation/Validator.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct Validator: @unchecked Sendable { + private let message: String? + private let isValid: (Value) async throws -> Bool + + public init(_ message: String? = nil, isValid: @escaping (Value) async throws -> Bool) { + self.message = message + self.isValid = isValid + } + + public init(_ message: String? = nil, validators: Validator...) { + self.message = message + self.isValid = { _ in fatalError() } + } + + public func validate(_ value: Value) async throws { + guard try await isValid(value) else { + let message = message ?? "Invalid content." + throw ValidationError.invalid(message) + } + } +} + +public enum ValidationError: Error { + case invalid(String) +} + +extension Validator { + public static let username = Validator(validators: .profanity, .email) + public static let profanity = Validator { $0 != "dang" } + + public static let email = Validator { + let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) + return emailPred.evaluate(with: $0) + } + + public static let password = Validator { + $0.count > 8 && + $0.rangeOfCharacter(from: .decimalDigits) != nil && + $0.rangeOfCharacter(from: .alphanumerics.inverted) != nil + } + + public static let fraud = Validator { + try await Task.sleep(for: .seconds(1)) + return $0 != "fraudman101@fraud.com" + } +} + +extension Validator { + public static func between(_ range: ClosedRange) -> Validator { + Validator { range.contains($0) } + } +} From f1e6e44c6c65223ae858daa2884c4ebf4f20bf7e Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 14 Jun 2024 09:23:45 -0700 Subject: [PATCH 24/55] add options --- Alchemy/AlchemyMacros.swift | 41 +++++++++++++++++++ Alchemy/Routing/Controller.swift | 14 +++++-- .../Validation/Validate.swift | 0 .../Validation/Validator.swift | 6 +-- .../Sources/Macros/ApplicationMacro.swift | 9 +++- .../Sources/Macros/DecoratorMacro.swift | 15 +++++++ AlchemyPlugin/Sources/Utilities/Routes.swift | 12 ++++-- Example/App.swift | 14 ++++++- 8 files changed, 96 insertions(+), 15 deletions(-) rename {Example => Alchemy}/Validation/Validate.swift (100%) rename {Example => Alchemy}/Validation/Validator.swift (93%) create mode 100644 AlchemyPlugin/Sources/Macros/DecoratorMacro.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index 75b16204..e46a6d36 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -23,3 +23,44 @@ public macro Application() = #externalMacro(module: "AlchemyPlugin", type: "Appl named(route) ) public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "ControllerMacro") + +// MARK: Routes + +@attached(peer) +public macro HTTP(_ path: String, method: String) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +@attached(peer) +public macro DELETE(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +@attached(peer) +public macro GET(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +@attached(peer) +public macro PATCH(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +@attached(peer) +public macro POST(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +@attached(peer) +public macro PUT(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +@attached(peer) +public macro OPTIONS(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +@attached(peer) +public macro HEAD(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +@attached(peer) +public macro TRACE(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +@attached(peer) +public macro CONNECT(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + +// MARK: Route Parameters + +public typealias Path = T +public typealias Header = T +//public typealias Query = T +public typealias Field = T +public typealias Body = T + diff --git a/Alchemy/Routing/Controller.swift b/Alchemy/Routing/Controller.swift index 2a6ac058..67e42704 100644 --- a/Alchemy/Routing/Controller.swift +++ b/Alchemy/Routing/Controller.swift @@ -1,20 +1,28 @@ /// Represents a type that adds handlers to a router. Used for organizing your /// app's handlers into smaller components. public protocol Controller { + /// Any middleware to be applied to all routes in this controller. + var middlewares: [Middleware] { get } + /// Add this controller's handlers to a router. func route(_ router: Router) } +extension Controller { + public var middlewares: [Middleware] { [] } +} + extension Router { /// Adds a controller to this router. /// /// - Parameter controller: The controller to handle routes on this router. @discardableResult public func use(_ controllers: Controller...) -> Self { - controllers.forEach { - $0.route(group()) + for controller in controllers { + let group = group(middlewares: controller.middlewares) + controller.route(group) } - + return self } } diff --git a/Example/Validation/Validate.swift b/Alchemy/Validation/Validate.swift similarity index 100% rename from Example/Validation/Validate.swift rename to Alchemy/Validation/Validate.swift diff --git a/Example/Validation/Validator.swift b/Alchemy/Validation/Validator.swift similarity index 93% rename from Example/Validation/Validator.swift rename to Alchemy/Validation/Validator.swift index 1ece1bff..73d519c1 100644 --- a/Example/Validation/Validator.swift +++ b/Alchemy/Validation/Validator.swift @@ -17,15 +17,11 @@ public struct Validator: @unchecked Sendable { public func validate(_ value: Value) async throws { guard try await isValid(value) else { let message = message ?? "Invalid content." - throw ValidationError.invalid(message) + throw ValidationError(message) } } } -public enum ValidationError: Error { - case invalid(String) -} - extension Validator { public static let username = Validator(validators: .profanity, .email) public static let profanity = Validator { $0 != "dang" } diff --git a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift index de5977da..2e0b8bee 100644 --- a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -41,7 +41,7 @@ extension Routes { extension Routes.Route { func handlerExpression(prefix: String = "") -> Declaration { - Declaration(prefix + method.lowercased() + path.inQuotes.inParentheses, "req") { + Declaration(prefix + method.lowercased() + routeParametersExpression, "req") { for parameter in parameters { if let validation = parameter.validation { "\(validation) var \(parameter.name) = \(parameter.parseExpression)" @@ -61,6 +61,13 @@ extension Routes.Route { } } + private var routeParametersExpression: String { + [path.inQuotes, options.map { "options: \($0)" }] + .compactMap { $0 } + .joined(separator: ", ") + .inParentheses + } + private var effectsExpression: String { let effectsExpressions = [ isThrows ? "try" : nil, diff --git a/AlchemyPlugin/Sources/Macros/DecoratorMacro.swift b/AlchemyPlugin/Sources/Macros/DecoratorMacro.swift new file mode 100644 index 00000000..d63b4c02 --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/DecoratorMacro.swift @@ -0,0 +1,15 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +public enum DecoratorMacro: PeerMacro { + + // MARK: PeerMacro + + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} diff --git a/AlchemyPlugin/Sources/Utilities/Routes.swift b/AlchemyPlugin/Sources/Utilities/Routes.swift index 3cc64274..23b1a1d4 100644 --- a/AlchemyPlugin/Sources/Utilities/Routes.swift +++ b/AlchemyPlugin/Sources/Utilities/Routes.swift @@ -8,6 +8,7 @@ struct Routes { let method: String let path: String let pathParameters: [String] + let options: String? /// The name of the function defining this endpoint. let name: String let parameters: [EndpointParameter] @@ -35,7 +36,7 @@ extension Routes { } private static func parse(_ function: FunctionDeclSyntax) throws -> Routes.Route? { - guard let (method, path, pathParameters) = parseMethodAndPath(function) else { + guard let (method, path, pathParameters, options) = parseMethodAndPath(function) else { return nil } @@ -43,6 +44,7 @@ extension Routes { method: method, path: path, pathParameters: pathParameters, + options: options, name: function.functionName, parameters: try function.parameters.compactMap { EndpointParameter($0, httpMethod: method, pathParameters: pathParameters) @@ -55,8 +57,8 @@ extension Routes { private static func parseMethodAndPath( _ function: FunctionDeclSyntax - ) -> (method: String, path: String, pathParameters: [String])? { - var method, path: String? + ) -> (method: String, path: String, pathParameters: [String], options: String?)? { + var method, path, options: String? for attribute in function.functionAttributes { if case let .argumentList(list) = attribute.arguments { let name = attribute.attributeName.trimmedDescription @@ -64,9 +66,11 @@ extension Routes { case "GET", "DELETE", "PATCH", "POST", "PUT", "OPTIONS", "HEAD", "TRACE", "CONNECT": method = name path = list.first?.expression.description.withoutQuotes + options = list.dropFirst().first?.expression.description.withoutQuotes case "HTTP": method = list.first?.expression.description.withoutQuotes path = list.dropFirst().first?.expression.description.withoutQuotes + options = list.dropFirst().dropFirst().first?.expression.description.withoutQuotes default: continue } @@ -77,7 +81,7 @@ extension Routes { return nil } - return (method, path, path.papyrusPathParameters) + return (method, path, path.papyrusPathParameters, options) } } diff --git a/Example/App.swift b/Example/App.swift index 437cdc2b..9c763141 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,5 +1,15 @@ import Alchemy -import Papyrus + +/* + Generated Routes + + 1. simple and easy to understand what's going on + 2. need customization (middleware, etc) + - would like all routing (middleware / routes) to be in the route / boot function + - would like to auto generate macro'd routes + - want some but not too many dollar signs + + */ @Application struct App { @@ -43,7 +53,7 @@ struct App { @Controller struct SomeController { - @POST("/user") + @POST("/user", options: .stream) func test( @Validate(.email) name: String, @Validate(.between(18...99)) age: Int, From cbd26edbf58593ef03bcebf57e563ee0de4d5b65 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 14 Jun 2024 09:30:56 -0700 Subject: [PATCH 25/55] cleanup --- Example/App.swift | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Example/App.swift b/Example/App.swift index 9c763141..5c92dbd7 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,16 +1,5 @@ import Alchemy -/* - Generated Routes - - 1. simple and easy to understand what's going on - 2. need customization (middleware, etc) - - would like all routing (middleware / routes) to be in the route / boot function - - would like to auto generate macro'd routes - - want some but not too many dollar signs - - */ - @Application struct App { func boot() throws { From 9da01b8131d1804b0304a5dae706cd0ccb3a9ca9 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 14 Jun 2024 16:59:45 -0700 Subject: [PATCH 26/55] update --- Alchemy/AlchemyMacros.swift | 95 +++++++-------- Alchemy/Validation/Validate.swift | 23 ++++ AlchemyPlugin/Sources/AlchemyPlugin.swift | 20 +--- .../Sources/Macros/ApplicationMacro.swift | 2 - .../Sources/Macros/HTTPMethodMacro.swift | 110 ++++++++++++++++++ AlchemyPlugin/Sources/Utilities/Routes.swift | 65 +++++------ Example/App.swift | 42 ++++++- 7 files changed, 250 insertions(+), 107 deletions(-) create mode 100644 AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index e46a6d36..656c3d9e 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -1,66 +1,67 @@ +@attached(extension, conformances: Application, RoutesGenerator, names: named(addGeneratedRoutes)) +public macro Application() = #externalMacro(module: "AlchemyPlugin", type: "ApplicationMacro") + +@attached(extension, conformances: Controller, RoutesGenerator, names: named(route)) +public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "ControllerMacro") + @attached(peer, names: prefixed(`$`)) public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") @attached(member, names: arbitrary) public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro") -@attached( - extension, - conformances: - Application, - RoutesGenerator, - names: - named(addGeneratedRoutes) -) -public macro Application() = #externalMacro(module: "AlchemyPlugin", type: "ApplicationMacro") - -@attached( - extension, - conformances: - Controller, - RoutesGenerator, - names: - named(route) -) -public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "ControllerMacro") - -// MARK: Routes +// MARK: Route Methods -@attached(peer) -public macro HTTP(_ path: String, method: String) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +@attached(peer, names: prefixed(`$`)) public macro HTTP(_ path: String, method: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +@attached(peer, names: prefixed(`$`)) public macro DELETE(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") +@attached(peer, names: prefixed(`$`)) public macro GET(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") +@attached(peer, names: prefixed(`$`)) public macro PATCH(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") +@attached(peer, names: prefixed(`$`)) public macro POST(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") +@attached(peer, names: prefixed(`$`)) public macro PUT(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") +@attached(peer, names: prefixed(`$`)) public macro OPTIONS(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") +@attached(peer, names: prefixed(`$`)) public macro HEAD(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") +@attached(peer, names: prefixed(`$`)) public macro TRACE(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") +@attached(peer, names: prefixed(`$`)) public macro CONNECT(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") -@attached(peer) -public macro DELETE(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +// MARK: Route Parameters -@attached(peer) -public macro GET(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +@propertyWrapper public struct Path { + public var wrappedValue: L -@attached(peer) -public macro PATCH(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + public init(wrappedValue: L) { + self.wrappedValue = wrappedValue + } +} -@attached(peer) -public macro POST(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +@propertyWrapper public struct Header { + public var wrappedValue: L -@attached(peer) -public macro PUT(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + public init(wrappedValue: L) { + self.wrappedValue = wrappedValue + } +} -@attached(peer) -public macro OPTIONS(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +@propertyWrapper public struct URLQuery { + public var wrappedValue: L -@attached(peer) -public macro HEAD(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + public init(wrappedValue: L) { + self.wrappedValue = wrappedValue + } +} -@attached(peer) -public macro TRACE(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +@propertyWrapper public struct Field { + public var wrappedValue: C -@attached(peer) -public macro CONNECT(_ path: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") + public init(wrappedValue: C) { + self.wrappedValue = wrappedValue + } +} -// MARK: Route Parameters +@propertyWrapper public struct Body { + public var wrappedValue: C -public typealias Path = T -public typealias Header = T -//public typealias Query = T -public typealias Field = T -public typealias Body = T + public init(wrappedValue: C) { + self.wrappedValue = wrappedValue + } +} diff --git a/Alchemy/Validation/Validate.swift b/Alchemy/Validation/Validate.swift index e56371db..86fe74b8 100644 --- a/Alchemy/Validation/Validate.swift +++ b/Alchemy/Validation/Validate.swift @@ -16,3 +16,26 @@ public struct Validate { } } } + +extension Validate: Codable where T: Codable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wrappedValue) + } + + public init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer().decode(T.self) + self.init(wrappedValue: value) + } +} + +extension Validate: LosslessStringConvertible & CustomStringConvertible where T: LosslessStringConvertible { + public init?(_ description: String) { + guard let wrappedValue = T(description) else { return nil } + self.init(wrappedValue: wrappedValue) + } + + public var description: String { + wrappedValue.description + } +} diff --git a/AlchemyPlugin/Sources/AlchemyPlugin.swift b/AlchemyPlugin/Sources/AlchemyPlugin.swift index 6e4e9e49..3be2a263 100644 --- a/AlchemyPlugin/Sources/AlchemyPlugin.swift +++ b/AlchemyPlugin/Sources/AlchemyPlugin.swift @@ -10,26 +10,8 @@ struct AlchemyPlugin: CompilerPlugin { ModelMacro.self, ApplicationMacro.self, ControllerMacro.self, + HTTPMethodMacro.self, ] } #endif - -/* - - # Routes - - - @Routes at top level searches for REST annotated functions - - constructs - - # Jobs - - # Model - - - - */ -// @Model -// @Routes -// @Application -// @Controller diff --git a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift index 2e0b8bee..98b2ec5a 100644 --- a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -17,7 +17,6 @@ struct ApplicationMacro: ExtensionMacro { } let routes = try Routes.parse(declaration) - return try [ Declaration("@main extension \(`struct`.name.trimmedDescription)") {}, Declaration("extension \(`struct`.name.trimmedDescription): Application") {}, @@ -103,4 +102,3 @@ extension EndpointParameter { } } } - diff --git a/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift new file mode 100644 index 00000000..cc3d632f --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift @@ -0,0 +1,110 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +struct HTTPMethodMacro: PeerMacro { + static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let function = declaration.as(FunctionDeclSyntax.self) else { + throw AlchemyMacroError("@\(node.name) can only be applied to functions") + } + + guard let route = try Routes.Route.parse(function) else { + throw AlchemyMacroError("Unable to parse function for @\(node.name)") + } + + var expressions: [String] = [] + for parameter in route.parameters { + if let validation = parameter.validation { + expressions.append("\(validation) var \(parameter.name) = \(parameter.parseExpression)") + expressions.append("try await $\(parameter.name).validate()") + expressions.append("") + } else { + expressions.append("let \(parameter.name) = \(parameter.parseExpression)") + } + } + + let arguments = route.parameters + .map { $0.argumentLabel + $0.name } + .joined(separator: ", ") + + + expressions.append("return " + route.effectsExpression + route.name + arguments.inParentheses) + + return [ + Declaration("var $\(route.name): Route") { + let options = route.options.map { "\n options: \($0)," } ?? "" + """ + Route( + method: .\(route.method), + path: \(route.path.inQuotes),\(options) + handler: { req in + \(expressions.joined(separator: "\n ")) + } + ) + """ + }, + ] + .map { $0.declSyntax() } + } +} + +extension Routes.Route { + fileprivate var routeParametersExpression: String { + [path.inQuotes, options.map { "options: \($0)" }] + .compactMap { $0 } + .joined(separator: ", ") + .inParentheses + } + + fileprivate var effectsExpression: String { + let effectsExpressions = [ + isThrows ? "try" : nil, + isAsync ? "await" : nil, + ].compactMap { $0 } + + return effectsExpressions.isEmpty ? "" : effectsExpressions.joined(separator: " ") + " " + } +} + +extension EndpointParameter { + var argumentLabel2: String { + let argumentLabel = label == "_" ? nil : label ?? name + return argumentLabel.map { "\($0): " } ?? "" + } + + var parseExpression2: String { + guard type != "Request" else { + return "req" + } + + switch kind { + case .field: + return "try req.content.\(name).decode(\(type).self)" + case .query: + return "try req.requireQuery(\(name.inQuotes), as: \(type).self)" + case .path: + return "try req.requireParameter(\(name.inQuotes), as: \(type).self)" + case .header: + return "try req.requireHeader(\(name.inQuotes))" + case .body: + return "try req.content.decode(\(type).self)" + } + } +} + +/* + var _body: Route { + Route( + method: .POST, + path: "/body", + handler: { req in + @Validate(.email) var thing = try req.content.thing.decode(String.self) + try await $thing.validate() + return body(thing: thing) + } + ) + } + */ diff --git a/AlchemyPlugin/Sources/Utilities/Routes.swift b/AlchemyPlugin/Sources/Utilities/Routes.swift index 23b1a1d4..62e7a1c9 100644 --- a/AlchemyPlugin/Sources/Utilities/Routes.swift +++ b/AlchemyPlugin/Sources/Utilities/Routes.swift @@ -31,11 +31,25 @@ extension Routes { return Routes( name: type.name.text, - routes: try type.functions.compactMap( { try parse($0) }) + routes: try type.functions.compactMap( { try Route.parse($0) }) ) } +} - private static func parse(_ function: FunctionDeclSyntax) throws -> Routes.Route? { +extension Routes.Route { + var functionSignature: String { + let parameters = parameters.map { + let name = [$0.label, $0.name] + .compactMap { $0 } + .joined(separator: " ") + return "\(name): \($0.type)" + } + + let returnType = responseType.map { " -> \($0)" } ?? "" + return parameters.joined(separator: ", ").inParentheses + " async throws" + returnType + } + + static func parse(_ function: FunctionDeclSyntax) throws -> Routes.Route? { guard let (method, path, pathParameters, options) = parseMethodAndPath(function) else { return nil } @@ -85,20 +99,6 @@ extension Routes { } } -extension Routes.Route { - var functionSignature: String { - let parameters = parameters.map { - let name = [$0.label, $0.name] - .compactMap { $0 } - .joined(separator: " ") - return "\(name): \($0.type)" - } - - let returnType = responseType.map { " -> \($0)" } ?? "" - return parameters.joined(separator: ", ").inParentheses + " async throws" + returnType - } -} - extension [EndpointParameter] { fileprivate func validated() throws -> [EndpointParameter] { let bodies = filter { $0.kind == .body } @@ -139,27 +139,20 @@ struct EndpointParameter { self.validation = parameter.parameterAttributes .first { $0.name == "Validate" } .map { $0.trimmedDescription } + + let attributeNames = parameter.parameterAttributes.map(\.name) self.kind = - if type.hasPrefix("Path<") { - .path - } else if type.hasPrefix("Body<") { - .body - } else if type.hasPrefix("Header<") { - .header - } else if type.hasPrefix("Field<") { - .field - } else if type.hasPrefix("Query<") { - .query - } else if pathParameters.contains(name) { - // if name matches a path param, infer this belongs in path - .path - } else if ["GET", "HEAD", "DELETE"].contains(httpMethod) { - // if method is GET, HEAD, DELETE, infer query - .query - } else { - // otherwise infer it's a body field - .field - } + if attributeNames.contains("Path") { .path } + else if attributeNames.contains("Body") { .body } + else if attributeNames.contains("Header") { .header } + else if attributeNames.contains("Field") { .field } + else if attributeNames.contains("URLQuery") { .query } + // if name matches a path param, infer this belongs in path + else if pathParameters.contains(name) { .path } + // if method is GET, HEAD, DELETE, infer query + else if ["GET", "HEAD", "DELETE"].contains(httpMethod) { .query } + // otherwise infer it's a body field + else { .field } } } diff --git a/Example/App.swift b/Example/App.swift index 5c92dbd7..98a058b6 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,5 +1,29 @@ import Alchemy +struct Route { + let method: HTTPMethod + let path: String + var options: RouteOptions + let handler: (Request) async throws -> ResponseConvertible + + init(method: HTTPMethod, path: String, options: RouteOptions = [], handler: @escaping (Request) async throws -> ResponseConvertible) { + self.method = method + self.path = path + self.options = options + self.handler = handler + } + + init(method: HTTPMethod, path: String, options: RouteOptions = [], handler: @escaping (Request) async throws -> Void) { + self.method = method + self.path = path + self.options = options + self.handler = { + try await handler($0) + return Response(status: .ok) + } + } +} + @Application struct App { func boot() throws { @@ -14,17 +38,29 @@ struct App { @POST("/foo/:id") func foo( id: Int, - one: Header, - two: Int, + @Header one: String, + @URLQuery @Validate(.between(18...99)) two: Int, three: Bool, request: Request ) async throws -> String { "Hello" } + var _body: Route { + Route( + method: .POST, + path: "/body", + handler: { req in + @Validate(.email) var thing = try req.content.thing.decode(String.self) + try await $thing.validate() + return body(thing: thing) + } + ) + } + @POST("/body") func body( - @Validate(.email) thing: Body + @Validate(.email) thing: String ) -> String { thing } From 636bd5bf0e7f0c3df62460b67e2e5e29fdd14f4a Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 14 Jun 2024 17:39:44 -0700 Subject: [PATCH 27/55] clean route --- .../Sources/Macros/ApplicationMacro.swift | 67 +------------------ .../Sources/Macros/ControllerMacro.swift | 2 +- .../Sources/Macros/HTTPMethodMacro.swift | 28 ++------ Example/App.swift | 40 +++++++---- 4 files changed, 35 insertions(+), 102 deletions(-) diff --git a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift index 98b2ec5a..53fe6a4d 100644 --- a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -32,73 +32,8 @@ extension Routes { func generatedRoutesFunction() -> Declaration { Declaration("func addGeneratedRoutes()") { for route in routes { - route.handlerExpression() + "use($\(route.name))" } } } } - -extension Routes.Route { - func handlerExpression(prefix: String = "") -> Declaration { - Declaration(prefix + method.lowercased() + routeParametersExpression, "req") { - for parameter in parameters { - if let validation = parameter.validation { - "\(validation) var \(parameter.name) = \(parameter.parseExpression)" - "try await $\(parameter.name).validate()" - "" - } else { - "let \(parameter.name) = \(parameter.parseExpression)" - } - } - - let arguments = parameters - .map { $0.argumentLabel + $0.name } - .joined(separator: ", ") - - - "return " + effectsExpression + name + arguments.inParentheses - } - } - - private var routeParametersExpression: String { - [path.inQuotes, options.map { "options: \($0)" }] - .compactMap { $0 } - .joined(separator: ", ") - .inParentheses - } - - private var effectsExpression: String { - let effectsExpressions = [ - isThrows ? "try" : nil, - isAsync ? "await" : nil, - ].compactMap { $0 } - - return effectsExpressions.isEmpty ? "" : effectsExpressions.joined(separator: " ") + " " - } -} - -extension EndpointParameter { - var argumentLabel: String { - let argumentLabel = label == "_" ? nil : label ?? name - return argumentLabel.map { "\($0): " } ?? "" - } - - var parseExpression: String { - guard type != "Request" else { - return "req" - } - - switch kind { - case .field: - return "try req.content.\(name).decode(\(type).self)" - case .query: - return "try req.requireQuery(\(name.inQuotes), as: \(type).self)" - case .path: - return "try req.requireParameter(\(name.inQuotes), as: \(type).self)" - case .header: - return "try req.requireHeader(\(name.inQuotes))" - case .body: - return "try req.content.decode(\(type).self)" - } - } -} diff --git a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift index e07a3703..a82a2f0a 100644 --- a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift @@ -31,7 +31,7 @@ extension Routes { func controllerRouteFunction() -> Declaration { Declaration("func route(_ router: Router)") { for route in routes { - route.handlerExpression(prefix: "router.") + "router.use($\(route.name))" } } } diff --git a/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift index cc3d632f..aafcaa91 100644 --- a/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift +++ b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift @@ -20,7 +20,6 @@ struct HTTPMethodMacro: PeerMacro { if let validation = parameter.validation { expressions.append("\(validation) var \(parameter.name) = \(parameter.parseExpression)") expressions.append("try await $\(parameter.name).validate()") - expressions.append("") } else { expressions.append("let \(parameter.name) = \(parameter.parseExpression)") } @@ -30,17 +29,18 @@ struct HTTPMethodMacro: PeerMacro { .map { $0.argumentLabel + $0.name } .joined(separator: ", ") - - expressions.append("return " + route.effectsExpression + route.name + arguments.inParentheses) - + let returnExpression = route.responseType != nil ? "return " : "" + expressions.append(returnExpression + route.effectsExpression + route.name + arguments.inParentheses) return [ Declaration("var $\(route.name): Route") { let options = route.options.map { "\n options: \($0)," } ?? "" + let closureArgument = arguments.isEmpty ? "_" : "req" + let returnType = route.responseType ?? "Void" """ Route( method: .\(route.method), path: \(route.path.inQuotes),\(options) - handler: { req in + handler: { \(closureArgument) -> \(returnType) in \(expressions.joined(separator: "\n ")) } ) @@ -70,12 +70,12 @@ extension Routes.Route { } extension EndpointParameter { - var argumentLabel2: String { + var argumentLabel: String { let argumentLabel = label == "_" ? nil : label ?? name return argumentLabel.map { "\($0): " } ?? "" } - var parseExpression2: String { + var parseExpression: String { guard type != "Request" else { return "req" } @@ -94,17 +94,3 @@ extension EndpointParameter { } } } - -/* - var _body: Route { - Route( - method: .POST, - path: "/body", - handler: { req in - @Validate(.email) var thing = try req.content.thing.decode(String.self) - try await $thing.validate() - return body(thing: thing) - } - ) - } - */ diff --git a/Example/App.swift b/Example/App.swift index 98a058b6..943bf5b2 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,5 +1,12 @@ import Alchemy +extension Router { + @discardableResult func use(_ route: Route) -> Self { + on(route.method, at: route.path, options: route.options, use: route.handler) + return self + } +} + struct Route { let method: HTTPMethod let path: String @@ -17,11 +24,25 @@ struct Route { self.method = method self.path = path self.options = options - self.handler = { - try await handler($0) + self.handler = { req in + try await handler(req) return Response(status: .ok) } } + + init(method: HTTPMethod, path: String, options: RouteOptions = [], handler: @escaping (Request) async throws -> E) { + self.method = method + self.path = path + self.options = options + self.handler = { req in + let value = try await handler(req) + if let convertible = value as? ResponseConvertible { + return try await convertible.response() + } else { + return try Response(status: .ok, encodable: value) + } + } + } } @Application @@ -46,18 +67,6 @@ struct App { "Hello" } - var _body: Route { - Route( - method: .POST, - path: "/body", - handler: { req in - @Validate(.email) var thing = try req.content.thing.decode(String.self) - try await $thing.validate() - return body(thing: thing) - } - ) - } - @POST("/body") func body( @Validate(.email) thing: String @@ -84,6 +93,9 @@ struct SomeController { @Validate(.between(18...99)) age: Int, @Validate(.password) password: String ) -> String { "test" } + + @GET("/foo") + func foo() -> Bool { .random() } } extension Application { From 3b8c8be3c416e81ea436ce1c053579a33a47b65b Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 15 Jun 2024 09:05:14 -0700 Subject: [PATCH 28/55] cleanup --- Alchemy/AlchemyMacros.swift | 2 +- ...g+Handlers.swift => Router+Handlers.swift} | 0 Alchemy/Routing/Router+Route.swift | 44 +++++++++ Alchemy/Validation/Validator.swift | 16 +-- AlchemyPlugin/Sources/Macros/JobMacro.swift | 2 +- AlchemyPlugin/Sources/Utilities/Routes.swift | 2 +- Example/App.swift | 98 +++---------------- 7 files changed, 63 insertions(+), 101 deletions(-) rename Alchemy/Routing/{Routing+Handlers.swift => Router+Handlers.swift} (100%) create mode 100644 Alchemy/Routing/Router+Route.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index 656c3d9e..fb5ccaf1 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -12,7 +12,7 @@ public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro // MARK: Route Methods -@attached(peer, names: prefixed(`$`)) public macro HTTP(_ path: String, method: String, options: RouteOptions = []) = #externalMacro(module: "PapyrusPlugin", type: "DecoratorMacro") +@attached(peer, names: prefixed(`$`)) public macro HTTP(_ method: String, _ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") @attached(peer, names: prefixed(`$`)) public macro DELETE(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") @attached(peer, names: prefixed(`$`)) public macro GET(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") @attached(peer, names: prefixed(`$`)) public macro PATCH(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") diff --git a/Alchemy/Routing/Routing+Handlers.swift b/Alchemy/Routing/Router+Handlers.swift similarity index 100% rename from Alchemy/Routing/Routing+Handlers.swift rename to Alchemy/Routing/Router+Handlers.swift diff --git a/Alchemy/Routing/Router+Route.swift b/Alchemy/Routing/Router+Route.swift new file mode 100644 index 00000000..26405f3f --- /dev/null +++ b/Alchemy/Routing/Router+Route.swift @@ -0,0 +1,44 @@ +extension Router { + @discardableResult public func use(_ route: Route) -> Self { + on(route.method, at: route.path, options: route.options, use: route.handler) + return self + } +} + +public struct Route { + public let method: HTTPMethod + public let path: String + public var options: RouteOptions + public let handler: (Request) async throws -> ResponseConvertible + + public init(method: HTTPMethod, path: String, options: RouteOptions = [], handler: @escaping (Request) async throws -> ResponseConvertible) { + self.method = method + self.path = path + self.options = options + self.handler = handler + } + + public init(method: HTTPMethod, path: String, options: RouteOptions = [], handler: @escaping (Request) async throws -> Void) { + self.method = method + self.path = path + self.options = options + self.handler = { req in + try await handler(req) + return Response(status: .ok) + } + } + + public init(method: HTTPMethod, path: String, options: RouteOptions = [], handler: @escaping (Request) async throws -> E) { + self.method = method + self.path = path + self.options = options + self.handler = { req in + let value = try await handler(req) + if let convertible = value as? ResponseConvertible { + return try await convertible.response() + } else { + return try Response(status: .ok, encodable: value) + } + } + } +} diff --git a/Alchemy/Validation/Validator.swift b/Alchemy/Validation/Validator.swift index 73d519c1..125ba795 100644 --- a/Alchemy/Validation/Validator.swift +++ b/Alchemy/Validation/Validator.swift @@ -23,25 +23,11 @@ public struct Validator: @unchecked Sendable { } extension Validator { - public static let username = Validator(validators: .profanity, .email) - public static let profanity = Validator { $0 != "dang" } - - public static let email = Validator { + public static let email = Validator("Invalid email.") { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: $0) } - - public static let password = Validator { - $0.count > 8 && - $0.rangeOfCharacter(from: .decimalDigits) != nil && - $0.rangeOfCharacter(from: .alphanumerics.inverted) != nil - } - - public static let fraud = Validator { - try await Task.sleep(for: .seconds(1)) - return $0 != "fraudman101@fraud.com" - } } extension Validator { diff --git a/AlchemyPlugin/Sources/Macros/JobMacro.swift b/AlchemyPlugin/Sources/Macros/JobMacro.swift index 1561cae8..e3408808 100644 --- a/AlchemyPlugin/Sources/Macros/JobMacro.swift +++ b/AlchemyPlugin/Sources/Macros/JobMacro.swift @@ -26,7 +26,7 @@ struct JobMacro: PeerMacro { let name = function.name.text let prefix = function.callPrefixes.isEmpty ? "" : function.callPrefixes.joined(separator: " ") + " " """ - try await JobContext.$current + \(prefix)JobContext.$current .withValue(context) { \(prefix)\(name)(\(function.jobPassthroughParameterSyntax)) } diff --git a/AlchemyPlugin/Sources/Utilities/Routes.swift b/AlchemyPlugin/Sources/Utilities/Routes.swift index 62e7a1c9..cd003992 100644 --- a/AlchemyPlugin/Sources/Utilities/Routes.swift +++ b/AlchemyPlugin/Sources/Utilities/Routes.swift @@ -82,7 +82,7 @@ extension Routes.Route { path = list.first?.expression.description.withoutQuotes options = list.dropFirst().first?.expression.description.withoutQuotes case "HTTP": - method = list.first?.expression.description.withoutQuotes + method = list.first.map { "RAW(value: \($0.expression.description))" } path = list.dropFirst().first?.expression.description.withoutQuotes options = list.dropFirst().dropFirst().first?.expression.description.withoutQuotes default: diff --git a/Example/App.swift b/Example/App.swift index 943bf5b2..97eabd9f 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,104 +1,36 @@ import Alchemy -extension Router { - @discardableResult func use(_ route: Route) -> Self { - on(route.method, at: route.path, options: route.options, use: route.handler) - return self - } -} - -struct Route { - let method: HTTPMethod - let path: String - var options: RouteOptions - let handler: (Request) async throws -> ResponseConvertible - - init(method: HTTPMethod, path: String, options: RouteOptions = [], handler: @escaping (Request) async throws -> ResponseConvertible) { - self.method = method - self.path = path - self.options = options - self.handler = handler - } - - init(method: HTTPMethod, path: String, options: RouteOptions = [], handler: @escaping (Request) async throws -> Void) { - self.method = method - self.path = path - self.options = options - self.handler = { req in - try await handler(req) - return Response(status: .ok) - } - } - - init(method: HTTPMethod, path: String, options: RouteOptions = [], handler: @escaping (Request) async throws -> E) { - self.method = method - self.path = path - self.options = options - self.handler = { req in - let value = try await handler(req) - if let convertible = value as? ResponseConvertible { - return try await convertible.response() - } else { - return try Response(status: .ok, encodable: value) - } - } - } -} - @Application struct App { func boot() throws { - use(SomeController()) - } - - @GET("/query") - func query(name: String) -> String { - "Hi, \(name)!" - } - - @POST("/foo/:id") - func foo( - id: Int, - @Header one: String, - @URLQuery @Validate(.between(18...99)) two: Int, - three: Bool, - request: Request - ) async throws -> String { - "Hello" + use(UserController()) } - @POST("/body") - func body( - @Validate(.email) thing: String - ) -> String { - thing - } - - @GET("/job") - func job() async throws { - try await $expensive(one: "", two: 1).dispatch() + @POST("/hello") + func helloWorld(email: String) -> String { + "Hello, \(email)!" } @Job - static func expensive(one: String, two: Int) async throws { - print("Hello \(JobContext.current!.jobData.id)") + static func expensiveWork(name: String) { + print("This is expensive!") } } @Controller -struct SomeController { - @POST("/user", options: .stream) - func test( - @Validate(.email) name: String, - @Validate(.between(18...99)) age: Int, - @Validate(.password) password: String - ) -> String { "test" } +struct UserController { + @HTTP("FOO", "/bar", options: .stream) + func bar() { + + } @GET("/foo") - func foo() -> Bool { .random() } + func foo() -> Int { + 123 + } } -extension Application { +extension App { var queues: Queues { Queues( default: "memory", From 7b3bf2a8db0d3ce546fd6adfc31b0a2ede466d02 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 15 Jun 2024 09:28:34 -0700 Subject: [PATCH 29/55] cleanup --- Alchemy/AlchemyMacros.swift | 4 +- Alchemy/AlchemyX/RoutesGenerator.swift | 3 - Alchemy/Application/Application.swift | 14 ++-- .../Sources/Macros/ApplicationMacro.swift | 16 +---- .../Sources/Macros/ControllerMacro.swift | 9 ++- .../Sources/Macros/HTTPMethodMacro.swift | 67 +++++++++++-------- AlchemyPlugin/Sources/Utilities/Routes.swift | 60 ++--------------- Example/App.swift | 2 +- 8 files changed, 61 insertions(+), 114 deletions(-) delete mode 100644 Alchemy/AlchemyX/RoutesGenerator.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index fb5ccaf1..997e433c 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -1,7 +1,7 @@ -@attached(extension, conformances: Application, RoutesGenerator, names: named(addGeneratedRoutes)) +@attached(extension, conformances: Application, Controller, names: named(route)) public macro Application() = #externalMacro(module: "AlchemyPlugin", type: "ApplicationMacro") -@attached(extension, conformances: Controller, RoutesGenerator, names: named(route)) +@attached(extension, conformances: Controller, names: named(route)) public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "ControllerMacro") @attached(peer, names: prefixed(`$`)) diff --git a/Alchemy/AlchemyX/RoutesGenerator.swift b/Alchemy/AlchemyX/RoutesGenerator.swift deleted file mode 100644 index 0ba4e989..00000000 --- a/Alchemy/AlchemyX/RoutesGenerator.swift +++ /dev/null @@ -1,3 +0,0 @@ -public protocol RoutesGenerator { - func addGeneratedRoutes() -} diff --git a/Alchemy/Application/Application.swift b/Alchemy/Application/Application.swift index 31434b1f..9587af75 100644 --- a/Alchemy/Application/Application.swift +++ b/Alchemy/Application/Application.swift @@ -22,8 +22,6 @@ public protocol Application: Router { /// Boots the app's dependencies. Don't override the default for this unless /// you want to prevent default Alchemy services from loading. func bootPlugins() - /// Setup generated routes. - func bootGeneratedRoutes() /// Setup your application here. Called after all services are registered. func boot() throws @@ -72,14 +70,14 @@ public extension Application { } } - func bootGeneratedRoutes() { - (self as? RoutesGenerator)?.addGeneratedRoutes() - } - func boot() throws { // } - + + func bootRouter() { + (self as? Controller)?.route(self) + } + // MARK: Plugin Defaults var http: HTTPConfiguration { HTTPConfiguration() } @@ -101,8 +99,8 @@ public extension Application { func run() async throws { do { bootPlugins() - bootGeneratedRoutes() try boot() + bootRouter() try await start() } catch { commander.exit(error: error) diff --git a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift index 53fe6a4d..5b5582e5 100644 --- a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -18,22 +18,10 @@ struct ApplicationMacro: ExtensionMacro { let routes = try Routes.parse(declaration) return try [ - Declaration("@main extension \(`struct`.name.trimmedDescription)") {}, - Declaration("extension \(`struct`.name.trimmedDescription): Application") {}, - Declaration("extension \(`struct`.name.trimmedDescription): RoutesGenerator") { - routes.generatedRoutesFunction() + Declaration("@main extension \(`struct`.name.trimmedDescription): Application, Controller") { + routes.routeFunction() }, ] .map { try $0.extensionDeclSyntax() } } } - -extension Routes { - func generatedRoutesFunction() -> Declaration { - Declaration("func addGeneratedRoutes()") { - for route in routes { - "use($\(route.name))" - } - } - } -} diff --git a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift index a82a2f0a..32b52e61 100644 --- a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift @@ -17,10 +17,9 @@ struct ControllerMacro: ExtensionMacro { } let routes = try Routes.parse(declaration) - return try [ Declaration("extension \(`struct`.name.trimmedDescription): Controller") { - routes.controllerRouteFunction() + routes.routeFunction() }, ] .map { try $0.extensionDeclSyntax() } @@ -28,10 +27,10 @@ struct ControllerMacro: ExtensionMacro { } extension Routes { - func controllerRouteFunction() -> Declaration { + func routeFunction() -> Declaration { Declaration("func route(_ router: Router)") { - for route in routes { - "router.use($\(route.name))" + for endpoint in endpoints { + "router.use($\(endpoint.name))" } } } diff --git a/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift index aafcaa91..479126ca 100644 --- a/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift +++ b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift @@ -11,12 +11,31 @@ struct HTTPMethodMacro: PeerMacro { throw AlchemyMacroError("@\(node.name) can only be applied to functions") } - guard let route = try Routes.Route.parse(function) else { + guard let endpoint = try Routes.Endpoint.parse(function) else { throw AlchemyMacroError("Unable to parse function for @\(node.name)") } + return [ + endpoint.routeDeclaration() + ] + .map { $0.declSyntax() } + } +} + +extension Routes.Endpoint { + fileprivate func routeDeclaration() -> Declaration { + let arguments = parameters + .map { parameter in + if parameter.type == "Request" { + parameter.argumentLabel + "req" + } else { + parameter.argumentLabel + parameter.name + } + } + .joined(separator: ", ") + var expressions: [String] = [] - for parameter in route.parameters { + for parameter in parameters where parameter.type != "Request" { if let validation = parameter.validation { expressions.append("\(validation) var \(parameter.name) = \(parameter.parseExpression)") expressions.append("try await $\(parameter.name).validate()") @@ -25,33 +44,27 @@ struct HTTPMethodMacro: PeerMacro { } } - let arguments = route.parameters - .map { $0.argumentLabel + $0.name } - .joined(separator: ", ") + let returnExpression = responseType != nil ? "return " : "" + expressions.append(returnExpression + effectsExpression + name + arguments.inParentheses) - let returnExpression = route.responseType != nil ? "return " : "" - expressions.append(returnExpression + route.effectsExpression + route.name + arguments.inParentheses) - return [ - Declaration("var $\(route.name): Route") { - let options = route.options.map { "\n options: \($0)," } ?? "" - let closureArgument = arguments.isEmpty ? "_" : "req" - let returnType = route.responseType ?? "Void" - """ - Route( - method: .\(route.method), - path: \(route.path.inQuotes),\(options) - handler: { \(closureArgument) -> \(returnType) in - \(expressions.joined(separator: "\n ")) - } - ) - """ - }, - ] - .map { $0.declSyntax() } + return Declaration("var $\(name): Route") { + let options = options.map { "\n options: \($0)," } ?? "" + let closureArgument = arguments.isEmpty ? "_" : "req" + let returnType = responseType ?? "Void" + """ + Route( + method: .\(method), + path: \(path.inQuotes),\(options) + handler: { \(closureArgument) -> \(returnType) in + \(expressions.joined(separator: "\n ")) + } + ) + """ + } } } -extension Routes.Route { +extension Routes.Endpoint { fileprivate var routeParametersExpression: String { [path.inQuotes, options.map { "options: \($0)" }] .compactMap { $0 } @@ -70,12 +83,12 @@ extension Routes.Route { } extension EndpointParameter { - var argumentLabel: String { + fileprivate var argumentLabel: String { let argumentLabel = label == "_" ? nil : label ?? name return argumentLabel.map { "\($0): " } ?? "" } - var parseExpression: String { + fileprivate var parseExpression: String { guard type != "Request" else { return "req" } diff --git a/AlchemyPlugin/Sources/Utilities/Routes.swift b/AlchemyPlugin/Sources/Utilities/Routes.swift index cd003992..c7f674bb 100644 --- a/AlchemyPlugin/Sources/Utilities/Routes.swift +++ b/AlchemyPlugin/Sources/Utilities/Routes.swift @@ -2,7 +2,7 @@ import Foundation import SwiftSyntax struct Routes { - struct Route { + struct Endpoint { /// Attributes to be applied to this endpoint. These take precedence /// over attributes at the API scope. let method: String @@ -20,7 +20,7 @@ struct Routes { /// The name of the type defining the API. let name: String /// Attributes to be applied to every endpoint of this API. - let routes: [Route] + let endpoints: [Endpoint] } extension Routes { @@ -31,12 +31,12 @@ extension Routes { return Routes( name: type.name.text, - routes: try type.functions.compactMap( { try Route.parse($0) }) + endpoints: try type.functions.compactMap( { try Endpoint.parse($0) }) ) } } -extension Routes.Route { +extension Routes.Endpoint { var functionSignature: String { let parameters = parameters.map { let name = [$0.label, $0.name] @@ -49,12 +49,12 @@ extension Routes.Route { return parameters.joined(separator: ", ").inParentheses + " async throws" + returnType } - static func parse(_ function: FunctionDeclSyntax) throws -> Routes.Route? { + static func parse(_ function: FunctionDeclSyntax) throws -> Routes.Endpoint? { guard let (method, path, pathParameters, options) = parseMethodAndPath(function) else { return nil } - return Routes.Route( + return Routes.Endpoint( method: method, path: path, pathParameters: pathParameters, @@ -164,26 +164,6 @@ extension StructDeclSyntax { } } -extension ProtocolDeclSyntax { - var protocolName: String { - name.text - } - - var access: String? { - modifiers.first?.trimmedDescription - } - - var functions: [FunctionDeclSyntax] { - memberBlock - .members - .compactMap { $0.decl.as(FunctionDeclSyntax.self) } - } - - var protocolAttributes: [AttributeSyntax] { - attributes.compactMap { $0.as(AttributeSyntax.self) } - } -} - extension FunctionDeclSyntax { // MARK: Function effects & attributes @@ -192,12 +172,6 @@ extension FunctionDeclSyntax { name.text } - var effects: [String] { - [signature.effectSpecifiers?.asyncSpecifier, signature.effectSpecifiers?.throwsSpecifier] - .compactMap { $0 } - .map { $0.text } - } - var parameters: [FunctionParameterSyntax] { signature .parameterClause @@ -211,21 +185,9 @@ extension FunctionDeclSyntax { // MARK: Return Data - var returnsResponse: Bool { - returnType == "Response" - } - var returnType: String? { signature.returnClause?.type.trimmedDescription } - - var returnsVoid: Bool { - guard let returnType else { - return true - } - - return returnType == "Void" - } } extension FunctionParameterSyntax { @@ -250,16 +212,6 @@ extension AttributeSyntax { var name: String { attributeName.trimmedDescription } - - var labeledArguments: [(label: String?, value: String)] { - guard case let .argumentList(list) = arguments else { - return [] - } - - return list.map { - ($0.label?.text, $0.expression.description) - } - } } extension String { diff --git a/Example/App.swift b/Example/App.swift index 97eabd9f..cd5f71ed 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -25,7 +25,7 @@ struct UserController { } @GET("/foo") - func foo() -> Int { + func foo(field1: String, request: Request) -> Int { 123 } } From 908066c2b529190d0cf2978bc3f787a79f1ace7a Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 15 Jun 2024 10:31:47 -0700 Subject: [PATCH 30/55] Add model macro --- Alchemy/AlchemyMacros.swift | 3 +- AlchemyPlugin/Sources/Macros/ModelMacro.swift | 162 +++++++++++++++++- Example/App.swift | 7 + 3 files changed, 168 insertions(+), 4 deletions(-) diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index 997e433c..892a6794 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -7,7 +7,8 @@ public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "Contr @attached(peer, names: prefixed(`$`)) public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") -@attached(member, names: arbitrary) +@attached(member, names: named(storage)) +@attached(extension, conformances: Model) public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro") // MARK: Route Methods diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index 884db41b..c89dd24d 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -1,12 +1,168 @@ import SwiftSyntax import SwiftSyntaxMacros -struct ModelMacro: PeerMacro { +struct ModelMacro: MemberMacro, ExtensionMacro { + + // MARK: ExtensionMacro + static func expansion( of node: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard let `struct` = declaration.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("@Model can only be used on a struct") + } + + return try [ + Declaration("extension \(`struct`.name.trimmedDescription): Model {}") + ] + .map { try $0.extensionDeclSyntax() } + } + + // MARK: Member Macro + + static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - [] + guard let `struct` = declaration.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("@Model can only be used on a struct") + } + + return [ + Declaration("var storage: String") { + `struct`.name.trimmedDescription.inQuotes + } + ] + .map { $0.declSyntax() } + } +} + +struct Resource { + struct Property { + let keyword: String + let name: String + let type: String + let defaultValue: String? + let isStored: Bool + + var isOptional: Bool { + type.last == "?" + } + } + + /// The type's access level - public, private, etc + let accessLevel: String? + /// The type name + let name: String + /// The type's properties + let properties: [Property] + + /// The type's stored properties + var storedProperties: [Property] { + properties.filter(\.isStored) + } +} + +extension Resource { + static func parse(syntax: DeclSyntaxProtocol) throws -> Resource { + guard let `struct` = syntax.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("For now, @Resource can only be applied to a struct") + } + + return Resource( + accessLevel: `struct`.accessLevel, + name: `struct`.structName, + properties: `struct`.members.map(Resource.Property.parse) + ) + } +} + +extension Resource.Property { + static func parse(variable: VariableDeclSyntax) -> Resource.Property { + let patterns = variable.bindings.compactMap { PatternBindingSyntax.init($0) } + let keyword = variable.bindingSpecifier.text + let name = "\(patterns.first!.pattern.as(IdentifierPatternSyntax.self)!.identifier.text)" + let type = "\(patterns.first!.typeAnnotation!.type.trimmed)" + let defaultValue = patterns.first!.initializer.map { "\($0.value.trimmed)" } + let isStored = patterns.first?.accessorBlock == nil + + return Resource.Property( + keyword: keyword, + name: name, + type: type, + defaultValue: defaultValue, + isStored: isStored + ) + } +} + +extension Resource { + fileprivate func generateInitializer() -> Declaration { + let parameters = storedProperties.map { + if let defaultValue = $0.defaultValue { + "\($0.name): \($0.type) = \(defaultValue)" + } else if $0.isOptional && $0.keyword == "var" { + "\($0.name): \($0.type) = nil" + } else { + "\($0.name): \($0.type)" + } + } + .joined(separator: ", ") + return Declaration("init(\(parameters))") { + for property in storedProperties { + "self.\(property.name) = \(property.name)" + } + } + .access(accessLevel) + } + + fileprivate func generateFieldLookup() -> Declaration { + let fieldsString = storedProperties + .map { property in + let key = "\\\(name).\(property.name)" + let defaultValue = property.defaultValue + let defaultArgument = defaultValue.map { ", default: \($0)" } ?? "" + let value = ".init(\(property.name.inQuotes), type: \(property.type).self\(defaultArgument))" + return "\(key): \(value)" + } + .joined(separator: ",\n") + return Declaration(""" + public static let fields: [PartialKeyPath<\(name)>: ResourceField] = [ + \(fieldsString) + ] + """) + } +} + +extension DeclGroupSyntax { + var hasInit: Bool { + !initializers.isEmpty + } + + var initializers: [InitializerDeclSyntax] { + memberBlock + .members + .compactMap { $0.decl.as(InitializerDeclSyntax.self) } + } + + var accessLevel: String? { + modifiers.first?.trimmedDescription + } + + var members: [VariableDeclSyntax] { + memberBlock + .members + .compactMap { $0.decl.as(VariableDeclSyntax.self) } + } +} + +extension StructDeclSyntax { + var structName: String { + name.text } } diff --git a/Example/App.swift b/Example/App.swift index cd5f71ed..64a3c277 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -30,6 +30,13 @@ struct UserController { } } +@Model +struct Todo: Codable { + var id: PK + var name: String + var isDone: Bool = false +} + extension App { var queues: Queues { Queues( From 4892649c84c81f58b69b04c9a4a091e8c276879e Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 17 Jun 2024 15:13:20 -0700 Subject: [PATCH 31/55] ModelMacro first stab --- Alchemy/AlchemyMacros.swift | 8 +- Alchemy/AlchemyX/Application+AlchemyX.swift | 16 ++- Alchemy/AlchemyX/Router+Resource.swift | 4 +- Alchemy/Cache/Providers/DatabaseCache.swift | 5 +- .../Migrations/Database+Migration.swift | 5 +- .../Providers/MySQL/MySQLGrammar.swift | 2 +- .../Providers/SQLite/SQLiteGrammar.swift | 2 +- Alchemy/Database/Query/Query.swift | 18 +-- Alchemy/Database/Query/SQLGrammar.swift | 20 +-- .../Rune/Model/Coding/SQLRowDecoder.swift | 2 +- .../Rune/Model/Coding/SQLRowEncoder.swift | 4 +- Alchemy/Database/Rune/Model/Model+CRUD.swift | 20 ++- Alchemy/Database/Rune/Model/Model+Dirty.swift | 8 +- Alchemy/Database/Rune/Model/Model.swift | 13 +- Alchemy/Database/Rune/Model/ModelField.swift | 18 +++ Alchemy/Database/Rune/Model/PK.swift | 114 ++------------- Alchemy/Database/Rune/Model/SoftDeletes.swift | 4 +- .../Rune/Relations/BelongsToMany.swift | 12 +- Alchemy/Database/SQL/SQLFields.swift | 9 ++ Alchemy/Database/SQL/SQLRow.swift | 31 ++-- Alchemy/Database/SQL/SQLValue.swift | 14 ++ Alchemy/Database/Schema/Database+Schema.swift | 2 +- Alchemy/Database/Seeding/Seedable.swift | 5 +- Alchemy/Queue/Providers/DatabaseQueue.swift | 9 +- AlchemyPlugin/Sources/AlchemyPlugin.swift | 1 + AlchemyPlugin/Sources/Macros/ModelMacro.swift | 132 +++++++++++++----- Example/App.swift | 20 ++- .../Model/Coding/SQLRowEncoderTests.swift | 2 +- Tests/Encryption/EncryptionTests.swift | 8 +- 29 files changed, 267 insertions(+), 241 deletions(-) create mode 100644 Alchemy/Database/Rune/Model/ModelField.swift create mode 100644 Alchemy/Database/SQL/SQLFields.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index 892a6794..b2c3f2f9 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -7,10 +7,14 @@ public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "Contr @attached(peer, names: prefixed(`$`)) public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") -@attached(member, names: named(storage)) -@attached(extension, conformances: Model) +@attached(memberAttribute) +@attached(member, names: named(storage), named(fieldLookup)) +@attached(extension, conformances: Model, names: named(init), named(fields)) public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro") +@attached(accessor) +public macro ID() = #externalMacro(module: "AlchemyPlugin", type: "IDMacro") + // MARK: Route Methods @attached(peer, names: prefixed(`$`)) public macro HTTP(_ method: String, _ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") diff --git a/Alchemy/AlchemyX/Application+AlchemyX.swift b/Alchemy/AlchemyX/Application+AlchemyX.swift index 630b19ec..97f96366 100644 --- a/Alchemy/AlchemyX/Application+AlchemyX.swift +++ b/Alchemy/AlchemyX/Application+AlchemyX.swift @@ -22,7 +22,7 @@ private struct AuthController: Controller, AuthAPI { func signUp(email: String, password: String) async throws -> AuthResponse { let password = try await Hash.make(password) let user = try await User(email: email, password: password).insertReturn() - let token = try await Token(userId: user.id()).insertReturn() + let token = try await Token(userId: user.id).insertReturn() return .init(token: token.value, user: user.dto) } @@ -35,7 +35,7 @@ private struct AuthController: Controller, AuthAPI { throw HTTPError(.unauthorized) } - let token = try await Token(userId: user.id()).insertReturn() + let token = try await Token(userId: user.id).insertReturn() return .init(token: token.value, user: user.dto) } @@ -62,10 +62,11 @@ extension Controller { fileprivate var token: Token { get throws { try req.get() } } } -struct Token: Model, Codable, TokenAuthable { +@Model +struct Token: TokenAuthable { typealias Authorizes = User - var id: PK = .new + var id: UUID var value: String = UUID().uuidString let userId: UUID @@ -74,8 +75,9 @@ struct Token: Model, Codable, TokenAuthable { } } -struct User: Model, Codable { - var id: PK = .new +@Model +struct User { + var id: UUID var email: String var password: String var phone: String? @@ -86,7 +88,7 @@ struct User: Model, Codable { var dto: AlchemyX.User { AlchemyX.User( - id: id(), + id: id, email: email, phone: phone ) diff --git a/Alchemy/AlchemyX/Router+Resource.swift b/Alchemy/AlchemyX/Router+Resource.swift index 749bfb33..b3c5f398 100644 --- a/Alchemy/AlchemyX/Router+Resource.swift +++ b/Alchemy/AlchemyX/Router+Resource.swift @@ -83,7 +83,7 @@ private struct ResourceController: Controller private func create(req: Request) async throws -> R { let resource = try req.decode(R.self) var fields = try resource.sqlFields() - fields["user_id"] = try SQLValue.uuid(req.user.id()) + fields["user_id"] = try SQLValue.uuid(req.user.id) return try await table .insertReturn(fields) .decode(keyMapping: db.keyMapping) @@ -153,6 +153,6 @@ extension Request { extension Query { fileprivate func ownedBy(_ user: User) throws -> Self { - return `where`("user_id" == user.id()) + return `where`("user_id" == user.id) } } diff --git a/Alchemy/Cache/Providers/DatabaseCache.swift b/Alchemy/Cache/Providers/DatabaseCache.swift index ce9d6071..7ad02705 100644 --- a/Alchemy/Cache/Providers/DatabaseCache.swift +++ b/Alchemy/Cache/Providers/DatabaseCache.swift @@ -97,10 +97,11 @@ extension Cache { } /// Model for storing cache data -private struct CacheItem: Model, Codable { +@Model +private struct CacheItem { static let table = "cache" - var id: PK = .new + var id: Int let key: String var value: String var expiration: Int = -1 diff --git a/Alchemy/Database/Migrations/Database+Migration.swift b/Alchemy/Database/Migrations/Database+Migration.swift index 5f481643..3a5a8247 100644 --- a/Alchemy/Database/Migrations/Database+Migration.swift +++ b/Alchemy/Database/Migrations/Database+Migration.swift @@ -2,7 +2,8 @@ extension Database { /// Represents a table for storing migration data. Alchemy will use /// this table for keeping track of the various batches of /// migrations that have been run. - struct AppliedMigration: Model, Codable { + @Model + struct AppliedMigration { /// A migration for adding the `AlchemyMigration` table. struct Migration: Alchemy.Migration { func up(db: Database) async throws { @@ -22,7 +23,7 @@ extension Database { static let table = "migrations" /// Serial primary key. - var id: PK = .new + var id: Int /// The name of the migration. let name: String diff --git a/Alchemy/Database/Providers/MySQL/MySQLGrammar.swift b/Alchemy/Database/Providers/MySQL/MySQLGrammar.swift index 14b07170..f71f14ee 100644 --- a/Alchemy/Database/Providers/MySQL/MySQLGrammar.swift +++ b/Alchemy/Database/Providers/MySQL/MySQLGrammar.swift @@ -1,6 +1,6 @@ /// A MySQL specific Grammar for compiling `Query` to SQL. struct MySQLGrammar: SQLGrammar { - func insertReturn(_ table: String, values: [[String : SQLConvertible]]) -> [SQL] { + func insertReturn(_ table: String, values: [SQLFields]) -> [SQL] { values.flatMap { [ insert(table, values: [$0]), diff --git a/Alchemy/Database/Providers/SQLite/SQLiteGrammar.swift b/Alchemy/Database/Providers/SQLite/SQLiteGrammar.swift index 639be4dc..b9c07b5a 100644 --- a/Alchemy/Database/Providers/SQLite/SQLiteGrammar.swift +++ b/Alchemy/Database/Providers/SQLite/SQLiteGrammar.swift @@ -1,5 +1,5 @@ struct SQLiteGrammar: SQLGrammar { - func insertReturn(_ table: String, values: [[String : SQLConvertible]]) -> [SQL] { + func insertReturn(_ table: String, values: [SQLFields]) -> [SQL] { return values.flatMap { fields -> [SQL] in // If the id is already set, search the database for that. Otherwise // assume id is autoincrementing and search for the last rowid. diff --git a/Alchemy/Database/Query/Query.swift b/Alchemy/Database/Query/Query.swift index 4668c6e0..207018a3 100644 --- a/Alchemy/Database/Query/Query.swift +++ b/Alchemy/Database/Query/Query.swift @@ -143,12 +143,12 @@ open class Query: SQLConvertible { // MARK: INSERT /// Perform an insert and create a database row from the provided data. - public func insert(_ value: [String: SQLConvertible]) async throws { + public func insert(_ value: SQLFields) async throws { try await insert([value]) } /// Perform an insert and create database rows from the provided data. - public func insert(_ values: [[String: SQLConvertible]]) async throws { + public func insert(_ values: [SQLFields]) async throws { guard let table else { throw DatabaseError("Table required to run query - use `.from(...)` to set one.") } @@ -169,7 +169,7 @@ open class Query: SQLConvertible { try await insert(try encodables.map { try $0.sqlFields(keyMapping: keyMapping, jsonEncoder: jsonEncoder) }) } - public func insertReturn(_ values: [String: SQLConvertible]) async throws -> SQLRow { + public func insertReturn(_ values: SQLFields) async throws -> SQLRow { guard let first = try await insertReturn([values]).first else { throw DatabaseError("INSERT didn't return any rows.") } @@ -182,7 +182,7 @@ open class Query: SQLConvertible { /// - Parameter values: An array of dictionaries containing the values to be /// inserted. /// - Returns: The inserted rows. - public func insertReturn(_ values: [[String: SQLConvertible]]) async throws -> [SQLRow] { + public func insertReturn(_ values: [SQLFields]) async throws -> [SQLRow] { guard let table else { throw DatabaseError("Table required to run query - use `.from(...)` to set one.") } @@ -224,11 +224,11 @@ open class Query: SQLConvertible { // MARK: UPSERT - public func upsert(_ value: [String: SQLConvertible], conflicts: [String] = ["id"]) async throws { + public func upsert(_ value: SQLFields, conflicts: [String] = ["id"]) async throws { try await upsert([value], conflicts: conflicts) } - public func upsert(_ values: [[String: SQLConvertible]], conflicts: [String] = ["id"]) async throws { + public func upsert(_ values: [SQLFields], conflicts: [String] = ["id"]) async throws { guard let table else { throw DatabaseError("Table required to run query - use `.from(...)` to set one.") } @@ -249,11 +249,11 @@ open class Query: SQLConvertible { try await upsert(try encodables.map { try $0.sqlFields(keyMapping: keyMapping, jsonEncoder: jsonEncoder) }) } - public func upsertReturn(_ values: [String: SQLConvertible], conflicts: [String] = ["id"]) async throws -> [SQLRow] { + public func upsertReturn(_ values: SQLFields, conflicts: [String] = ["id"]) async throws -> [SQLRow] { try await upsertReturn([values], conflicts: conflicts) } - public func upsertReturn(_ values: [[String: SQLConvertible]], conflicts: [String] = ["id"]) async throws -> [SQLRow] { + public func upsertReturn(_ values: [SQLFields], conflicts: [String] = ["id"]) async throws -> [SQLRow] { guard let table else { throw DatabaseError("Table required to run query - use `.from(...)` to set one.") } @@ -299,7 +299,7 @@ open class Query: SQLConvertible { /// /// - Parameter fields: An dictionary containing the values to be /// updated. - public func update(_ fields: [String: SQLConvertible]) async throws { + public func update(_ fields: SQLFields) async throws { guard let table else { throw DatabaseError("Table required to run query - use `.from(...)` to set one.") } diff --git a/Alchemy/Database/Query/SQLGrammar.swift b/Alchemy/Database/Query/SQLGrammar.swift index 68d05313..6cae9c62 100644 --- a/Alchemy/Database/Query/SQLGrammar.swift +++ b/Alchemy/Database/Query/SQLGrammar.swift @@ -26,20 +26,20 @@ public protocol SQLGrammar { // MARK: INSERT func insert(_ table: String, columns: [String], sql: SQL) -> SQL - func insert(_ table: String, values: [[String: SQLConvertible]]) -> SQL - func insertReturn(_ table: String, values: [[String: SQLConvertible]]) -> [SQL] + func insert(_ table: String, values: [SQLFields]) -> SQL + func insertReturn(_ table: String, values: [SQLFields]) -> [SQL] // MARK: UPSERT - func upsert(_ table: String, values: [[String: SQLConvertible]], conflictKeys: [String]) -> SQL - func upsertReturn(_ table: String, values: [[String: SQLConvertible]], conflictKeys: [String]) -> [SQL] + func upsert(_ table: String, values: [SQLFields], conflictKeys: [String]) -> SQL + func upsertReturn(_ table: String, values: [SQLFields], conflictKeys: [String]) -> [SQL] // MARK: UPDATE func update(table: String, joins: [SQLJoin], wheres: [SQLWhere], - fields: [String: SQLConvertible]) -> SQL + fields: SQLFields) -> SQL // MARK: DELETE @@ -193,7 +193,7 @@ extension SQLGrammar { SQL("INSERT INTO \(table)(\(columns.joined(separator: ", "))) \(sql.statement)", parameters: sql.parameters) } - public func insert(_ table: String, values: [[String: SQLConvertible]]) -> SQL { + public func insert(_ table: String, values: [SQLFields]) -> SQL { guard !values.isEmpty else { return SQL("INSERT INTO \(table) DEFAULT VALUES") } @@ -212,13 +212,13 @@ extension SQLGrammar { return SQL("INSERT INTO \(table) (\(columnsJoined)) VALUES \(placeholders.joined(separator: ", "))", input: input) } - public func insertReturn(_ table: String, values: [[String: SQLConvertible]]) -> [SQL] { + public func insertReturn(_ table: String, values: [SQLFields]) -> [SQL] { [insert(table, values: values) + " RETURNING *"] } // MARK: UPSERT - public func upsert(_ table: String, values: [[String: SQLConvertible]], conflictKeys: [String]) -> SQL { + public func upsert(_ table: String, values: [SQLFields], conflictKeys: [String]) -> SQL { var upsert = insert(table, values: values) guard !values.isEmpty else { return upsert @@ -239,7 +239,7 @@ extension SQLGrammar { return upsert } - public func upsertReturn(_ table: String, values: [[String: SQLConvertible]], conflictKeys: [String]) -> [SQL] { + public func upsertReturn(_ table: String, values: [SQLFields], conflictKeys: [String]) -> [SQL] { [upsert(table, values: values, conflictKeys: conflictKeys) + " RETURNING *"] } @@ -248,7 +248,7 @@ extension SQLGrammar { public func update(table: String, joins: [SQLJoin], wheres: [SQLWhere], - fields: [String: SQLConvertible]) -> SQL { + fields: SQLFields) -> SQL { var parameters: [SQLValue] = [] var base = "UPDATE \(table)" if let joinSQL = compileJoins(joins) { diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowDecoder.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowDecoder.swift index 2cdc2615..df600158 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowDecoder.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowDecoder.swift @@ -85,7 +85,7 @@ struct SQLRowDecoder: Decoder, SQLRowReader { } func singleValueContainer() throws -> SingleValueDecodingContainer { - guard let firstColumn = row.fields.first?.column else { + guard let firstColumn = row.fields.elements.first?.key else { throw DatabaseError("SQLRow had no fields to decode a value from.") } diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift index 660f89f0..a78dab9d 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift @@ -40,7 +40,7 @@ final class SQLRowEncoder: Encoder, SQLRowWriter { /// Used for keeping track of the database fields pulled off the /// object encoded to this encoder. - private var fields: [String: SQLConvertible] = [:] + private var fields: SQLFields = [:] /// The mapping strategy for associating `CodingKey`s on an object /// with column names in a database. @@ -65,7 +65,7 @@ final class SQLRowEncoder: Encoder, SQLRowWriter { /// - Throws: A `DatabaseError` if there is an error reading /// fields from `value`. /// - Returns: An ordered dictionary of the model's columns and values. - func fields(for value: E) throws -> [String: SQLConvertible] { + func fields(for value: E) throws -> SQLFields { try value.encode(to: self) defer { fields = [:] } return fields diff --git a/Alchemy/Database/Rune/Model/Model+CRUD.swift b/Alchemy/Database/Rune/Model/Model+CRUD.swift index eb38394e..aeead9fd 100644 --- a/Alchemy/Database/Rune/Model/Model+CRUD.swift +++ b/Alchemy/Database/Rune/Model/Model+CRUD.swift @@ -86,7 +86,7 @@ extension Model { } self.row = model.row - self.id.value = model.id.value + self.id = model.id return model } @@ -120,7 +120,7 @@ extension Model { } @discardableResult - public func update(on db: Database = database, _ fields: [String: SQLConvertible]) async throws -> Self { + public func update(on db: Database = database, _ fields: SQLFields) async throws -> Self { try await [self].updateAll(on: db, fields) return try await refresh(on: db) } @@ -142,7 +142,7 @@ extension Model { } self.row = model.row - self.id.value = model.id.value + self.id = model.id return model } @@ -188,7 +188,7 @@ extension Model { /// Fetches an copy of this model from a database, with any updates that may /// have been made since it was last fetched. public func refresh(on db: Database = database) async throws -> Self { - let model = try await Self.require(id.require(), db: db) + let model = try await Self.require(id, db: db) row = model.row model.mergeCache(self) return model @@ -232,7 +232,7 @@ extension Array where Element: Model { try await _insertReturnAll(on: db) } - func _insertReturnAll(on db: Database = Element.database, fieldOverrides: [String: SQLConvertible] = [:]) async throws -> Self { + func _insertReturnAll(on db: Database = Element.database, fieldOverrides: SQLFields = [:]) async throws -> Self { let fields = try insertableFields(on: db).map { $0 + fieldOverrides } try await Element.willCreate(self) let results = try await Element.query(on: db) @@ -250,7 +250,7 @@ extension Array where Element: Model { return try await updateAll(on: db, values) } - public func updateAll(on db: Database = Element.database, _ fields: [String: SQLConvertible]) async throws { + public func updateAll(on db: Database = Element.database, _ fields: SQLFields) async throws { let ids = map(\.id) let fields = touchUpdatedAt(on: db, fields) try await Element.willUpdate(self) @@ -300,10 +300,6 @@ extension Array where Element: Model { return self } - guard allSatisfy({ $0.id.value != nil }) else { - throw RuneError("Can't .refresh() an object with a nil `id`.") - } - let byId = keyed(by: \.id) let refreshed = try await Element.query() .where(Element.primaryKey, in: byId.keys.array) @@ -319,7 +315,7 @@ extension Array where Element: Model { return refreshed } - private func touchUpdatedAt(on db: Database, _ fields: [String: SQLConvertible]) -> [String: SQLConvertible] { + private func touchUpdatedAt(on db: Database, _ fields: SQLFields) -> SQLFields { guard let timestamps = Element.self as? Timestamped.Type else { return fields } @@ -329,7 +325,7 @@ extension Array where Element: Model { return fields } - private func insertableFields(on db: Database) throws -> [[String: SQLConvertible]] { + private func insertableFields(on db: Database) throws -> [SQLFields] { guard let timestamps = Element.self as? Timestamped.Type else { return try map { try $0.fields() } } diff --git a/Alchemy/Database/Rune/Model/Model+Dirty.swift b/Alchemy/Database/Rune/Model/Model+Dirty.swift index c3b98d35..b8094dfb 100644 --- a/Alchemy/Database/Rune/Model/Model+Dirty.swift +++ b/Alchemy/Database/Rune/Model/Model+Dirty.swift @@ -1,3 +1,5 @@ +import Collections + extension Model { // MARK: Dirty @@ -10,15 +12,15 @@ extension Model { (try? dirtyFields()[column]) != nil } - public func dirtyFields() throws -> [String: SQL] { - let oldFields = row?.fieldDictionary.filter { $0.value != .null }.mapValues(\.sql) ?? [:] + public func dirtyFields() throws -> SQLFields { + let oldFields = row?.fields.filter { $0.value.sqlValue != .null }.mapValues(\.sql) ?? [:] let newFields = try fields().mapValues(\.sql) var dirtyFields = newFields.filter { $0.value != oldFields[$0.key] } for key in Set(oldFields.keys).subtracting(newFields.keys) { dirtyFields[key] = .null } - return dirtyFields + return dirtyFields.mapValues { $0 } } // MARK: Clean diff --git a/Alchemy/Database/Rune/Model/Model.swift b/Alchemy/Database/Rune/Model/Model.swift index 92877096..a3f372d8 100644 --- a/Alchemy/Database/Rune/Model/Model.swift +++ b/Alchemy/Database/Rune/Model/Model.swift @@ -7,11 +7,14 @@ public protocol Model: Identifiable, QueryResult, ModelOrOptional { /// The type of this object's primary key. associatedtype PrimaryKey: PrimaryKeyProtocol - /// The identifier / primary key of this type. - var id: PK { get set } + /// Storage for loaded information. + var storage: ModelStorage { get } + + /// The identifier of this model + var id: PrimaryKey { get nonmutating set } /// Convert this to an SQLRow for updating or inserting into a database. - func fields() throws -> [String: SQLConvertible] + func fields() throws -> SQLFields /// The database on which this model is saved & queried by default. static var database: Database { get } @@ -73,13 +76,13 @@ extension Model where Self: Codable { self = try row.decode(Self.self, keyMapping: Self.keyMapping, jsonDecoder: Self.jsonDecoder) } - public func fields() throws -> [String: SQLConvertible] { + public func fields() throws -> SQLFields { try sqlFields(keyMapping: Self.keyMapping, jsonEncoder: Self.jsonEncoder) } } extension Encodable { - func sqlFields(keyMapping: KeyMapping = .snakeCase, jsonEncoder: JSONEncoder = JSONEncoder()) throws -> [String: SQLConvertible] { + func sqlFields(keyMapping: KeyMapping = .snakeCase, jsonEncoder: JSONEncoder = JSONEncoder()) throws -> SQLFields { try SQLRowEncoder(keyMapping: keyMapping, jsonEncoder: jsonEncoder).fields(for: self) } } diff --git a/Alchemy/Database/Rune/Model/ModelField.swift b/Alchemy/Database/Rune/Model/ModelField.swift new file mode 100644 index 00000000..989268e7 --- /dev/null +++ b/Alchemy/Database/Rune/Model/ModelField.swift @@ -0,0 +1,18 @@ +import Collections + +public struct ModelField: Identifiable { + public var id: String { name } + public let name: String + public let type: Any.Type + public let `default`: Any? + + public init(_ name: String, type: T.Type, default: T? = nil) { + self.name = name + self.type = type + self.default = `default` + } +} + +extension Model { + public typealias FieldLookup = OrderedDictionary, ModelField> +} diff --git a/Alchemy/Database/Rune/Model/PK.swift b/Alchemy/Database/Rune/Model/PK.swift index 9326863f..bfcec0f3 100644 --- a/Alchemy/Database/Rune/Model/PK.swift +++ b/Alchemy/Database/Rune/Model/PK.swift @@ -1,106 +1,8 @@ -public final class PK: Codable, Hashable, SQLValueConvertible, ModelProperty, CustomDebugStringConvertible { - public var value: Identifier? - fileprivate var storage: ModelStorage - - public var sqlValue: SQLValue { - value.sqlValue - } - - init(_ value: Identifier?) { - self.value = value - self.storage = .new - } - - public var debugDescription: String { - value.map { "\($0)" } ?? "null" - } - - public func require() throws -> Identifier { - guard let value else { - throw DatabaseError("Object of type \(type(of: self)) had a nil id.") - } - - return value - } - - public func callAsFunction() -> Identifier { - try! require() - } - - // MARK: ModelProperty - - public init(key: String, on row: SQLRowReader) throws { - self.storage = .new - self.storage.row = row.row - self.value = try Identifier(value: row.require(key)) - } - - public func store(key: String, on row: inout SQLRowWriter) throws { - if let value { - row.put(value, at: key) - } - } - - // MARK: Codable - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(value) - } - - public init(from decoder: Decoder) throws { - self.value = try decoder.singleValueContainer().decode(Identifier?.self) - self.storage = .new - } - - // MARK: Equatable - - public static func == (lhs: PK, rhs: PK) -> Bool { - lhs.value == rhs.value - } - - // MARK: Hashable - - public func hash(into hasher: inout Swift.Hasher) { - hasher.combine(value) - } - - public static var new: Self { .init(nil) } - public static func new(_ value: Identifier) -> Self { .init(value) } - public static func existing(_ value: Identifier) -> Self { .init(value) } -} - -extension PK: ExpressibleByNilLiteral { - public convenience init(nilLiteral: ()) { - self.init(nil) - } -} - -extension PK: ExpressibleByIntegerLiteral { - public convenience init(integerLiteral value: Int) { - self.init(value) - } -} - -extension PK: ExpressibleByStringLiteral, ExpressibleByExtendedGraphemeClusterLiteral, ExpressibleByUnicodeScalarLiteral { - public convenience init(unicodeScalarLiteral value: String) { - self.init(value) - } - - public convenience init(extendedGraphemeClusterLiteral value: String) { - self.init(value) - } - - public convenience init(stringLiteral value: String) { - self.init(value) - } -} - -private final class ModelStorage { +public final class ModelStorage { var row: SQLRow? var relationships: [String: Any] - init() { + public init() { self.relationships = [:] self.row = nil } @@ -112,20 +14,20 @@ private final class ModelStorage { extension Model { public internal(set) var row: SQLRow? { - get { id.storage.row } - nonmutating set { id.storage.row = newValue } + get { storage.row } + nonmutating set { storage.row = newValue } } func mergeCache(_ otherModel: Self) { - id.storage.relationships = otherModel.id.storage.relationships + storage.relationships = otherModel.storage.relationships } func cache(_ value: To, at key: String) { - id.storage.relationships[key] = value + storage.relationships[key] = value } func cached(at key: String, _ type: To.Type = To.self) throws -> To? { - guard let value = id.storage.relationships[key] else { + guard let value = storage.relationships[key] else { return nil } @@ -137,6 +39,6 @@ extension Model { } func cacheExists(_ key: String) -> Bool { - id.storage.relationships[key] != nil + storage.relationships[key] != nil } } diff --git a/Alchemy/Database/Rune/Model/SoftDeletes.swift b/Alchemy/Database/Rune/Model/SoftDeletes.swift index 06f1aecc..f6f812b7 100644 --- a/Alchemy/Database/Rune/Model/SoftDeletes.swift +++ b/Alchemy/Database/Rune/Model/SoftDeletes.swift @@ -15,9 +15,9 @@ extension SoftDeletes where Self: Model { get { try? row?[Self.deletedAtKey]?.date() } nonmutating set { guard let row else { return } - var dict = row.fieldDictionary + var dict = row.fields dict[Self.deletedAtKey] = newValue.map { .date($0) } ?? .null - self.row = SQLRow(dictionary: dict) + self.row = SQLRow(fields: dict) } } } diff --git a/Alchemy/Database/Rune/Relations/BelongsToMany.swift b/Alchemy/Database/Rune/Relations/BelongsToMany.swift index 79b1fcd4..d9344489 100644 --- a/Alchemy/Database/Rune/Relations/BelongsToMany.swift +++ b/Alchemy/Database/Rune/Relations/BelongsToMany.swift @@ -1,3 +1,5 @@ +import Collections + extension Model { public typealias BelongsToMany = BelongsToManyRelation @@ -28,11 +30,11 @@ public class BelongsToManyRelation: Relation { _through(table: pivot, from: pivotFrom, to: pivotTo) } - public func connect(_ model: M, pivotFields: [String: SQLConvertible] = [:]) async throws { + public func connect(_ model: M, pivotFields: SQLFields = [:]) async throws { try await connect([model], pivotFields: pivotFields) } - public func connect(_ models: [M], pivotFields: [String: SQLConvertible] = [:]) async throws { + public func connect(_ models: [M], pivotFields: SQLFields = [:]) async throws { let from = try requireFromValue() let tos = try models.map { try requireToValue($0) } guard fromKey.string != toKey.string else { @@ -43,11 +45,11 @@ public class BelongsToManyRelation: Relation { try await db.table(pivot.table).insert(fieldsArray) } - public func connectOrUpdate(_ model: M, pivotFields: [String: SQLConvertible] = [:]) async throws { + public func connectOrUpdate(_ model: M, pivotFields: SQLFields = [:]) async throws { try await connectOrUpdate([model], pivotFields: pivotFields) } - public func connectOrUpdate(_ models: [M], pivotFields: [String: SQLConvertible] = [:]) async throws { + public func connectOrUpdate(_ models: [M], pivotFields: SQLFields = [:]) async throws { let from = try requireFromValue() let tos = try models.map { try (requireToValue($0), $0) } @@ -70,7 +72,7 @@ public class BelongsToManyRelation: Relation { try await connect(notExisting, pivotFields: pivotFields) } - public func replace(_ models: [M], pivotFields: [String: SQLConvertible] = [:]) async throws { + public func replace(_ models: [M], pivotFields: SQLFields = [:]) async throws { try await disconnectAll() try await connect(models, pivotFields: pivotFields) } diff --git a/Alchemy/Database/SQL/SQLFields.swift b/Alchemy/Database/SQL/SQLFields.swift new file mode 100644 index 00000000..65fbabfa --- /dev/null +++ b/Alchemy/Database/SQL/SQLFields.swift @@ -0,0 +1,9 @@ +import Collections + +public typealias SQLFields = OrderedDictionary + +extension SQLFields { + public static func + (lhs: SQLFields, rhs: SQLFields) -> SQLFields { + lhs.merging(rhs, uniquingKeysWith: { _, b in b }) + } +} diff --git a/Alchemy/Database/SQL/SQLRow.swift b/Alchemy/Database/SQL/SQLRow.swift index 62efbf2c..af3d846c 100644 --- a/Alchemy/Database/SQL/SQLRow.swift +++ b/Alchemy/Database/SQL/SQLRow.swift @@ -1,31 +1,25 @@ +import Collections + /// A row of data returned by an SQL query. public struct SQLRow: ExpressibleByDictionaryLiteral { - public let fields: [(column: String, value: SQLValue)] - private let lookupTable: [String: Int] + public let fields: OrderedDictionary - public var fieldDictionary: [String: SQLValue] { - lookupTable.mapValues { fields[$0].value } - } - - public init(fields: [(column: String, value: SQLValue)]) { + public init(fields: OrderedDictionary) { self.fields = fields - self.lookupTable = Dictionary(fields.enumerated().map { ($1.column, $0) }) } - public init(fields: [(column: String, value: SQLValueConvertible)]) { - self.init(fields: fields.map { ($0, $1.sqlValue) }) + public init(fields: [(String, SQLValueConvertible)]) { + let dict = fields.map { ($0, $1.sqlValue) } + self.init(fields: .init(dict, uniquingKeysWith: { a, _ in a })) } public init(dictionaryLiteral elements: (String, SQLValueConvertible)...) { - self.init(fields: elements) - } - - public init(dictionary: [String: SQLValueConvertible]) { - self.init(fields: dictionary.map { (column: $0.key, value: $0.value) }) + let dict = elements.map { ($0, $1.sqlValue) } + self.init(fields: .init(dict, uniquingKeysWith: { a, _ in a })) } public func contains(_ column: String) -> Bool { - lookupTable[column] != nil + fields[column] != nil } public func require(_ column: String) throws -> SQLValue { @@ -43,12 +37,11 @@ public struct SQLRow: ExpressibleByDictionaryLiteral { } public subscript(_ index: Int) -> SQLValue { - fields[index].value + fields.elements[index].value.sqlValue } public subscript(_ column: String) -> SQLValue? { - guard let index = lookupTable[column] else { return nil } - return fields[index].value + fields[column]?.sqlValue } } diff --git a/Alchemy/Database/SQL/SQLValue.swift b/Alchemy/Database/SQL/SQLValue.swift index 544c2c51..41329d18 100644 --- a/Alchemy/Database/SQL/SQLValue.swift +++ b/Alchemy/Database/SQL/SQLValue.swift @@ -206,3 +206,17 @@ public enum SQLValue: Hashable, CustomStringConvertible { return DatabaseError("Unable to coerce value `\(self)` \(detail)to \(typeName).") } } + +extension SQLValue { + public func decode(_ type: D.Type) throws -> D { + fatalError() + } + + public func decode(_ type: P.Type) throws -> P { + fatalError() + } + + public func decode(_ type: D.Type) throws -> D { + fatalError() + } +} diff --git a/Alchemy/Database/Schema/Database+Schema.swift b/Alchemy/Database/Schema/Database+Schema.swift index e97b8860..b89a0232 100644 --- a/Alchemy/Database/Schema/Database+Schema.swift +++ b/Alchemy/Database/Schema/Database+Schema.swift @@ -54,6 +54,6 @@ extension Database { /// Check if the database has a table with the given name. public func hasTable(_ table: String) async throws -> Bool { let sql = grammar.hasTable(table) - return try await query(sql: sql).first?.fields.first?.value.bool() ?? false + return try await query(sql: sql).first?.fields.elements.first?.value.bool() ?? false } } diff --git a/Alchemy/Database/Seeding/Seedable.swift b/Alchemy/Database/Seeding/Seedable.swift index efcb574d..9801e99a 100644 --- a/Alchemy/Database/Seeding/Seedable.swift +++ b/Alchemy/Database/Seeding/Seedable.swift @@ -1,3 +1,4 @@ +import Collections import Fakery public protocol Seedable { @@ -18,14 +19,14 @@ extension Seedable where Self: Model { } @discardableResult - public static func seed(fields: [String: SQLConvertible] = [:], modifier: ((inout Self) async throws -> Void)? = nil) async throws -> Self { + public static func seed(fields: SQLFields = [:], modifier: ((inout Self) async throws -> Void)? = nil) async throws -> Self { try await seed(1, fields: fields, modifier: modifier).first! } @discardableResult public static func seed( _ count: Int = 1, - fields: [String: SQLConvertible] = [:], + fields: SQLFields = [:], modifier: ((inout Self) async throws -> Void)? = nil ) async throws -> [Self] { var models: [Self] = [] diff --git a/Alchemy/Queue/Providers/DatabaseQueue.swift b/Alchemy/Queue/Providers/DatabaseQueue.swift index 761e117b..e2c352e3 100644 --- a/Alchemy/Queue/Providers/DatabaseQueue.swift +++ b/Alchemy/Queue/Providers/DatabaseQueue.swift @@ -17,10 +17,11 @@ extension Queue { /// A queue that persists jobs to a database. private final class DatabaseQueue: QueueProvider { /// Represents the table of jobs backing a `DatabaseQueue`. - struct JobModel: Model, Codable { + @Model + struct JobModel { static var table = "jobs" - var id: PK = .new + var id: String let jobName: String let channel: String let payload: Data @@ -34,7 +35,6 @@ private final class DatabaseQueue: QueueProvider { var backoffUntil: Date? init(jobData: JobData) { - id = .new(jobData.id) jobName = jobData.jobName channel = jobData.channel payload = jobData.payload @@ -43,11 +43,12 @@ private final class DatabaseQueue: QueueProvider { backoffSeconds = jobData.backoff.seconds backoffUntil = jobData.backoffUntil reserved = false + id = jobData.id } func toJobData() throws -> JobData { JobData( - id: try id.require(), + id: id, payload: payload, jobName: jobName, channel: channel, diff --git a/AlchemyPlugin/Sources/AlchemyPlugin.swift b/AlchemyPlugin/Sources/AlchemyPlugin.swift index 3be2a263..8e93521e 100644 --- a/AlchemyPlugin/Sources/AlchemyPlugin.swift +++ b/AlchemyPlugin/Sources/AlchemyPlugin.swift @@ -8,6 +8,7 @@ struct AlchemyPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ JobMacro.self, ModelMacro.self, + IDMacro.self, ApplicationMacro.self, ControllerMacro.self, HTTPMethodMacro.self, diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index c89dd24d..59f80349 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -1,8 +1,30 @@ import SwiftSyntax import SwiftSyntaxMacros -struct ModelMacro: MemberMacro, ExtensionMacro { +/* + 1. add var storage + 2. add init(row: SQLRow) + 3. add fields: SQLFields + 4. add @ID to `var id` - if it exists. + + */ + +struct IDMacro: AccessorMacro { + static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + [ + "get { fatalError() }", + "nonmutating set { fatalError() }", + ] + } +} + +struct ModelMacro: MemberMacro, ExtensionMacro, MemberAttributeMacro { + // MARK: ExtensionMacro static func expansion( @@ -12,12 +34,12 @@ struct ModelMacro: MemberMacro, ExtensionMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { - guard let `struct` = declaration.as(StructDeclSyntax.self) else { - throw AlchemyMacroError("@Model can only be used on a struct") - } - + let resource = try Resource.parse(syntax: declaration) return try [ - Declaration("extension \(`struct`.name.trimmedDescription): Model {}") + Declaration("extension \(resource.name): Model") { + resource.generateInitializer() + resource.generateFields() + } ] .map { try $0.extensionDeclSyntax() } } @@ -29,17 +51,33 @@ struct ModelMacro: MemberMacro, ExtensionMacro { providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard let `struct` = declaration.as(StructDeclSyntax.self) else { - throw AlchemyMacroError("@Model can only be used on a struct") - } - + let resource = try Resource.parse(syntax: declaration) return [ - Declaration("var storage: String") { - `struct`.name.trimmedDescription.inQuotes - } + resource.generateStorage(), + resource.generateFieldLookup(), ] .map { $0.declSyntax() } } + + // MARK: MemberAttributeMacro + + static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AttributeSyntax] { + guard let member = member.as(VariableDeclSyntax.self) else { + return [] + } + + guard !member.isStatic else { + return [] + } + + let property = try Resource.Property.parse(variable: member) + return property.name == "id" ? ["@ID"] : [] + } } struct Resource { @@ -71,25 +109,38 @@ struct Resource { extension Resource { static func parse(syntax: DeclSyntaxProtocol) throws -> Resource { guard let `struct` = syntax.as(StructDeclSyntax.self) else { - throw AlchemyMacroError("For now, @Resource can only be applied to a struct") + throw AlchemyMacroError("For now, @Model can only be applied to a struct") } return Resource( accessLevel: `struct`.accessLevel, name: `struct`.structName, - properties: `struct`.members.map(Resource.Property.parse) + properties: try `struct`.instanceMembers.map(Resource.Property.parse) ) } } extension Resource.Property { - static func parse(variable: VariableDeclSyntax) -> Resource.Property { - let patterns = variable.bindings.compactMap { PatternBindingSyntax.init($0) } + static func parse(variable: VariableDeclSyntax) throws -> Resource.Property { + let patternBindings = variable.bindings.compactMap { PatternBindingSyntax.init($0) } let keyword = variable.bindingSpecifier.text - let name = "\(patterns.first!.pattern.as(IdentifierPatternSyntax.self)!.identifier.text)" - let type = "\(patterns.first!.typeAnnotation!.type.trimmed)" - let defaultValue = patterns.first!.initializer.map { "\($0.value.trimmed)" } - let isStored = patterns.first?.accessorBlock == nil + + guard let patternBinding = patternBindings.first else { + throw AlchemyMacroError("Property had no pattern bindings") + } + + guard let identifierPattern = patternBinding.pattern.as(IdentifierPatternSyntax.self) else { + throw AlchemyMacroError("Unable to detect property name") + } + + guard let typeAnnotation = patternBinding.typeAnnotation else { + throw AlchemyMacroError("Property \(identifierPattern.identifier.trimmedDescription) \(variable.isStatic) had no type annotation") + } + + let name = "\(identifierPattern.identifier.text)" + let type = "\(typeAnnotation.type.trimmedDescription)" + let defaultValue = patternBinding.initializer.map { "\($0.value.trimmed)" } + let isStored = patternBinding.accessorBlock == nil return Resource.Property( keyword: keyword, @@ -102,23 +153,24 @@ extension Resource.Property { } extension Resource { + + fileprivate func generateStorage() -> Declaration { + Declaration("let storage = ModelStorage()") + } + fileprivate func generateInitializer() -> Declaration { - let parameters = storedProperties.map { - if let defaultValue = $0.defaultValue { - "\($0.name): \($0.type) = \(defaultValue)" - } else if $0.isOptional && $0.keyword == "var" { - "\($0.name): \($0.type) = nil" - } else { - "\($0.name): \($0.type)" + Declaration("init(row: SQLRow) throws") { + for property in storedProperties where property.name != "id" { + "self.\(property.name) = try row.require(\(property.name.inQuotes)).decode(\(property.type).self)" } } - .joined(separator: ", ") - return Declaration("init(\(parameters))") { - for property in storedProperties { - "self.\(property.name) = \(property.name)" - } + .access(accessLevel == "public" ? "public" : nil) + } + + fileprivate func generateFields() -> Declaration { + Declaration("func fields() -> SQLFields") { + "[:]" } - .access(accessLevel) } fileprivate func generateFieldLookup() -> Declaration { @@ -132,7 +184,7 @@ extension Resource { } .joined(separator: ",\n") return Declaration(""" - public static let fields: [PartialKeyPath<\(name)>: ResourceField] = [ + public static let fieldLookup: FieldLookup = [ \(fieldsString) ] """) @@ -159,6 +211,16 @@ extension DeclGroupSyntax { .members .compactMap { $0.decl.as(VariableDeclSyntax.self) } } + + var instanceMembers: [VariableDeclSyntax] { + members.filter { !$0.isStatic } + } +} + +extension VariableDeclSyntax { + var isStatic: Bool { + modifiers.contains { $0.name.trimmedDescription == "static" } + } } extension StructDeclSyntax { diff --git a/Example/App.swift b/Example/App.swift index 64a3c277..3757f96c 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,4 +1,5 @@ import Alchemy +import Collections @Application struct App { @@ -30,10 +31,23 @@ struct UserController { } } +public struct ModelField: Identifiable { + public var id: String { name } + public let name: String + public let type: Any.Type + public let `default`: Any? + + public init(_ name: String, type: T.Type, default: T? = nil) { + self.name = name + self.type = type + self.default = `default` + } +} + @Model -struct Todo: Codable { - var id: PK - var name: String +struct Todo { + var id: Int + let name: String var isDone: Bool = false } diff --git a/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift b/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift index ae4671bf..15335207 100644 --- a/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift +++ b/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift @@ -36,7 +36,7 @@ final class SQLRowEncoderTests: TestCase { ) let jsonData = try EverythingModel.jsonEncoder.encode(json) - let expectedFields: [String: SQLConvertible] = [ + let expectedFields: SQLFields = [ "id": 1, "string_enum": "one", "int_enum": 2, diff --git a/Tests/Encryption/EncryptionTests.swift b/Tests/Encryption/EncryptionTests.swift index e6b9f572..12f61559 100644 --- a/Tests/Encryption/EncryptionTests.swift +++ b/Tests/Encryption/EncryptionTests.swift @@ -40,7 +40,7 @@ final class EncryptionTests: XCTestCase { let fakeWriter = FakeWriter() var writer: SQLRowWriter = fakeWriter try encrypted.store(key: "foo", on: &writer) - guard let storedValue = fakeWriter.dict["foo"] as? String else { + guard let storedValue = fakeWriter.fields["foo"] as? String else { return XCTFail("a String wasn't stored") } @@ -55,11 +55,11 @@ final class EncryptionTests: XCTestCase { } private final class FakeWriter: SQLRowWriter { - var dict: [String: SQLConvertible] = [:] + var fields: SQLFields = [:] subscript(column: String) -> SQLConvertible? { - get { dict[column] } - set { dict[column] = newValue } + get { fields[column] } + set { fields[column] = newValue } } func put(json: E, at key: String) throws { From f4b50b9d9ac64e8db069033646560c90e2c63f3e Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 17 Jun 2024 15:59:10 -0700 Subject: [PATCH 32/55] Codable support --- Alchemy/Database/Rune/Model/PK.swift | 30 ++++++++++++++-- AlchemyPlugin/Sources/Macros/ModelMacro.swift | 35 ++++++++++++------- Example/App.swift | 15 +------- 3 files changed, 52 insertions(+), 28 deletions(-) diff --git a/Alchemy/Database/Rune/Model/PK.swift b/Alchemy/Database/Rune/Model/PK.swift index bfcec0f3..b3e0ec5d 100644 --- a/Alchemy/Database/Rune/Model/PK.swift +++ b/Alchemy/Database/Rune/Model/PK.swift @@ -1,4 +1,4 @@ -public final class ModelStorage { +public final class ModelStorage: Codable { var row: SQLRow? var relationships: [String: Any] @@ -7,11 +7,37 @@ public final class ModelStorage { self.row = nil } - static var new: ModelStorage { + public func encode(to encoder: Encoder) throws { + // instead, use the KeyedEncodingContainer extension below. + preconditionFailure("Directly encoding ModelStorage not supported!") + } + + public init(from decoder: Decoder) throws { + // instead, use the KeyedDecodingContainer extension below. + preconditionFailure("Directly decoding ModelStorage not supported!") + } +} + +extension KeyedDecodingContainer { + public func decode( + _ type: ModelStorage, + forKey key: Self.Key + ) throws -> ModelStorage { + // decode id ModelStorage() } } +extension KeyedEncodingContainer { + public mutating func encode( + _ value: ModelStorage, + forKey key: KeyedEncodingContainer.Key + ) throws { + // encode id + // encode relationships + } +} + extension Model { public internal(set) var row: SQLRow? { get { storage.row } diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index 59f80349..d6afd8c9 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -1,22 +1,25 @@ import SwiftSyntax import SwiftSyntaxMacros -/* - - 1. add var storage - 2. add init(row: SQLRow) - 3. add fields: SQLFields - 4. add @ID to `var id` - if it exists. +struct IDMacro: AccessorMacro { - */ + // MARK: AccessorMacro -struct IDMacro: AccessorMacro { static func expansion( of node: AttributeSyntax, providingAccessorsOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [AccessorDeclSyntax] { - [ + guard let variable = declaration.as(VariableDeclSyntax.self) else { + throw AlchemyMacroError("@ID can only be applied to a stored property.") + } + + let property = try Resource.Property.parse(variable: variable) + guard property.keyword == "var" else { + throw AlchemyMacroError("Property 'id' must be a var.") + } + + return [ "get { fatalError() }", "nonmutating set { fatalError() }", ] @@ -76,7 +79,15 @@ struct ModelMacro: MemberMacro, ExtensionMacro, MemberAttributeMacro { } let property = try Resource.Property.parse(variable: member) - return property.name == "id" ? ["@ID"] : [] + if property.name == "id" { + guard property.keyword == "var" else { + throw AlchemyMacroError("Property 'id' must be a var.") + } + + return ["@ID"] + } else { + return [] + } } } @@ -134,7 +145,7 @@ extension Resource.Property { } guard let typeAnnotation = patternBinding.typeAnnotation else { - throw AlchemyMacroError("Property \(identifierPattern.identifier.trimmedDescription) \(variable.isStatic) had no type annotation") + throw AlchemyMacroError("Property '\(identifierPattern.identifier.trimmedDescription)' had no type annotation") } let name = "\(identifierPattern.identifier.text)" @@ -155,7 +166,7 @@ extension Resource.Property { extension Resource { fileprivate func generateStorage() -> Declaration { - Declaration("let storage = ModelStorage()") + Declaration("var storage = ModelStorage()") } fileprivate func generateInitializer() -> Declaration { diff --git a/Example/App.swift b/Example/App.swift index 3757f96c..412d214d 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -31,21 +31,8 @@ struct UserController { } } -public struct ModelField: Identifiable { - public var id: String { name } - public let name: String - public let type: Any.Type - public let `default`: Any? - - public init(_ name: String, type: T.Type, default: T? = nil) { - self.name = name - self.type = type - self.default = `default` - } -} - @Model -struct Todo { +struct Todo: Codable { var id: Int let name: String var isDone: Bool = false From f228835ca6ba4fb0b6a3d66f1cd22e54bc7801c4 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 17 Jun 2024 18:20:55 -0700 Subject: [PATCH 33/55] model macro working --- .../Rune/Model/Coding/SQLRowDecoder.swift | 41 +++++------------- .../Rune/Model/Coding/SQLRowEncoder.swift | 33 ++++----------- .../Rune/Model/Coding/SQLRowReader.swift | 41 ++++++++++-------- .../Rune/Model/Coding/SQLRowWriter.swift | 42 ++++++++++++++++--- Alchemy/Database/Rune/Model/Model.swift | 8 ++-- Alchemy/Database/Rune/Model/ModelEnum.swift | 4 +- .../Database/Rune/Model/ModelProperty.swift | 34 +++++++-------- .../Model/{PK.swift => ModelStorage.swift} | 35 +++++++++++----- Alchemy/Database/SQL/SQLValue.swift | 14 ------- .../Database/SQL/SQLValueConvertible.swift | 4 ++ Alchemy/Encryption/Encrypted.swift | 4 +- Alchemy/Filesystem/File.swift | 4 +- Alchemy/HTTP/Content/Content.swift | 16 +++---- Alchemy/Validation/Validator.swift | 9 +++- AlchemyPlugin/Sources/Macros/ModelMacro.swift | 31 ++++++++++---- Example/App.swift | 14 +++++++ 16 files changed, 188 insertions(+), 146 deletions(-) rename Alchemy/Database/Rune/Model/{PK.swift => ModelStorage.swift} (66%) diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowDecoder.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowDecoder.swift index df600158..756e5908 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowDecoder.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowDecoder.swift @@ -1,4 +1,4 @@ -struct SQLRowDecoder: Decoder, SQLRowReader { +struct SQLRowDecoder: Decoder { /// A `KeyedDecodingContainerProtocol` used to decode keys from a /// `SQLRow`. private struct KeyedContainer: KeyedDecodingContainerProtocol { @@ -67,17 +67,19 @@ struct SQLRowDecoder: Decoder, SQLRowReader { } /// The row that will be decoded out of. - let row: SQLRow - let keyMapping: KeyMapping - let jsonDecoder: JSONDecoder - + let reader: SQLRowReader + + init(row: SQLRow, keyMapping: KeyMapping, jsonDecoder: JSONDecoder) { + self.reader = SQLRowReader(row: row, keyMapping: keyMapping, jsonDecoder: jsonDecoder) + } + // MARK: Decoder var codingPath: [CodingKey] = [] var userInfo: [CodingUserInfoKey : Any] = [:] func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { - KeyedDecodingContainer(KeyedContainer(reader: self)) + KeyedDecodingContainer(KeyedContainer(reader: reader)) } func unkeyedContainer() throws -> UnkeyedDecodingContainer { @@ -85,33 +87,10 @@ struct SQLRowDecoder: Decoder, SQLRowReader { } func singleValueContainer() throws -> SingleValueDecodingContainer { - guard let firstColumn = row.fields.elements.first?.key else { + guard let firstColumn = reader.row.fields.elements.first?.key else { throw DatabaseError("SQLRow had no fields to decode a value from.") } - return SingleContainer(reader: self, column: firstColumn) - } - - // MARK: SQLRowReader - - func requireJSON(_ key: String) throws -> D { - let key = keyMapping.encode(key) - return try jsonDecoder.decode(D.self, from: row.require(key).json(key)) - } - - func require(_ key: String) throws -> SQLValue { - try row.require(keyMapping.encode(key)) - } - - func contains(_ column: String) -> Bool { - row[keyMapping.encode(column)] != nil - } - - subscript(_ index: Int) -> SQLValue { - row[index] - } - - subscript(_ column: String) -> SQLValue? { - row[keyMapping.encode(column)] + return SingleContainer(reader: reader, column: firstColumn) } } diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift index a78dab9d..8eaed314 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift @@ -1,4 +1,4 @@ -final class SQLRowEncoder: Encoder, SQLRowWriter { +final class SQLRowEncoder: Encoder { /// Used to decode keyed values from a Model. private struct _KeyedEncodingContainer: KeyedEncodingContainerProtocol { var writer: SQLRowWriter @@ -8,7 +8,7 @@ final class SQLRowEncoder: Encoder, SQLRowWriter { var codingPath = [CodingKey]() mutating func encodeNil(forKey key: Key) throws { - writer.put(.null, at: key.stringValue) + writer.put(sql: .null, at: key.stringValue) } mutating func encode(_ value: T, forKey key: Key) throws { @@ -18,7 +18,7 @@ final class SQLRowEncoder: Encoder, SQLRowWriter { return } - try property.store(key: key.stringValue, on: &writer) + try property.store(key: key.stringValue, on: writer) } mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { @@ -40,12 +40,10 @@ final class SQLRowEncoder: Encoder, SQLRowWriter { /// Used for keeping track of the database fields pulled off the /// object encoded to this encoder. - private var fields: SQLFields = [:] + private let writer: SQLRowWriter /// The mapping strategy for associating `CodingKey`s on an object /// with column names in a database. - let keyMapping: KeyMapping - let jsonEncoder: JSONEncoder var codingPath = [CodingKey]() var userInfo: [CodingUserInfoKey: Any] = [:] @@ -54,9 +52,7 @@ final class SQLRowEncoder: Encoder, SQLRowWriter { /// - Parameter mappingStrategy: The strategy for mapping `CodingKey` string /// values to SQL columns. init(keyMapping: KeyMapping, jsonEncoder: JSONEncoder) { - self.keyMapping = keyMapping - self.jsonEncoder = jsonEncoder - self.fields = [:] + self.writer = SQLRowWriter(keyMapping: keyMapping, jsonEncoder: jsonEncoder) } /// Read and return the stored properties of an `Model` object. @@ -67,14 +63,14 @@ final class SQLRowEncoder: Encoder, SQLRowWriter { /// - Returns: An ordered dictionary of the model's columns and values. func fields(for value: E) throws -> SQLFields { try value.encode(to: self) - defer { fields = [:] } - return fields + defer { writer.fields = [:] } + return writer.fields } // MARK: Encoder func container(keyedBy: Key.Type) -> KeyedEncodingContainer { - KeyedEncodingContainer(_KeyedEncodingContainer(writer: self, codingPath: codingPath)) + KeyedEncodingContainer(_KeyedEncodingContainer(writer: writer, codingPath: codingPath)) } func unkeyedContainer() -> UnkeyedEncodingContainer { @@ -84,17 +80,4 @@ final class SQLRowEncoder: Encoder, SQLRowWriter { func singleValueContainer() -> SingleValueEncodingContainer { fatalError("`Model`s should never encode to a single value container.") } - - // MARK: SQLRowWritier - - func put(json: E, at key: String) throws { - let jsonData = try jsonEncoder.encode(json) - let bytes = ByteBuffer(data: jsonData) - self[key] = .value(.json(bytes)) - } - - subscript(column: String) -> SQLConvertible? { - get { fields[column] } - set { fields[keyMapping.encode(column)] = newValue ?? .null } - } } diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift index 2a87031c..c3dc184d 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift @@ -1,35 +1,40 @@ -public protocol SQLRowReader { - var row: SQLRow { get } - func require(_ key: String) throws -> SQLValue - func requireJSON(_ key: String) throws -> D - func contains(_ column: String) -> Bool - subscript(_ index: Int) -> SQLValue { get } - subscript(_ column: String) -> SQLValue? { get } -} +public struct SQLRowReader { + public let row: SQLRow + public let keyMapping: KeyMapping + public let jsonDecoder: JSONDecoder + + public init(row: SQLRow, keyMapping: KeyMapping, jsonDecoder: JSONDecoder) { + self.row = row + self.keyMapping = keyMapping + self.jsonDecoder = jsonDecoder + } -struct GenericRowReader: SQLRowReader { - let row: SQLRow - let keyMapping: KeyMapping - let jsonDecoder: JSONDecoder + public func require(_ key: String) throws -> SQLValue { + try row.require(keyMapping.encode(key)) + } - func requireJSON(_ key: String) throws -> D { + public func requireJSON(_ key: String) throws -> D { let key = keyMapping.encode(key) return try jsonDecoder.decode(D.self, from: row.require(key).json(key)) } - func require(_ key: String) throws -> SQLValue { - try row.require(keyMapping.encode(key)) + public func require(_ type: D.Type, at key: String) throws -> D { + if let type = type as? ModelProperty.Type { + return try type.init(key: key, on: self) as! D + } else { + return try requireJSON(key) + } } - func contains(_ column: String) -> Bool { + public func contains(_ column: String) -> Bool { row[keyMapping.encode(column)] != nil } - subscript(_ index: Int) -> SQLValue { + public subscript(_ index: Int) -> SQLValue { row[index] } - subscript(_ column: String) -> SQLValue? { + public subscript(_ column: String) -> SQLValue? { row[keyMapping.encode(column)] } } diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift index adc4cd83..aecbe505 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift @@ -1,14 +1,44 @@ -public protocol SQLRowWriter { - subscript(_ column: String) -> SQLConvertible? { get set } - mutating func put(json: E, at key: String) throws +public final class SQLRowWriter { + public internal(set) var fields: SQLFields + let keyMapping: KeyMapping + let jsonEncoder: JSONEncoder + + public init(keyMapping: KeyMapping, jsonEncoder: JSONEncoder) { + self.fields = [:] + self.keyMapping = keyMapping + self.jsonEncoder = jsonEncoder + } + + public func put(json: some Encodable, at key: String) throws { + let jsonData = try jsonEncoder.encode(json) + let bytes = ByteBuffer(data: jsonData) + self[key] = .value(.json(bytes)) + } + + public func put(sql: SQLConvertible, at key: String) { + self[key] = sql + } + + public subscript(column: String) -> SQLConvertible? { + get { fields[column] } + set { fields[keyMapping.encode(column)] = newValue ?? .null } + } } extension SQLRowWriter { - public mutating func put(_ value: SQLConvertible, at key: String) { - self[key] = value + public func put(_ value: ModelProperty, at key: String) throws { + + } + + public func put(_ value: some Encodable, at key: String) throws { + if let value = value as? ModelProperty { + try value.store(key: key, on: self) + } else { + try put(json: value, at: key) + } } - public mutating func put(_ int: F, at key: String) { + public func put(_ int: F, at key: String) { self[key] = Int(int) } } diff --git a/Alchemy/Database/Rune/Model/Model.swift b/Alchemy/Database/Rune/Model/Model.swift index a3f372d8..239ffbff 100644 --- a/Alchemy/Database/Rune/Model/Model.swift +++ b/Alchemy/Database/Rune/Model/Model.swift @@ -3,16 +3,18 @@ import Pluralize /// An ActiveRecord-esque type used for modeling a table in a relational /// database. Contains many extensions for making database queries, /// supporting relationships & more. +/// +/// Use @Model to apply this protocol. public protocol Model: Identifiable, QueryResult, ModelOrOptional { /// The type of this object's primary key. associatedtype PrimaryKey: PrimaryKeyProtocol - /// Storage for loaded information. - var storage: ModelStorage { get } - /// The identifier of this model var id: PrimaryKey { get nonmutating set } + /// Storage for model metadata (relationships, original row, etc). + var storage: Storage { get } + /// Convert this to an SQLRow for updating or inserting into a database. func fields() throws -> SQLFields diff --git a/Alchemy/Database/Rune/Model/ModelEnum.swift b/Alchemy/Database/Rune/Model/ModelEnum.swift index 65962ef6..091ae6a8 100644 --- a/Alchemy/Database/Rune/Model/ModelEnum.swift +++ b/Alchemy/Database/Rune/Model/ModelEnum.swift @@ -25,8 +25,8 @@ extension ModelEnum where Self: RawRepresentable, RawValue: ModelProperty { self = value } - public func store(key: String, on row: inout SQLRowWriter) throws { - try rawValue.store(key: key, on: &row) + public func store(key: String, on row: SQLRowWriter) throws { + try rawValue.store(key: key, on: row) } } diff --git a/Alchemy/Database/Rune/Model/ModelProperty.swift b/Alchemy/Database/Rune/Model/ModelProperty.swift index 54ea070b..a896b9ad 100644 --- a/Alchemy/Database/Rune/Model/ModelProperty.swift +++ b/Alchemy/Database/Rune/Model/ModelProperty.swift @@ -1,7 +1,7 @@ // For custom logic around loading and saving properties on a Model. public protocol ModelProperty { init(key: String, on row: SQLRowReader) throws - func store(key: String, on row: inout SQLRowWriter) throws + func store(key: String, on row: SQLRowWriter) throws } extension String: ModelProperty { @@ -9,8 +9,8 @@ extension String: ModelProperty { self = try row.require(key).string(key) } - public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + public func store(key: String, on row: SQLRowWriter) throws { + row.put(sql: self, at: key) } } @@ -19,8 +19,8 @@ extension Bool: ModelProperty { self = try row.require(key).bool(key) } - public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + public func store(key: String, on row: SQLRowWriter) throws { + row.put(sql: self, at: key) } } @@ -29,18 +29,18 @@ extension Float: ModelProperty { self = Float(try row.require(key).double(key)) } - public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + public func store(key: String, on row: SQLRowWriter) throws { + row.put(sql: self, at: key) } } extension Double: ModelProperty { public init(key: String, on row: SQLRowReader) throws { - self = try row.require(key).double(key) + self = try row.require(key).double(key) } - public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + public func store(key: String, on row: SQLRowWriter) throws { + row.put(sql: self, at: key) } } @@ -49,7 +49,7 @@ extension FixedWidthInteger { self = try .init(row.require(key).int(key)) } - public func store(key: String, on row: inout SQLRowWriter) throws { + public func store(key: String, on row: SQLRowWriter) throws { row.put(self, at: key) } } @@ -70,8 +70,8 @@ extension Date: ModelProperty { self = try row.require(key).date(key) } - public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + public func store(key: String, on row: SQLRowWriter) throws { + row.put(sql: self, at: key) } } @@ -80,8 +80,8 @@ extension UUID: ModelProperty { self = try row.require(key).uuid(key) } - public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + public func store(key: String, on row: SQLRowWriter) throws { + row.put(sql: self, at: key) } } @@ -95,7 +95,7 @@ extension Optional: ModelProperty where Wrapped: ModelProperty { self = .some(try Wrapped(key: key, on: row)) } - public func store(key: String, on row: inout SQLRowWriter) throws { - try self?.store(key: key, on: &row) + public func store(key: String, on row: SQLRowWriter) throws { + try self?.store(key: key, on: row) } } diff --git a/Alchemy/Database/Rune/Model/PK.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift similarity index 66% rename from Alchemy/Database/Rune/Model/PK.swift rename to Alchemy/Database/Rune/Model/ModelStorage.swift index b3e0ec5d..055313ce 100644 --- a/Alchemy/Database/Rune/Model/PK.swift +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -1,10 +1,12 @@ -public final class ModelStorage: Codable { - var row: SQLRow? +public final class ModelStorage: Codable { + public var id: M.PrimaryKey? + public var row: SQLRow? var relationships: [String: Any] public init() { - self.relationships = [:] + self.id = nil self.row = nil + self.relationships = [:] } public func encode(to encoder: Encoder) throws { @@ -18,22 +20,35 @@ public final class ModelStorage: Codable { } } +extension Model { + public typealias Storage = ModelStorage +} + extension KeyedDecodingContainer { - public func decode( - _ type: ModelStorage, + public func decode( + _ type: ModelStorage, forKey key: Self.Key - ) throws -> ModelStorage { - // decode id - ModelStorage() + ) throws -> ModelStorage { + let storage = M.Storage() + let hasId = allKeys.contains { $0.stringValue == M.primaryKey } + if hasId, let key = K(stringValue: M.primaryKey) { + storage.id = try decode(M.PrimaryKey.self, forKey: key) + } + + return storage } } extension KeyedEncodingContainer { - public mutating func encode( - _ value: ModelStorage, + public mutating func encode( + _ value: ModelStorage, forKey key: KeyedEncodingContainer.Key ) throws { // encode id + if let key = K(stringValue: M.primaryKey) { + try encode(value.id, forKey: key) + } + // encode relationships } } diff --git a/Alchemy/Database/SQL/SQLValue.swift b/Alchemy/Database/SQL/SQLValue.swift index 41329d18..544c2c51 100644 --- a/Alchemy/Database/SQL/SQLValue.swift +++ b/Alchemy/Database/SQL/SQLValue.swift @@ -206,17 +206,3 @@ public enum SQLValue: Hashable, CustomStringConvertible { return DatabaseError("Unable to coerce value `\(self)` \(detail)to \(typeName).") } } - -extension SQLValue { - public func decode(_ type: D.Type) throws -> D { - fatalError() - } - - public func decode(_ type: P.Type) throws -> P { - fatalError() - } - - public func decode(_ type: D.Type) throws -> D { - fatalError() - } -} diff --git a/Alchemy/Database/SQL/SQLValueConvertible.swift b/Alchemy/Database/SQL/SQLValueConvertible.swift index 500b8b87..d93b03c3 100644 --- a/Alchemy/Database/SQL/SQLValueConvertible.swift +++ b/Alchemy/Database/SQL/SQLValueConvertible.swift @@ -18,6 +18,10 @@ extension FixedWidthInteger { public var sqlValue: SQLValue { .int(Int(self)) } } +extension Data: SQLValueConvertible { + public var sqlValue: SQLValue { .bytes(.init(data: self)) } +} + extension Int: SQLValueConvertible {} extension Int8: SQLValueConvertible {} extension Int16: SQLValueConvertible {} diff --git a/Alchemy/Encryption/Encrypted.swift b/Alchemy/Encryption/Encrypted.swift index 01895b96..23360206 100644 --- a/Alchemy/Encryption/Encrypted.swift +++ b/Alchemy/Encryption/Encrypted.swift @@ -13,9 +13,9 @@ public struct Encrypted: ModelProperty, Codable { wrappedValue = try Crypt.decrypt(data: data) } - public func store(key: String, on row: inout SQLRowWriter) throws { + public func store(key: String, on row: SQLRowWriter) throws { let encrypted = try Crypt.encrypt(string: wrappedValue) let string = encrypted.base64EncodedString() - row.put(string, at: key) + row.put(sql: string, at: key) } } diff --git a/Alchemy/Filesystem/File.swift b/Alchemy/Filesystem/File.swift index 03824d5b..2d6e30e3 100644 --- a/Alchemy/Filesystem/File.swift +++ b/Alchemy/Filesystem/File.swift @@ -103,12 +103,12 @@ public struct File: Codable, ResponseConvertible, ModelProperty { self.init(name: name, source: .filesystem(Storage, path: name)) } - public func store(key: String, on row: inout SQLRowWriter) throws { + public func store(key: String, on row: SQLRowWriter) throws { guard case .filesystem(_, let path) = source else { throw RuneError("currently, only files saved in a `Filesystem` can be stored on a `Model`") } - row.put(path, at: key) + row.put(sql: path, at: key) } // MARK: - ResponseConvertible diff --git a/Alchemy/HTTP/Content/Content.swift b/Alchemy/HTTP/Content/Content.swift index a3f487be..2ea4f40a 100644 --- a/Alchemy/HTTP/Content/Content.swift +++ b/Alchemy/HTTP/Content/Content.swift @@ -459,28 +459,28 @@ extension Content.Value: ModelProperty { throw ContentError.notSupported("Reading content from database models isn't supported, yet.") } - public func store(key: String, on row: inout SQLRowWriter) throws { + public func store(key: String, on row: SQLRowWriter) throws { switch self { case .array(let values): try row.put(json: values, at: key) case .dictionary(let dict): try row.put(json: dict, at: key) case .bool(let value): - try value.store(key: key, on: &row) + try value.store(key: key, on: row) case .string(let value): - try value.store(key: key, on: &row) + try value.store(key: key, on: row) case .int(let value): - try value.store(key: key, on: &row) + try value.store(key: key, on: row) case .double(let double): - try double.store(key: key, on: &row) + try double.store(key: key, on: row) case .file(let file): if let buffer = file.content?.buffer { - row.put(SQLValue.bytes(buffer), at: key) + row.put(sql: SQLValue.bytes(buffer), at: key) } else { - row.put(SQLValue.null, at: key) + row.put(sql: SQLValue.null, at: key) } case .null: - row.put(SQLValue.null, at: key) + row.put(sql: SQLValue.null, at: key) } } } diff --git a/Alchemy/Validation/Validator.swift b/Alchemy/Validation/Validator.swift index 125ba795..07e541b0 100644 --- a/Alchemy/Validation/Validator.swift +++ b/Alchemy/Validation/Validator.swift @@ -11,7 +11,14 @@ public struct Validator: @unchecked Sendable { public init(_ message: String? = nil, validators: Validator...) { self.message = message - self.isValid = { _ in fatalError() } + self.isValid = { value in + for validator in validators { + let result = try await validator.isValid(value) + if !result { return false } + } + + return true + } } public func validate(_ value: Value) async throws { diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index d6afd8c9..6c754c81 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -20,8 +20,16 @@ struct IDMacro: AccessorMacro { } return [ - "get { fatalError() }", - "nonmutating set { fatalError() }", + """ + get { + guard let id = storage.id else { + preconditionFailure("Attempting to access 'id' from Model that doesn't have one.") + } + + return id + } + """, + "nonmutating set { storage.id = newValue }", ] } } @@ -164,24 +172,33 @@ extension Resource.Property { } extension Resource { - fileprivate func generateStorage() -> Declaration { - Declaration("var storage = ModelStorage()") + Declaration("var storage = Storage()") } fileprivate func generateInitializer() -> Declaration { Declaration("init(row: SQLRow) throws") { + "let reader = SQLRowReader(row: row, keyMapping: Self.keyMapping, jsonDecoder: Self.jsonDecoder)" for property in storedProperties where property.name != "id" { - "self.\(property.name) = try row.require(\(property.name.inQuotes)).decode(\(property.type).self)" + "self.\(property.name) = try reader.require(\(property.type).self, at: \(property.name.inQuotes))" } + + "storage.row = row" } .access(accessLevel == "public" ? "public" : nil) } fileprivate func generateFields() -> Declaration { - Declaration("func fields() -> SQLFields") { - "[:]" + Declaration("func fields() throws -> SQLFields") { + "let writer = SQLRowWriter(keyMapping: Self.keyMapping, jsonEncoder: Self.jsonEncoder)" + for property in storedProperties { + "try writer.put(\(property.name), at: \(property.name.inQuotes))" + } + """ + return writer.fields + """ } + .access(accessLevel == "public" ? "public" : nil) } fileprivate func generateFieldLookup() -> Declaration { diff --git a/Example/App.swift b/Example/App.swift index 412d214d..fc76beb3 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -12,6 +12,11 @@ struct App { "Hello, \(email)!" } + @GET("/todos") + func getTodos() async throws -> [Todo] { + try await Todo.all() + } + @Job static func expensiveWork(name: String) { print("This is expensive!") @@ -39,6 +44,15 @@ struct Todo: Codable { } extension App { + var databases: Databases { + Databases( + default: "sqlite", + databases: [ + "sqlite": .sqlite(path: "../AlchemyXDemo/Server/test.db") + ] + ) + } + var queues: Queues { Queues( default: "memory", From 910c4046e1d46397d4f67e51882895f53143bed2 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 18 Jun 2024 23:42:09 -0700 Subject: [PATCH 34/55] add encoding of relationships --- Alchemy/AlchemyMacros.swift | 2 +- Alchemy/AlchemyX/Database+Resource.swift | 10 --- .../Rune/Model/Coding/SQLRowReader.swift | 6 +- .../Rune/Model/Coding/SQLRowWriter.swift | 2 +- Alchemy/Database/Rune/Model/Model+CRUD.swift | 4 -- Alchemy/Database/Rune/Model/Model.swift | 2 +- .../Database/Rune/Model/ModelStorage.swift | 39 +++++----- .../Rune/Relations/EagerLoadable.swift | 6 ++ Alchemy/Database/SQL/SQLRow.swift | 4 +- Alchemy/HTTP/Content/Content.swift | 15 ---- Alchemy/HTTP/Response.swift | 8 --- Alchemy/Utilities/AnyEncodable.swift | 11 +++ Alchemy/Utilities/AnyOptional.swift | 9 +++ Alchemy/Utilities/GenericCodingKey.swift | 24 +++++++ AlchemyPlugin/Sources/Macros/IDMacro.swift | 35 +++++++++ AlchemyPlugin/Sources/Macros/JobMacro.swift | 2 +- AlchemyPlugin/Sources/Macros/ModelMacro.swift | 71 ++++++++++--------- Example/App.swift | 11 ++- 18 files changed, 162 insertions(+), 99 deletions(-) create mode 100644 Alchemy/Utilities/AnyEncodable.swift create mode 100644 Alchemy/Utilities/AnyOptional.swift create mode 100644 Alchemy/Utilities/GenericCodingKey.swift create mode 100644 AlchemyPlugin/Sources/Macros/IDMacro.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index b2c3f2f9..31c5dcca 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -9,7 +9,7 @@ public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") @attached(memberAttribute) @attached(member, names: named(storage), named(fieldLookup)) -@attached(extension, conformances: Model, names: named(init), named(fields)) +@attached(extension, conformances: Model, Codable, names: named(init), named(fields), named(encode)) public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro") @attached(accessor) diff --git a/Alchemy/AlchemyX/Database+Resource.swift b/Alchemy/AlchemyX/Database+Resource.swift index 2efe7502..35af0957 100644 --- a/Alchemy/AlchemyX/Database+Resource.swift +++ b/Alchemy/AlchemyX/Database+Resource.swift @@ -91,16 +91,6 @@ extension ResourceField { } } -private protocol AnyOptional { - static var wrappedType: Any.Type { get } -} - -extension Optional: AnyOptional { - static fileprivate var wrappedType: Any.Type { - Wrapped.self - } -} - extension CreateColumnBuilder { @discardableResult func `default`(any: Any?) -> Self { guard let any else { return self } diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift index c3dc184d..6d312309 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift @@ -15,7 +15,11 @@ public struct SQLRowReader { public func requireJSON(_ key: String) throws -> D { let key = keyMapping.encode(key) - return try jsonDecoder.decode(D.self, from: row.require(key).json(key)) + if let type = D.self as? AnyOptional.Type, row[key, default: .null] == .null { + return type.nilValue as! D + } else { + return try jsonDecoder.decode(D.self, from: row.require(key).json(key)) + } } public func require(_ type: D.Type, at key: String) throws -> D { diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift index aecbe505..79af5214 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift @@ -27,7 +27,7 @@ public final class SQLRowWriter { extension SQLRowWriter { public func put(_ value: ModelProperty, at key: String) throws { - + } public func put(_ value: some Encodable, at key: String) throws { diff --git a/Alchemy/Database/Rune/Model/Model+CRUD.swift b/Alchemy/Database/Rune/Model/Model+CRUD.swift index aeead9fd..4247638f 100644 --- a/Alchemy/Database/Rune/Model/Model+CRUD.swift +++ b/Alchemy/Database/Rune/Model/Model+CRUD.swift @@ -85,8 +85,6 @@ extension Model { throw RuneError.notFound } - self.row = model.row - self.id = model.id return model } @@ -141,8 +139,6 @@ extension Model { throw RuneError.notFound } - self.row = model.row - self.id = model.id return model } diff --git a/Alchemy/Database/Rune/Model/Model.swift b/Alchemy/Database/Rune/Model/Model.swift index 239ffbff..06979002 100644 --- a/Alchemy/Database/Rune/Model/Model.swift +++ b/Alchemy/Database/Rune/Model/Model.swift @@ -10,7 +10,7 @@ public protocol Model: Identifiable, QueryResult, ModelOrOptional { associatedtype PrimaryKey: PrimaryKeyProtocol /// The identifier of this model - var id: PrimaryKey { get nonmutating set } + var id: PrimaryKey { get set } /// Storage for model metadata (relationships, original row, etc). var storage: Storage { get } diff --git a/Alchemy/Database/Rune/Model/ModelStorage.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift index 055313ce..be1be90e 100644 --- a/Alchemy/Database/Rune/Model/ModelStorage.swift +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -1,12 +1,21 @@ public final class ModelStorage: Codable { public var id: M.PrimaryKey? - public var row: SQLRow? - var relationships: [String: Any] + public var row: SQLRow? { + didSet { + if let value = try? row?.require(M.primaryKey) { + self.id = try? M.PrimaryKey(value: value) + } + } + } + + public var relationships: [String: Any] + public var encodableCache: [String: AnyEncodable] public init() { self.id = nil self.row = nil self.relationships = [:] + self.encodableCache = [:] } public func encode(to encoder: Encoder) throws { @@ -29,13 +38,7 @@ extension KeyedDecodingContainer { _ type: ModelStorage, forKey key: Self.Key ) throws -> ModelStorage { - let storage = M.Storage() - let hasId = allKeys.contains { $0.stringValue == M.primaryKey } - if hasId, let key = K(stringValue: M.primaryKey) { - storage.id = try decode(M.PrimaryKey.self, forKey: key) - } - - return storage + M.Storage() } } @@ -44,12 +47,7 @@ extension KeyedEncodingContainer { _ value: ModelStorage, forKey key: KeyedEncodingContainer.Key ) throws { - // encode id - if let key = K(stringValue: M.primaryKey) { - try encode(value.id, forKey: key) - } - - // encode relationships + // ignore } } @@ -61,14 +59,19 @@ extension Model { func mergeCache(_ otherModel: Self) { storage.relationships = otherModel.storage.relationships + storage.encodableCache = otherModel.storage.encodableCache } func cache(_ value: To, at key: String) { - storage.relationships[key] = value + if let value = value as? Encodable { + storage.encodableCache[key] = AnyEncodable(value) + } else { + storage.relationships[key] = value + } } func cached(at key: String, _ type: To.Type = To.self) throws -> To? { - guard let value = storage.relationships[key] else { + guard let value = storage.relationships[key] ?? storage.encodableCache[key] else { return nil } @@ -80,6 +83,6 @@ extension Model { } func cacheExists(_ key: String) -> Bool { - storage.relationships[key] != nil + storage.relationships[key] != nil || storage.encodableCache[key] != nil } } diff --git a/Alchemy/Database/Rune/Relations/EagerLoadable.swift b/Alchemy/Database/Rune/Relations/EagerLoadable.swift index e652c61b..190ecafd 100644 --- a/Alchemy/Database/Rune/Relations/EagerLoadable.swift +++ b/Alchemy/Database/Rune/Relations/EagerLoadable.swift @@ -84,4 +84,10 @@ extension Array where Element: Model { guard let first else { return } try await loader(first).load(on: self) } + + public func with(_ loader: @escaping (Element) -> E) async throws -> Self where E.From == Element { + guard let first else { return self } + try await loader(first).load(on: self) + return self + } } diff --git a/Alchemy/Database/SQL/SQLRow.swift b/Alchemy/Database/SQL/SQLRow.swift index af3d846c..4b29ec28 100644 --- a/Alchemy/Database/SQL/SQLRow.swift +++ b/Alchemy/Database/SQL/SQLRow.swift @@ -40,8 +40,8 @@ public struct SQLRow: ExpressibleByDictionaryLiteral { fields.elements[index].value.sqlValue } - public subscript(_ column: String) -> SQLValue? { - fields[column]?.sqlValue + public subscript(_ column: String, default default: SQLValue? = nil) -> SQLValue? { + fields[column]?.sqlValue ?? `default` } } diff --git a/Alchemy/HTTP/Content/Content.swift b/Alchemy/HTTP/Content/Content.swift index 2ea4f40a..bc4ceead 100644 --- a/Alchemy/HTTP/Content/Content.swift +++ b/Alchemy/HTTP/Content/Content.swift @@ -406,21 +406,6 @@ extension Content: Encodable { } extension Content.Value: Encodable { - private struct GenericCodingKey: CodingKey { - let stringValue: String - let intValue: Int? - - init(stringValue: String) { - self.stringValue = stringValue - self.intValue = Int(stringValue) - } - - init(intValue: Int) { - self.stringValue = "\(intValue)" - self.intValue = intValue - } - } - public func encode(to encoder: Encoder) throws { switch self { case .array(let array): diff --git a/Alchemy/HTTP/Response.swift b/Alchemy/HTTP/Response.swift index db6bcb94..ffeffa17 100644 --- a/Alchemy/HTTP/Response.swift +++ b/Alchemy/HTTP/Response.swift @@ -72,14 +72,6 @@ public final class Response { /// Creates a new body containing the text of the given string. public convenience init(status: HTTPResponseStatus = .ok, headers: HTTPHeaders = [:], dict: [String: Encodable], encoder: HTTPEncoder = Bytes.defaultEncoder) throws { - struct AnyEncodable: Encodable { - let value: Encodable - - func encode(to encoder: Encoder) throws { - try value.encode(to: encoder) - } - } - let dict = dict.compactMapValues(AnyEncodable.init) try self.init(status: status, headers: headers, encodable: dict, encoder: encoder) } diff --git a/Alchemy/Utilities/AnyEncodable.swift b/Alchemy/Utilities/AnyEncodable.swift new file mode 100644 index 00000000..f37ed079 --- /dev/null +++ b/Alchemy/Utilities/AnyEncodable.swift @@ -0,0 +1,11 @@ +public struct AnyEncodable: Encodable { + let value: Encodable + + init(_ value: Encodable) { + self.value = value + } + + public func encode(to encoder: Encoder) throws { + try value.encode(to: encoder) + } +} diff --git a/Alchemy/Utilities/AnyOptional.swift b/Alchemy/Utilities/AnyOptional.swift new file mode 100644 index 00000000..c9f6a890 --- /dev/null +++ b/Alchemy/Utilities/AnyOptional.swift @@ -0,0 +1,9 @@ +protocol AnyOptional { + static var wrappedType: Any.Type { get } + static var nilValue: Self { get } +} + +extension Optional: AnyOptional { + static var wrappedType: Any.Type { Wrapped.self } + static var nilValue: Self { nil} +} diff --git a/Alchemy/Utilities/GenericCodingKey.swift b/Alchemy/Utilities/GenericCodingKey.swift new file mode 100644 index 00000000..daf00026 --- /dev/null +++ b/Alchemy/Utilities/GenericCodingKey.swift @@ -0,0 +1,24 @@ +public struct GenericCodingKey: CodingKey, ExpressibleByStringLiteral { + public let stringValue: String + public let intValue: Int? + + public init(stringValue: String) { + self.stringValue = stringValue + self.intValue = Int(stringValue) + } + + public init(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } + + public static func key(_ string: String) -> GenericCodingKey { + .init(stringValue: string) + } + + // MARK: ExpressibleByStringLiteral + + public init(stringLiteral value: String) { + self.init(stringValue: value) + } +} diff --git a/AlchemyPlugin/Sources/Macros/IDMacro.swift b/AlchemyPlugin/Sources/Macros/IDMacro.swift new file mode 100644 index 00000000..7d6f7a01 --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/IDMacro.swift @@ -0,0 +1,35 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +struct IDMacro: AccessorMacro { + + // MARK: AccessorMacro + + static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + guard let variable = declaration.as(VariableDeclSyntax.self) else { + throw AlchemyMacroError("@ID can only be applied to a stored property.") + } + + let property = try Resource.Property.parse(variable: variable) + guard property.keyword == "var" else { + throw AlchemyMacroError("Property 'id' must be a var.") + } + + return [ + """ + get { + guard let id = storage.id else { + preconditionFailure("Attempting to access 'id' from Model that doesn't have one.") + } + + return id + } + """, + "nonmutating set { storage.id = newValue }", + ] + } +} diff --git a/AlchemyPlugin/Sources/Macros/JobMacro.swift b/AlchemyPlugin/Sources/Macros/JobMacro.swift index e3408808..b683f279 100644 --- a/AlchemyPlugin/Sources/Macros/JobMacro.swift +++ b/AlchemyPlugin/Sources/Macros/JobMacro.swift @@ -7,7 +7,7 @@ struct JobMacro: PeerMacro { providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard + guard let function = declaration.as(FunctionDeclSyntax.self), function.isStatic else { diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index 6c754c81..e2f024b4 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -1,39 +1,6 @@ import SwiftSyntax import SwiftSyntaxMacros -struct IDMacro: AccessorMacro { - - // MARK: AccessorMacro - - static func expansion( - of node: AttributeSyntax, - providingAccessorsOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [AccessorDeclSyntax] { - guard let variable = declaration.as(VariableDeclSyntax.self) else { - throw AlchemyMacroError("@ID can only be applied to a stored property.") - } - - let property = try Resource.Property.parse(variable: variable) - guard property.keyword == "var" else { - throw AlchemyMacroError("Property 'id' must be a var.") - } - - return [ - """ - get { - guard let id = storage.id else { - preconditionFailure("Attempting to access 'id' from Model that doesn't have one.") - } - - return id - } - """, - "nonmutating set { storage.id = newValue }", - ] - } -} - struct ModelMacro: MemberMacro, ExtensionMacro, MemberAttributeMacro { // MARK: ExtensionMacro @@ -47,9 +14,11 @@ struct ModelMacro: MemberMacro, ExtensionMacro, MemberAttributeMacro { ) throws -> [ExtensionDeclSyntax] { let resource = try Resource.parse(syntax: declaration) return try [ - Declaration("extension \(resource.name): Model") { + Declaration("extension \(resource.name): Model, Codable") { resource.generateInitializer() resource.generateFields() + resource.generateEncode() + resource.generateDecode() } ] .map { try $0.extensionDeclSyntax() } @@ -201,6 +170,40 @@ extension Resource { .access(accessLevel == "public" ? "public" : nil) } + fileprivate func generateEncode() -> Declaration { + Declaration("func encode(to encoder: Encoder) throws") { + "var container = encoder.container(keyedBy: GenericCodingKey.self)" + for property in storedProperties { + "try container.encode(\(property.name), forKey: \(property.name.inQuotes))" + } + + """ + for (key, relationship) in storage.encodableCache { + try container.encode(relationship, forKey: .key(key)) + } + """ + } + .access(accessLevel == "public" ? "public" : nil) + } + + fileprivate func generateDecode() -> Declaration { + Declaration("init(from decoder: Decoder) throws") { + "let container = try decoder.container(keyedBy: GenericCodingKey.self)" + for property in storedProperties where property.name != "id" { + "self.\(property.name) = try container.decode(\(property.type).self, forKey: \(property.name.inQuotes))" + } + + if let idType = storedProperties.first(where: { $0.name == "id" })?.type { + """ + if container.contains("id") { + self.id = try container.decode(\(idType).self, forKey: "id") + } + """ + } + } + .access(accessLevel == "public" ? "public" : nil) + } + fileprivate func generateFieldLookup() -> Declaration { let fieldsString = storedProperties .map { property in diff --git a/Example/App.swift b/Example/App.swift index fc76beb3..bc51d47d 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -14,7 +14,7 @@ struct App { @GET("/todos") func getTodos() async throws -> [Todo] { - try await Todo.all() + try await Todo.all().with { $0.this.with(\.this) } } @Job @@ -37,10 +37,15 @@ struct UserController { } @Model -struct Todo: Codable { +struct Todo { var id: Int let name: String var isDone: Bool = false + let tags: [String]? + + var this: HasMany { + hasMany(from: "id", to: "id") + } } extension App { @@ -48,7 +53,7 @@ extension App { Databases( default: "sqlite", databases: [ - "sqlite": .sqlite(path: "../AlchemyXDemo/Server/test.db") + "sqlite": .sqlite(path: "../AlchemyXDemo/Server/test.db").logRawSQL() ] ) } From 631567d474405d3dce60b0d28567c7a773860025 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 19 Jun 2024 09:26:58 -0700 Subject: [PATCH 35/55] bit of cleanup --- .../Database/Rune/Model/ModelStorage.swift | 21 ++++++++----------- AlchemyPlugin/Sources/Macros/ModelMacro.swift | 6 +----- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/Alchemy/Database/Rune/Model/ModelStorage.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift index be1be90e..fc3be4fc 100644 --- a/Alchemy/Database/Rune/Model/ModelStorage.swift +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -9,18 +9,20 @@ public final class ModelStorage: Codable { } public var relationships: [String: Any] - public var encodableCache: [String: AnyEncodable] public init() { self.id = nil self.row = nil self.relationships = [:] - self.encodableCache = [:] } public func encode(to encoder: Encoder) throws { - // instead, use the KeyedEncodingContainer extension below. - preconditionFailure("Directly encoding ModelStorage not supported!") + var container = encoder.container(keyedBy: GenericCodingKey.self) + for (key, relationship) in relationships { + if let relationship = relationship as? AnyEncodable { + try container.encode(relationship, forKey: .key(key)) + } + } } public init(from decoder: Decoder) throws { @@ -59,19 +61,14 @@ extension Model { func mergeCache(_ otherModel: Self) { storage.relationships = otherModel.storage.relationships - storage.encodableCache = otherModel.storage.encodableCache } func cache(_ value: To, at key: String) { - if let value = value as? Encodable { - storage.encodableCache[key] = AnyEncodable(value) - } else { - storage.relationships[key] = value - } + storage.relationships[key] = value } func cached(at key: String, _ type: To.Type = To.self) throws -> To? { - guard let value = storage.relationships[key] ?? storage.encodableCache[key] else { + guard let value = storage.relationships[key] else { return nil } @@ -83,6 +80,6 @@ extension Model { } func cacheExists(_ key: String) -> Bool { - storage.relationships[key] != nil || storage.encodableCache[key] != nil + storage.relationships[key] != nil } } diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index e2f024b4..c6f2a299 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -177,11 +177,7 @@ extension Resource { "try container.encode(\(property.name), forKey: \(property.name.inQuotes))" } - """ - for (key, relationship) in storage.encodableCache { - try container.encode(relationship, forKey: .key(key)) - } - """ + "try storage.encode(to: encoder)" } .access(accessLevel == "public" ? "public" : nil) } From d3a3cf5221831a63a2d2a995c2fd93928cf3988c Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 19 Jun 2024 12:51:16 -0700 Subject: [PATCH 36/55] hasmany --- Alchemy/AlchemyMacros.swift | 6 ++ .../Database/Rune/Model/ModelStorage.swift | 12 +-- .../Rune/Relations/EagerLoadable.swift | 11 ++- .../Database/Rune/Relations/Relation.swift | 12 ++- AlchemyPlugin/Sources/AlchemyPlugin.swift | 1 + AlchemyPlugin/Sources/Macros/ModelMacro.swift | 4 +- .../Sources/Macros/RelationshipMacro.swift | 76 +++++++++++++++++++ Example/App.swift | 6 +- 8 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 AlchemyPlugin/Sources/Macros/RelationshipMacro.swift diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index 31c5dcca..b047259b 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -7,6 +7,8 @@ public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "Contr @attached(peer, names: prefixed(`$`)) public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") +// MARK: Rune + @attached(memberAttribute) @attached(member, names: named(storage), named(fieldLookup)) @attached(extension, conformances: Model, Codable, names: named(init), named(fields), named(encode)) @@ -15,6 +17,10 @@ public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro @attached(accessor) public macro ID() = #externalMacro(module: "AlchemyPlugin", type: "IDMacro") +@attached(accessor) +@attached(peer, names: prefixed(`$`)) +public macro HasMany() = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro") + // MARK: Route Methods @attached(peer, names: prefixed(`$`)) public macro HTTP(_ method: String, _ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro") diff --git a/Alchemy/Database/Rune/Model/ModelStorage.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift index fc3be4fc..640df9c7 100644 --- a/Alchemy/Database/Rune/Model/ModelStorage.swift +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -8,7 +8,7 @@ public final class ModelStorage: Codable { } } - public var relationships: [String: Any] + public var relationships: [CacheKey: Any] public init() { self.id = nil @@ -19,8 +19,8 @@ public final class ModelStorage: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: GenericCodingKey.self) for (key, relationship) in relationships { - if let relationship = relationship as? AnyEncodable { - try container.encode(relationship, forKey: .key(key)) + if let relationship = relationship as? Encodable, let name = key.name { + try container.encode(AnyEncodable(relationship), forKey: .key(name)) } } } @@ -63,11 +63,11 @@ extension Model { storage.relationships = otherModel.storage.relationships } - func cache(_ value: To, at key: String) { + func cache(_ value: To, at key: CacheKey) { storage.relationships[key] = value } - func cached(at key: String, _ type: To.Type = To.self) throws -> To? { + func cached(at key: CacheKey, _ type: To.Type = To.self) throws -> To? { guard let value = storage.relationships[key] else { return nil } @@ -79,7 +79,7 @@ extension Model { return value } - func cacheExists(_ key: String) -> Bool { + func cacheExists(_ key: CacheKey) -> Bool { storage.relationships[key] != nil } } diff --git a/Alchemy/Database/Rune/Relations/EagerLoadable.swift b/Alchemy/Database/Rune/Relations/EagerLoadable.swift index 190ecafd..39eac5cc 100644 --- a/Alchemy/Database/Rune/Relations/EagerLoadable.swift +++ b/Alchemy/Database/Rune/Relations/EagerLoadable.swift @@ -5,16 +5,21 @@ public protocol EagerLoadable { /// The model instance this relation was accessed from. var from: From { get } - var cacheKey: String { get } + var cacheKey: CacheKey { get } /// Load results given the input rows. Results must be the same length and /// order as the input. func fetch(for models: [From]) async throws -> [To] } +public struct CacheKey: Hashable { + public let name: String? + public let value: String +} + extension EagerLoadable { - public var cacheKey: String { - "\(Self.self)" + public var cacheKey: CacheKey { + CacheKey(name: nil, value: "\(Self.self)") } public var isLoaded: Bool { diff --git a/Alchemy/Database/Rune/Relations/Relation.swift b/Alchemy/Database/Rune/Relations/Relation.swift index 1c7c5329..ec6987c2 100644 --- a/Alchemy/Database/Rune/Relations/Relation.swift +++ b/Alchemy/Database/Rune/Relations/Relation.swift @@ -10,6 +10,7 @@ public class Relation: Query, EagerLoadable { var toKey: SQLKey var lookupKey: String var throughs: [Through] + var name: String? = nil public override var sql: SQL { sql(for: [from]) @@ -22,11 +23,11 @@ public class Relation: Query, EagerLoadable { return copy.`where`(lookupKey, in: fromKeys).sql } - public var cacheKey: String { - let key = "\(name(of: Self.self))_\(fromKey)_\(toKey)" + public var cacheKey: CacheKey { + let key = "\(Self.self)_\(fromKey)_\(toKey)" let throughKeys = throughs.map { "\($0.table)_\($0.from)_\($0.to)" } let whereKeys = wheres.map { "\($0.hashValue)" } - return ([key] + throughKeys + whereKeys).joined(separator: ":") + return CacheKey(name: name, value: ([key] + throughKeys + whereKeys).joined(separator: ":")) } public init(db: Database, from: From, fromKey: SQLKey, toKey: SQLKey) { @@ -87,4 +88,9 @@ public class Relation: Query, EagerLoadable { return value } + + public func named(_ name: String) -> Self { + self.name = name + return self + } } diff --git a/AlchemyPlugin/Sources/AlchemyPlugin.swift b/AlchemyPlugin/Sources/AlchemyPlugin.swift index 8e93521e..a041417a 100644 --- a/AlchemyPlugin/Sources/AlchemyPlugin.swift +++ b/AlchemyPlugin/Sources/AlchemyPlugin.swift @@ -12,6 +12,7 @@ struct AlchemyPlugin: CompilerPlugin { ApplicationMacro.self, ControllerMacro.self, HTTPMethodMacro.self, + RelationshipMacro.self, ] } diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index c6f2a299..67f3bc06 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -240,7 +240,9 @@ extension DeclGroupSyntax { } var instanceMembers: [VariableDeclSyntax] { - members.filter { !$0.isStatic } + members + .filter { !$0.isStatic } + .filter { $0.attributes.isEmpty } } } diff --git a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift new file mode 100644 index 00000000..98ac423e --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift @@ -0,0 +1,76 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +public enum RelationshipMacro: AccessorMacro, PeerMacro { + + // MARK: AccessorMacro + + public static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + guard let declaration = declaration.as(VariableDeclSyntax.self) else { + throw AlchemyMacroError("@\(node.name) can only be applied to variables") + } + + return [ + """ + get async throws { + try await $\(raw: declaration.name).get() + } + """ + ] + } + + // MARK: PeerMacro + + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let declaration = declaration.as(VariableDeclSyntax.self) else { + throw AlchemyMacroError("@\(node.name) can only be applied to variables") + } + + return [ + Declaration("var $\(declaration.name): \(node.name)<\(declaration.type).Element>") { + "\(node.name.lowercaseFirst)().named(\(declaration.name.inQuotes))" + } + ] + .map { $0.declSyntax() } + } +} + +extension String { + var lowercaseFirst: String { + prefix(1).lowercased() + dropFirst() + } +} + +extension VariableDeclSyntax { + var name: String { + bindings.compactMap { + $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.trimmedDescription + }.first ?? "unknown" + } + + var type: String { + bindings.compactMap { + $0.typeAnnotation?.type.trimmedDescription + }.first ?? "unknown" + } +} + +/* + @HasMany var todos: [Todo] { + get async throws { + try await $todos.get() + } + } + + var $todos: HasMany { + hasMany(from: "id", to: "id") + } + */ diff --git a/Example/App.swift b/Example/App.swift index bc51d47d..46de1aca 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -14,7 +14,7 @@ struct App { @GET("/todos") func getTodos() async throws -> [Todo] { - try await Todo.all().with { $0.this.with(\.this) } + try await Todo.all().with(\.$todos) } @Job @@ -43,9 +43,7 @@ struct Todo { var isDone: Bool = false let tags: [String]? - var this: HasMany { - hasMany(from: "id", to: "id") - } + @HasMany var todos: [Todo] } extension App { From e3523cf524e85f4efffd7b3f8f8e731c1ad23678 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 20 Jun 2024 07:56:30 -0700 Subject: [PATCH 37/55] relationship macros --- Alchemy/AlchemyMacros.swift | 54 ++++++++++++++++++- Alchemy/Auth/TokenAuthable.swift | 2 +- .../Database/Rune/Relations/BelongsTo.swift | 6 +-- .../Rune/Relations/BelongsToMany.swift | 28 ++++++---- .../Rune/Relations/BelongsToThrough.swift | 25 +++++++-- Alchemy/Database/Rune/Relations/HasMany.swift | 6 +-- .../Rune/Relations/HasManyThrough.swift | 20 +++++-- Alchemy/Database/Rune/Relations/HasOne.swift | 6 +-- .../Rune/Relations/HasOneThrough.swift | 20 +++++-- .../{Relation.swift => Relationship.swift} | 4 +- .../Sources/Macros/RelationshipMacro.swift | 8 ++- Example/App.swift | 13 ++++- 12 files changed, 151 insertions(+), 41 deletions(-) rename Alchemy/Database/Rune/Relations/{Relation.swift => Relationship.swift} (95%) diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift index b047259b..f32fb91b 100644 --- a/Alchemy/AlchemyMacros.swift +++ b/Alchemy/AlchemyMacros.swift @@ -7,7 +7,7 @@ public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "Contr @attached(peer, names: prefixed(`$`)) public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") -// MARK: Rune +// MARK: Rune - Model @attached(memberAttribute) @attached(member, names: named(storage), named(fieldLookup)) @@ -17,9 +17,59 @@ public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro @attached(accessor) public macro ID() = #externalMacro(module: "AlchemyPlugin", type: "IDMacro") +// MARK: Rune - Relationships + +@attached(accessor) +@attached(peer, names: prefixed(`$`)) +public macro HasMany(from: String? = nil, to: String? = nil) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro") + +@attached(accessor) +@attached(peer, names: prefixed(`$`)) +public macro HasManyThrough( + _ through: String, + from: String? = nil, + to: String? = nil, + throughFrom: String? = nil, + throughTo: String? = nil +) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro") + +@attached(accessor) +@attached(peer, names: prefixed(`$`)) +public macro HasOne(from: String? = nil, to: String? = nil) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro") + +@attached(accessor) +@attached(peer, names: prefixed(`$`)) +public macro HasOneThrough( + _ through: String, + from: String? = nil, + to: String? = nil, + throughFrom: String? = nil, + throughTo: String? = nil +) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro") + +@attached(accessor) +@attached(peer, names: prefixed(`$`)) +public macro BelongsTo(from: String? = nil, to: String? = nil) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro") + +@attached(accessor) +@attached(peer, names: prefixed(`$`)) +public macro BelongsToThrough( + _ through: String, + from: String? = nil, + to: String? = nil, + throughFrom: String? = nil, + throughTo: String? = nil +) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro") + @attached(accessor) @attached(peer, names: prefixed(`$`)) -public macro HasMany() = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro") +public macro BelongsToMany( + _ pivot: String? = nil, + from: String? = nil, + to: String? = nil, + pivotFrom: String? = nil, + pivotTo: String? = nil +) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro") // MARK: Route Methods diff --git a/Alchemy/Auth/TokenAuthable.swift b/Alchemy/Auth/TokenAuthable.swift index 60575525..a578780a 100644 --- a/Alchemy/Auth/TokenAuthable.swift +++ b/Alchemy/Auth/TokenAuthable.swift @@ -40,7 +40,7 @@ public protocol TokenAuthable: Model { /// this type will be pulled from the database and /// associated with the request. associatedtype Authorizes: Model - associatedtype AuthorizesRelation: Relation + associatedtype AuthorizesRelation: Relationship /// The user in question. var user: AuthorizesRelation { get } diff --git a/Alchemy/Database/Rune/Relations/BelongsTo.swift b/Alchemy/Database/Rune/Relations/BelongsTo.swift index 757fffb1..c4849c90 100644 --- a/Alchemy/Database/Rune/Relations/BelongsTo.swift +++ b/Alchemy/Database/Rune/Relations/BelongsTo.swift @@ -1,5 +1,5 @@ extension Model { - public typealias BelongsTo = BelongsToRelation + public typealias BelongsTo = BelongsToRelationship public func belongsTo(_ type: To.Type = To.self, on db: Database = To.M.database, @@ -9,8 +9,8 @@ extension Model { } } -public class BelongsToRelation: Relation { - init(db: Database, from: From, fromKey: String?, toKey: String?) { +public class BelongsToRelationship: Relationship { + public init(db: Database = To.M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { let fromKey: SQLKey = db.inferReferenceKey(To.M.self).specify(fromKey) let toKey: SQLKey = .infer(To.M.primaryKey).specify(toKey) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) diff --git a/Alchemy/Database/Rune/Relations/BelongsToMany.swift b/Alchemy/Database/Rune/Relations/BelongsToMany.swift index d9344489..e37781e9 100644 --- a/Alchemy/Database/Rune/Relations/BelongsToMany.swift +++ b/Alchemy/Database/Rune/Relations/BelongsToMany.swift @@ -1,29 +1,39 @@ import Collections extension Model { - public typealias BelongsToMany = BelongsToManyRelation + public typealias BelongsToMany = BelongsToManyRelationship - public func belongsToMany(_ type: To.Type = To.self, - on db: Database = To.M.database, + public func belongsToMany(on db: Database = To.M.database, + _ pivotTable: String? = nil, from fromKey: String? = nil, to toKey: String? = nil, - pivot: String? = nil, pivotFrom: String? = nil, pivotTo: String? = nil) -> BelongsToMany { - BelongsToMany(db: db, from: self, fromKey: fromKey, toKey: toKey, pivot: pivot, pivotFrom: pivotFrom, pivotTo: pivotTo) + BelongsToMany(db: db, from: self, pivotTable, fromKey: fromKey, toKey: toKey, pivotFrom: pivotFrom, pivotTo: pivotTo) } } -public class BelongsToManyRelation: Relation { +public class BelongsToManyRelationship: Relationship { private var pivot: Through { - guard let pivot = throughs.first else { preconditionFailure("BelongsToManyRelation must never have no throughs.") } + guard let pivot = throughs.first else { + preconditionFailure("BelongsToManyRelationship must always have at least 1 through.") + } + return pivot } - init(db: Database, from: From, fromKey: String?, toKey: String?, pivot: String?, pivotFrom: String?, pivotTo: String?) { + public init( + db: Database = M.database, + from: From, + _ pivotTable: String? = nil, + fromKey: String? = nil, + toKey: String? = nil, + pivotFrom: String? = nil, + pivotTo: String? = nil + ) { let fromKey: SQLKey = .infer(From.primaryKey).specify(fromKey) let toKey: SQLKey = .infer(M.primaryKey).specify(toKey) - let pivot: String = pivot ?? From.table.singularized + "_" + M.table.singularized + let pivot: String = pivotTable ?? From.table.singularized + "_" + M.table.singularized let pivotFrom: SQLKey = db.inferReferenceKey(From.self).specify(pivotFrom) let pivotTo: SQLKey = db.inferReferenceKey(M.self).specify(pivotTo) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) diff --git a/Alchemy/Database/Rune/Relations/BelongsToThrough.swift b/Alchemy/Database/Rune/Relations/BelongsToThrough.swift index 393a01cc..14ce78cd 100644 --- a/Alchemy/Database/Rune/Relations/BelongsToThrough.swift +++ b/Alchemy/Database/Rune/Relations/BelongsToThrough.swift @@ -1,19 +1,34 @@ extension Model { - public typealias BelongsToThrough = BelongsToThroughRelation + public typealias BelongsToThrough = BelongsToThroughRelationship + + public func belongsToThrough(db: Database = To.M.database, + _ through: String, + fromKey: String? = nil, + toKey: String? = nil, + throughFromKey: String? = nil, + throughToKey: String? = nil) -> BelongsToThrough { + belongsTo(To.self, on: db, from: fromKey, to: toKey) + .through(through, from: throughFromKey, to: throughToKey) + } } -extension BelongsToRelation { +extension BelongsToRelationship { public func through(_ table: String, from throughFromKey: String? = nil, to throughToKey: String? = nil) -> From.BelongsToThrough { - BelongsToThroughRelation(belongsTo: self, through: table, fromKey: throughFromKey, toKey: throughToKey) + BelongsToThroughRelationship(belongsTo: self, through: table, fromKey: throughFromKey, toKey: throughToKey) } } -public final class BelongsToThroughRelation: Relation { - init(belongsTo: BelongsToRelation, through table: String, fromKey: String?, toKey: String?) { +public final class BelongsToThroughRelationship: Relationship { + public init(belongsTo: BelongsToRelationship, through table: String, fromKey: String?, toKey: String?) { super.init(db: belongsTo.db, from: belongsTo.from, fromKey: belongsTo.fromKey, toKey: belongsTo.toKey) through(table, from: fromKey, to: toKey) } + public convenience init(db: Database = To.M.database, from: From, _ through: String, fromKey: String? = nil, toKey: String? = nil, throughFromKey: String? = nil, throughToKey: String? = nil) { + let belongsTo = From.BelongsTo(db: db, from: from, fromKey: fromKey, toKey: toKey) + self.init(belongsTo: belongsTo, through: through, fromKey: fromKey, toKey: toKey) + } + @discardableResult public func through(_ table: String, from throughFromKey: String? = nil, to throughToKey: String? = nil) -> Self { let from: SQLKey = .infer(From.primaryKey).specify(throughFromKey) diff --git a/Alchemy/Database/Rune/Relations/HasMany.swift b/Alchemy/Database/Rune/Relations/HasMany.swift index 22fc7bba..56406b73 100644 --- a/Alchemy/Database/Rune/Relations/HasMany.swift +++ b/Alchemy/Database/Rune/Relations/HasMany.swift @@ -1,5 +1,5 @@ extension Model { - public typealias HasMany = HasManyRelation + public typealias HasMany = HasManyRelationship public func hasMany(_ type: To.Type = To.self, on db: Database = To.M.database, @@ -9,8 +9,8 @@ extension Model { } } -public class HasManyRelation: Relation { - init(db: Database, from: From, fromKey: String?, toKey: String?) { +public class HasManyRelationship: Relationship { + public init(db: Database = M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { let fromKey: SQLKey = .infer(From.primaryKey).specify(fromKey) let toKey: SQLKey = db.inferReferenceKey(From.self).specify(toKey) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) diff --git a/Alchemy/Database/Rune/Relations/HasManyThrough.swift b/Alchemy/Database/Rune/Relations/HasManyThrough.swift index bc0216e1..39a4fc40 100644 --- a/Alchemy/Database/Rune/Relations/HasManyThrough.swift +++ b/Alchemy/Database/Rune/Relations/HasManyThrough.swift @@ -1,15 +1,25 @@ extension Model { - public typealias HasManyThrough = HasManyThroughRelation + public typealias HasManyThrough = HasManyThroughRelationship + + public func hasManyThrough(db: Database = To.M.database, + _ through: String, + fromKey: String? = nil, + toKey: String? = nil, + throughFromKey: String? = nil, + throughToKey: String? = nil) -> HasManyThrough { + hasMany(To.self, on: db, from: fromKey, to: toKey) + .through(through, from: throughFromKey, to: throughToKey) + } } -extension HasManyRelation { +extension HasManyRelationship { public func through(_ table: String, from throughFromKey: String? = nil, to throughToKey: String? = nil) -> From.HasManyThrough { - HasManyThroughRelation(hasMany: self, through: table, fromKey: throughFromKey, toKey: throughToKey) + HasManyThroughRelationship(hasMany: self, through: table, fromKey: throughFromKey, toKey: throughToKey) } } -public final class HasManyThroughRelation: Relation { - init(hasMany: HasManyRelation, through table: String, fromKey: String?, toKey: String?) { +public final class HasManyThroughRelationship: Relationship { + public init(hasMany: HasManyRelationship, through table: String, fromKey: String?, toKey: String?) { super.init(db: hasMany.db, from: hasMany.from, fromKey: hasMany.fromKey, toKey: hasMany.toKey) through(table, from: fromKey, to: toKey) } diff --git a/Alchemy/Database/Rune/Relations/HasOne.swift b/Alchemy/Database/Rune/Relations/HasOne.swift index a613ba4d..807736c1 100644 --- a/Alchemy/Database/Rune/Relations/HasOne.swift +++ b/Alchemy/Database/Rune/Relations/HasOne.swift @@ -1,5 +1,5 @@ extension Model { - public typealias HasOne = HasOneRelation + public typealias HasOne = HasOneRelationship public func hasOne(_ type: To.Type = To.self, on db: Database = To.M.database, @@ -9,8 +9,8 @@ extension Model { } } -public class HasOneRelation: Relation { - init(db: Database, from: From, fromKey: String?, toKey: String?) { +public class HasOneRelationship: Relationship { + public init(db: Database = To.M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { let fromKey: SQLKey = .infer(From.primaryKey).specify(fromKey) let toKey: SQLKey = db.inferReferenceKey(From.self).specify(toKey) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) diff --git a/Alchemy/Database/Rune/Relations/HasOneThrough.swift b/Alchemy/Database/Rune/Relations/HasOneThrough.swift index 3398736b..d9a430ea 100644 --- a/Alchemy/Database/Rune/Relations/HasOneThrough.swift +++ b/Alchemy/Database/Rune/Relations/HasOneThrough.swift @@ -1,15 +1,25 @@ extension Model { - public typealias HasOneThrough = HasOneThroughRelation + public typealias HasOneThrough = HasOneThroughRelationship + + public func hasOneThrough(db: Database = To.M.database, + _ through: String, + fromKey: String? = nil, + toKey: String? = nil, + throughFromKey: String? = nil, + throughToKey: String? = nil) -> HasOneThrough { + hasOne(To.self, on: db, from: fromKey, to: toKey) + .through(through, from: throughFromKey, to: throughToKey) + } } -extension HasOneRelation { +extension HasOneRelationship { public func through(_ table: String, from throughFromKey: String? = nil, to throughToKey: String? = nil) -> From.HasOneThrough { - HasOneThroughRelation(hasOne: self, through: table, fromKey: throughFromKey, toKey: throughToKey) + HasOneThroughRelationship(hasOne: self, through: table, fromKey: throughFromKey, toKey: throughToKey) } } -public final class HasOneThroughRelation: Relation { - init(hasOne: HasOneRelation, through table: String, fromKey: String?, toKey: String?) { +public final class HasOneThroughRelationship: Relationship { + public init(hasOne: HasOneRelationship, through table: String, fromKey: String?, toKey: String?) { super.init(db: hasOne.db, from: hasOne.from, fromKey: hasOne.fromKey, toKey: hasOne.toKey) through(table, from: fromKey, to: toKey) } diff --git a/Alchemy/Database/Rune/Relations/Relation.swift b/Alchemy/Database/Rune/Relations/Relationship.swift similarity index 95% rename from Alchemy/Database/Rune/Relations/Relation.swift rename to Alchemy/Database/Rune/Relations/Relationship.swift index ec6987c2..244fe9e0 100644 --- a/Alchemy/Database/Rune/Relations/Relation.swift +++ b/Alchemy/Database/Rune/Relations/Relationship.swift @@ -1,4 +1,4 @@ -public class Relation: Query, EagerLoadable { +public class Relationship: Query, EagerLoadable { struct Through { let table: String var from: SQLKey @@ -10,6 +10,8 @@ public class Relation: Query, EagerLoadable { var toKey: SQLKey var lookupKey: String var throughs: [Through] + + /// Relationships will be encoded at this key. var name: String? = nil public override var sql: SQL { diff --git a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift index 98ac423e..b0f82ae0 100644 --- a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift +++ b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift @@ -34,9 +34,13 @@ public enum RelationshipMacro: AccessorMacro, PeerMacro { throw AlchemyMacroError("@\(node.name) can only be applied to variables") } + let arguments = node.arguments.map { "\($0.trimmedDescription)" } ?? "" return [ - Declaration("var $\(declaration.name): \(node.name)<\(declaration.type).Element>") { - "\(node.name.lowercaseFirst)().named(\(declaration.name.inQuotes))" + Declaration("var $\(declaration.name): \(node.name)<\(declaration.type).M>") { + """ + \(node.name.lowercaseFirst)(\(arguments)) + .named(\(declaration.name.inQuotes)) + """ } ] .map { $0.declSyntax() } diff --git a/Example/App.swift b/Example/App.swift index 46de1aca..d647ae2c 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -14,7 +14,7 @@ struct App { @GET("/todos") func getTodos() async throws -> [Todo] { - try await Todo.all().with(\.$todos) + try await Todo.all().with(\.$hasMany.$hasMany) } @Job @@ -43,7 +43,16 @@ struct Todo { var isDone: Bool = false let tags: [String]? - @HasMany var todos: [Todo] + @HasOne var hasOne: [Todo] + @HasMany var hasMany: [Todo] + @BelongsTo var belongsTo: [Todo] + @BelongsToMany var belongsToMany: [Todo] +} + +extension Todo { + @HasOneThrough("through_table") var hasOneThrough: [Todo] + @HasManyThrough("through_table") var hasManyThrough: [Todo] + @BelongsToThrough("through_table") var belongsToThrough: [Todo] } extension App { From a3c2e426d3bb24cd28378cfef6085dd31e3e76ce Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 20 Jun 2024 08:18:42 -0700 Subject: [PATCH 38/55] cleanup --- Alchemy/Database/Rune/Relations/EagerLoadable.swift | 12 ++++++------ Alchemy/Database/Rune/Relations/HasMany.swift | 2 +- AlchemyPlugin/Sources/Macros/RelationshipMacro.swift | 2 +- Example/App.swift | 12 +++++++----- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Alchemy/Database/Rune/Relations/EagerLoadable.swift b/Alchemy/Database/Rune/Relations/EagerLoadable.swift index 39eac5cc..f7677a6d 100644 --- a/Alchemy/Database/Rune/Relations/EagerLoadable.swift +++ b/Alchemy/Database/Rune/Relations/EagerLoadable.swift @@ -76,23 +76,23 @@ extension EagerLoadable { } extension Query { - public func with(_ loader: @escaping (Result) -> E) -> Self where E.From == Result { + public func with(_ relationship: @escaping (Result) -> E) -> Self where E.From == Result { didLoad { models in guard let first = models.first else { return } - try await loader(first).load(on: models) + try await relationship(first).load(on: models) } } } extension Array where Element: Model { - public func load(_ loader: @escaping (Element) -> E) async throws where E.From == Element { + public func load(_ relationship: @escaping (Element) -> E) async throws where E.From == Element { guard let first else { return } - try await loader(first).load(on: self) + try await relationship(first).load(on: self) } - public func with(_ loader: @escaping (Element) -> E) async throws -> Self where E.From == Element { + public func with(_ relationship: @escaping (Element) -> E) async throws -> Self where E.From == Element { guard let first else { return self } - try await loader(first).load(on: self) + try await relationship(first).load(on: self) return self } } diff --git a/Alchemy/Database/Rune/Relations/HasMany.swift b/Alchemy/Database/Rune/Relations/HasMany.swift index 56406b73..75c6a059 100644 --- a/Alchemy/Database/Rune/Relations/HasMany.swift +++ b/Alchemy/Database/Rune/Relations/HasMany.swift @@ -10,7 +10,7 @@ extension Model { } public class HasManyRelationship: Relationship { - public init(db: Database = M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { + public init(db: Database = To.M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { let fromKey: SQLKey = .infer(From.primaryKey).specify(fromKey) let toKey: SQLKey = db.inferReferenceKey(From.self).specify(toKey) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) diff --git a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift index b0f82ae0..cbb00bee 100644 --- a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift +++ b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift @@ -17,7 +17,7 @@ public enum RelationshipMacro: AccessorMacro, PeerMacro { return [ """ get async throws { - try await $\(raw: declaration.name).get() + try await $\(raw: declaration.name).value() } """ ] diff --git a/Example/App.swift b/Example/App.swift index d647ae2c..a2051c1a 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -14,7 +14,9 @@ struct App { @GET("/todos") func getTodos() async throws -> [Todo] { - try await Todo.all().with(\.$hasMany.$hasMany) + try await Todo.all() + .with(\.$hasOne.$hasOne) + .with(\.$hasMany) } @Job @@ -43,16 +45,16 @@ struct Todo { var isDone: Bool = false let tags: [String]? - @HasOne var hasOne: [Todo] + @HasOne var hasOne: Todo @HasMany var hasMany: [Todo] - @BelongsTo var belongsTo: [Todo] + @BelongsTo var belongsTo: Todo @BelongsToMany var belongsToMany: [Todo] } extension Todo { - @HasOneThrough("through_table") var hasOneThrough: [Todo] + @HasOneThrough("through_table") var hasOneThrough: Todo @HasManyThrough("through_table") var hasManyThrough: [Todo] - @BelongsToThrough("through_table") var belongsToThrough: [Todo] + @BelongsToThrough("through_table") var belongsToThrough: Todo } extension App { From d399425bd683227555c324b0c42077d1989f03f8 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 20 Jun 2024 09:10:34 -0700 Subject: [PATCH 39/55] cleanup --- Alchemy/Application/Application.swift | 16 +++++++--------- Alchemy/Database/Rune/Model/Model+CRUD.swift | 7 ++----- .../Sources/Macros/RelationshipMacro.swift | 12 ------------ 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/Alchemy/Application/Application.swift b/Alchemy/Application/Application.swift index 9587af75..ff560504 100644 --- a/Alchemy/Application/Application.swift +++ b/Alchemy/Application/Application.swift @@ -1,13 +1,11 @@ -/// The core type for an Alchemy application. Implement this & it's -/// `boot` function, then add the `@main` attribute to mark it as -/// the entrypoint for your application. +/// The core type for an Alchemy application. /// -/// @main -/// final class App: Application { -/// func boot() { -/// get("/hello") { _ in -/// "Hello, world!" -/// } +/// @Application +/// struct App { +/// +/// @GET("/hello") +/// func sayHello(name: String) -> String { +/// "Hello, \(name)!" /// } /// } /// diff --git a/Alchemy/Database/Rune/Model/Model+CRUD.swift b/Alchemy/Database/Rune/Model/Model+CRUD.swift index 4247638f..ce089bb1 100644 --- a/Alchemy/Database/Rune/Model/Model+CRUD.swift +++ b/Alchemy/Database/Rune/Model/Model+CRUD.swift @@ -201,11 +201,8 @@ extension Model { query(on: db).select(columns) } - public static func with(on db: Database = database, _ loader: @escaping (Self) -> E) -> Query where E.From == Self { - query(on: db).didLoad { models in - guard let first = models.first else { return } - try await loader(first).load(on: models) - } + public static func with(on db: Database = database, _ relationship: @escaping (Self) -> E) -> Query where E.From == Self { + query(on: db).with(relationship) } } diff --git a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift index cbb00bee..2bc9d79f 100644 --- a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift +++ b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift @@ -66,15 +66,3 @@ extension VariableDeclSyntax { }.first ?? "unknown" } } - -/* - @HasMany var todos: [Todo] { - get async throws { - try await $todos.get() - } - } - - var $todos: HasMany { - hasMany(from: "id", to: "id") - } - */ From cd3f1d1cd84b36a22d38bd2abae2991d078b7389 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 20 Jun 2024 09:13:53 -0700 Subject: [PATCH 40/55] workflows --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e4978f70..e7534b4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,9 +9,9 @@ on: jobs: test-macos: - runs-on: macos-13 + runs-on: macos-14 env: - DEVELOPER_DIR: /Applications/Xcode_14.3.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer steps: - uses: actions/checkout@v3 - name: Build @@ -19,10 +19,10 @@ jobs: - name: Run tests run: swift test -v test-linux: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest strategy: matrix: - swift: [5.8] + swift: [5.10] container: swift:${{ matrix.swift }} steps: - uses: actions/checkout@v3 From 4a1b42b1fe286b9ce6de03feb34b616dcb7c7aba Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 20 Jun 2024 09:15:35 -0700 Subject: [PATCH 41/55] workflows again --- .github/workflows/test.yml | 2 +- Example/App.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7534b4f..38bc932d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - swift: [5.10] + swift: ["5.10"] container: swift:${{ matrix.swift }} steps: - uses: actions/checkout@v3 diff --git a/Example/App.swift b/Example/App.swift index a2051c1a..95868233 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,5 +1,4 @@ import Alchemy -import Collections @Application struct App { From ef0345c90fc21597c73331dbd6a4023b60904727 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 20 Jun 2024 09:30:13 -0700 Subject: [PATCH 42/55] direct link --- Package.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 5518e7a3..44ebce7a 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,6 @@ let package = Package( .library(name: "AlchemyTest", targets: ["AlchemyTest"]), ], dependencies: [ - .package(path: "../AlchemyX"), .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "1.8.1"), .package(url: "https://github.com/hummingbird-project/hummingbird-core.git", from: "1.3.1"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), @@ -34,6 +33,10 @@ let package = Package( .package(url: "https://github.com/swift-server/RediStack", branch: "1.5.1"), .package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")), .package(url: "https://github.com/vadymmarkov/Fakery", from: "5.0.0"), + + // MARK: Experimental + + .package(url: "https://github.com/joshuawright11/AlchemyX.git", branch: "main"), ], targets: [ From 9c1bbd312eb40151d5b697d7afb11ea7f55e549c Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 21 Jun 2024 08:18:56 -0700 Subject: [PATCH 43/55] fix some tests --- Alchemy/Database/Database.swift | 4 ++ .../Rune/Model/Coding/SQLRowEncoder.swift | 4 +- .../Rune/Model/Coding/SQLRowReader.swift | 2 +- .../Rune/Model/Coding/SQLRowWriter.swift | 18 +++---- Alchemy/Database/Rune/Model/ModelEnum.swift | 4 +- .../Database/Rune/Model/ModelProperty.swift | 20 ++++---- .../Database/Rune/Model/ModelStorage.swift | 8 +++- Alchemy/Database/Rune/Model/OneOrMany.swift | 4 +- Alchemy/Encryption/Encrypted.swift | 2 +- Alchemy/Filesystem/File.swift | 2 +- Alchemy/HTTP/Content/Content.swift | 10 ++-- AlchemyPlugin/Sources/Macros/ModelMacro.swift | 11 +++-- AlchemyTest/Stubs/Database/StubDatabase.swift | 2 + Tests/Auth/Fixtures/AuthModel.swift | 5 +- Tests/Auth/Fixtures/TokenModel.swift | 11 +++-- Tests/Auth/TokenAuthableTests.swift | 2 +- Tests/Database/Query/QueryTests.swift | 5 +- .../Model/Coding/SQLRowEncoderTests.swift | 10 ++-- .../Database/Rune/Model/ModelCrudTests.swift | 16 ++++--- .../Rune/Relations/EagerLoadableTests.swift | 12 +++-- .../Rune/Relations/RelationTests.swift | 41 +++++++++------- Tests/Database/SQL/SQLRowTests.swift | 5 +- Tests/Database/_Fixtures/Models.swift | 10 ++-- Tests/Encryption/EncryptionTests.swift | 48 +++---------------- Tests/Hashing/HasherTests.swift | 10 ++-- Tests/Routing/ControllerTests.swift | 6 +-- 26 files changed, 137 insertions(+), 135 deletions(-) diff --git a/Alchemy/Database/Database.swift b/Alchemy/Database/Database.swift index 1f0246f9..dc3c698c 100644 --- a/Alchemy/Database/Database.swift +++ b/Alchemy/Database/Database.swift @@ -139,6 +139,10 @@ public final class Database: Service { public struct DatabaseType: Equatable { public let name: String + public init(name: String) { + self.name = name + } + public static let sqlite = DatabaseType(name: "SQLite") public static let postgres = DatabaseType(name: "PostgreSQL") public static let mysql = DatabaseType(name: "MySQL") diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift index 8eaed314..15a8bc0b 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift @@ -18,7 +18,7 @@ final class SQLRowEncoder: Encoder { return } - try property.store(key: key.stringValue, on: writer) + try property.store(key: key.stringValue, on: &writer) } mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { @@ -40,7 +40,7 @@ final class SQLRowEncoder: Encoder { /// Used for keeping track of the database fields pulled off the /// object encoded to this encoder. - private let writer: SQLRowWriter + private var writer: SQLRowWriter /// The mapping strategy for associating `CodingKey`s on an object /// with column names in a database. diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift index 6d312309..32702781 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift @@ -3,7 +3,7 @@ public struct SQLRowReader { public let keyMapping: KeyMapping public let jsonDecoder: JSONDecoder - public init(row: SQLRow, keyMapping: KeyMapping, jsonDecoder: JSONDecoder) { + public init(row: SQLRow, keyMapping: KeyMapping = .useDefaultKeys, jsonDecoder: JSONDecoder = JSONDecoder()) { self.row = row self.keyMapping = keyMapping self.jsonDecoder = jsonDecoder diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift index 79af5214..28079690 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift @@ -1,21 +1,21 @@ -public final class SQLRowWriter { +public struct SQLRowWriter { public internal(set) var fields: SQLFields let keyMapping: KeyMapping let jsonEncoder: JSONEncoder - public init(keyMapping: KeyMapping, jsonEncoder: JSONEncoder) { + public init(keyMapping: KeyMapping = .useDefaultKeys, jsonEncoder: JSONEncoder = JSONEncoder()) { self.fields = [:] self.keyMapping = keyMapping self.jsonEncoder = jsonEncoder } - public func put(json: some Encodable, at key: String) throws { + public mutating func put(json: some Encodable, at key: String) throws { let jsonData = try jsonEncoder.encode(json) let bytes = ByteBuffer(data: jsonData) self[key] = .value(.json(bytes)) } - public func put(sql: SQLConvertible, at key: String) { + public mutating func put(sql: SQLConvertible, at key: String) { self[key] = sql } @@ -26,19 +26,19 @@ public final class SQLRowWriter { } extension SQLRowWriter { - public func put(_ value: ModelProperty, at key: String) throws { - + public mutating func put(_ value: ModelProperty, at key: String) throws { + try value.store(key: key, on: &self) } - public func put(_ value: some Encodable, at key: String) throws { + public mutating func put(_ value: some Encodable, at key: String) throws { if let value = value as? ModelProperty { - try value.store(key: key, on: self) + try put(value, at: key) } else { try put(json: value, at: key) } } - public func put(_ int: F, at key: String) { + public mutating func put(_ int: F, at key: String) { self[key] = Int(int) } } diff --git a/Alchemy/Database/Rune/Model/ModelEnum.swift b/Alchemy/Database/Rune/Model/ModelEnum.swift index 091ae6a8..65962ef6 100644 --- a/Alchemy/Database/Rune/Model/ModelEnum.swift +++ b/Alchemy/Database/Rune/Model/ModelEnum.swift @@ -25,8 +25,8 @@ extension ModelEnum where Self: RawRepresentable, RawValue: ModelProperty { self = value } - public func store(key: String, on row: SQLRowWriter) throws { - try rawValue.store(key: key, on: row) + public func store(key: String, on row: inout SQLRowWriter) throws { + try rawValue.store(key: key, on: &row) } } diff --git a/Alchemy/Database/Rune/Model/ModelProperty.swift b/Alchemy/Database/Rune/Model/ModelProperty.swift index a896b9ad..ad87df10 100644 --- a/Alchemy/Database/Rune/Model/ModelProperty.swift +++ b/Alchemy/Database/Rune/Model/ModelProperty.swift @@ -1,7 +1,7 @@ // For custom logic around loading and saving properties on a Model. public protocol ModelProperty { init(key: String, on row: SQLRowReader) throws - func store(key: String, on row: SQLRowWriter) throws + func store(key: String, on row: inout SQLRowWriter) throws } extension String: ModelProperty { @@ -9,7 +9,7 @@ extension String: ModelProperty { self = try row.require(key).string(key) } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { row.put(sql: self, at: key) } } @@ -19,7 +19,7 @@ extension Bool: ModelProperty { self = try row.require(key).bool(key) } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { row.put(sql: self, at: key) } } @@ -29,7 +29,7 @@ extension Float: ModelProperty { self = Float(try row.require(key).double(key)) } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { row.put(sql: self, at: key) } } @@ -39,7 +39,7 @@ extension Double: ModelProperty { self = try row.require(key).double(key) } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { row.put(sql: self, at: key) } } @@ -49,7 +49,7 @@ extension FixedWidthInteger { self = try .init(row.require(key).int(key)) } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { row.put(self, at: key) } } @@ -70,7 +70,7 @@ extension Date: ModelProperty { self = try row.require(key).date(key) } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { row.put(sql: self, at: key) } } @@ -80,7 +80,7 @@ extension UUID: ModelProperty { self = try row.require(key).uuid(key) } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { row.put(sql: self, at: key) } } @@ -95,7 +95,7 @@ extension Optional: ModelProperty where Wrapped: ModelProperty { self = .some(try Wrapped(key: key, on: row)) } - public func store(key: String, on row: SQLRowWriter) throws { - try self?.store(key: key, on: row) + public func store(key: String, on row: inout SQLRowWriter) throws { + try self?.store(key: key, on: &row) } } diff --git a/Alchemy/Database/Rune/Model/ModelStorage.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift index 640df9c7..772a3dc6 100644 --- a/Alchemy/Database/Rune/Model/ModelStorage.swift +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -1,4 +1,4 @@ -public final class ModelStorage: Codable { +public final class ModelStorage: Codable, Equatable { public var id: M.PrimaryKey? public var row: SQLRow? { didSet { @@ -29,6 +29,12 @@ public final class ModelStorage: Codable { // instead, use the KeyedDecodingContainer extension below. preconditionFailure("Directly decoding ModelStorage not supported!") } + + // MARK: Equatable + + public static func == (lhs: ModelStorage, rhs: ModelStorage) -> Bool { + lhs.id == rhs.id + } } extension Model { diff --git a/Alchemy/Database/Rune/Model/OneOrMany.swift b/Alchemy/Database/Rune/Model/OneOrMany.swift index 4405b0a0..e0a09fc6 100644 --- a/Alchemy/Database/Rune/Model/OneOrMany.swift +++ b/Alchemy/Database/Rune/Model/OneOrMany.swift @@ -17,6 +17,8 @@ extension Array: OneOrMany where Element: Model { } extension Optional: OneOrMany where Wrapped: Model { + public typealias M = Wrapped + public init(models: [Wrapped]) throws { self = models.first } @@ -26,7 +28,7 @@ extension Optional: OneOrMany where Wrapped: Model { } } -extension Model { +extension Model where M == Self { public init(models: [Self]) throws { guard let model = models.first else { throw RuneError("Non-optional relationship to \(Self.self) had no results!") diff --git a/Alchemy/Encryption/Encrypted.swift b/Alchemy/Encryption/Encrypted.swift index 23360206..e4d46cb3 100644 --- a/Alchemy/Encryption/Encrypted.swift +++ b/Alchemy/Encryption/Encrypted.swift @@ -13,7 +13,7 @@ public struct Encrypted: ModelProperty, Codable { wrappedValue = try Crypt.decrypt(data: data) } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { let encrypted = try Crypt.encrypt(string: wrappedValue) let string = encrypted.base64EncodedString() row.put(sql: string, at: key) diff --git a/Alchemy/Filesystem/File.swift b/Alchemy/Filesystem/File.swift index 2d6e30e3..b534ff50 100644 --- a/Alchemy/Filesystem/File.swift +++ b/Alchemy/Filesystem/File.swift @@ -103,7 +103,7 @@ public struct File: Codable, ResponseConvertible, ModelProperty { self.init(name: name, source: .filesystem(Storage, path: name)) } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { guard case .filesystem(_, let path) = source else { throw RuneError("currently, only files saved in a `Filesystem` can be stored on a `Model`") } diff --git a/Alchemy/HTTP/Content/Content.swift b/Alchemy/HTTP/Content/Content.swift index bc4ceead..0ea5c58a 100644 --- a/Alchemy/HTTP/Content/Content.swift +++ b/Alchemy/HTTP/Content/Content.swift @@ -444,20 +444,20 @@ extension Content.Value: ModelProperty { throw ContentError.notSupported("Reading content from database models isn't supported, yet.") } - public func store(key: String, on row: SQLRowWriter) throws { + public func store(key: String, on row: inout SQLRowWriter) throws { switch self { case .array(let values): try row.put(json: values, at: key) case .dictionary(let dict): try row.put(json: dict, at: key) case .bool(let value): - try value.store(key: key, on: row) + try value.store(key: key, on: &row) case .string(let value): - try value.store(key: key, on: row) + try value.store(key: key, on: &row) case .int(let value): - try value.store(key: key, on: row) + try value.store(key: key, on: &row) case .double(let double): - try double.store(key: key, on: row) + try double.store(key: key, on: &row) case .file(let file): if let buffer = file.content?.buffer { row.put(sql: SQLValue.bytes(buffer), at: key) diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index 67f3bc06..2a193db8 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -147,9 +147,12 @@ extension Resource { fileprivate func generateInitializer() -> Declaration { Declaration("init(row: SQLRow) throws") { - "let reader = SQLRowReader(row: row, keyMapping: Self.keyMapping, jsonDecoder: Self.jsonDecoder)" - for property in storedProperties where property.name != "id" { - "self.\(property.name) = try reader.require(\(property.type).self, at: \(property.name.inQuotes))" + let propertiesExceptId = storedProperties.filter { $0.name != "id" } + if !propertiesExceptId.isEmpty { + "let reader = SQLRowReader(row: row, keyMapping: Self.keyMapping, jsonDecoder: Self.jsonDecoder)" + for property in propertiesExceptId { + "self.\(property.name) = try reader.require(\(property.type).self, at: \(property.name.inQuotes))" + } } "storage.row = row" @@ -159,7 +162,7 @@ extension Resource { fileprivate func generateFields() -> Declaration { Declaration("func fields() throws -> SQLFields") { - "let writer = SQLRowWriter(keyMapping: Self.keyMapping, jsonEncoder: Self.jsonEncoder)" + "var writer = SQLRowWriter(keyMapping: Self.keyMapping, jsonEncoder: Self.jsonEncoder)" for property in storedProperties { "try writer.put(\(property.name), at: \(property.name.inQuotes))" } diff --git a/AlchemyTest/Stubs/Database/StubDatabase.swift b/AlchemyTest/Stubs/Database/StubDatabase.swift index 9c1a39ea..31d6bc22 100644 --- a/AlchemyTest/Stubs/Database/StubDatabase.swift +++ b/AlchemyTest/Stubs/Database/StubDatabase.swift @@ -3,6 +3,8 @@ import Alchemy public struct StubGrammar: SQLGrammar {} public final class StubDatabase: DatabaseProvider { + public var type: DatabaseType { .init(name: "stub") } + private var isShutdown = false private var stubs: [[SQLRow]] = [] diff --git a/Tests/Auth/Fixtures/AuthModel.swift b/Tests/Auth/Fixtures/AuthModel.swift index fb30c0a3..ae1b1755 100644 --- a/Tests/Auth/Fixtures/AuthModel.swift +++ b/Tests/Auth/Fixtures/AuthModel.swift @@ -1,7 +1,8 @@ import Alchemy -struct AuthModel: Model, Codable, BasicAuthable { - var id: PK = .new +@Model +struct AuthModel: BasicAuthable { + var id: Int let email: String let password: String diff --git a/Tests/Auth/Fixtures/TokenModel.swift b/Tests/Auth/Fixtures/TokenModel.swift index d7db4c89..c61e4197 100644 --- a/Tests/Auth/Fixtures/TokenModel.swift +++ b/Tests/Auth/Fixtures/TokenModel.swift @@ -1,14 +1,17 @@ import Alchemy -struct TokenModel: Model, Codable, TokenAuthable { +@Model +struct TokenModel: TokenAuthable { typealias Authorizes = AuthModel - var id: PK = .new - var value = UUID() + var id: Int + var value: UUID = UUID() var userId: Int + @BelongsTo var auth: AuthModel + var user: BelongsTo { - belongsTo(from: "user_id") + $auth } struct Migrate: Migration { diff --git a/Tests/Auth/TokenAuthableTests.swift b/Tests/Auth/TokenAuthableTests.swift index acc1603f..af951eaf 100644 --- a/Tests/Auth/TokenAuthableTests.swift +++ b/Tests/Auth/TokenAuthableTests.swift @@ -11,7 +11,7 @@ final class TokenAuthableTests: TestCase { } let auth = try await AuthModel(email: "test@withapollo.com", password: Hash.make("password")).insertReturn() - let token = try await TokenModel(userId: auth.id()).insertReturn() + let token = try await TokenModel(userId: auth.id).insertReturn() try await Test.get("/user") .assertUnauthorized() diff --git a/Tests/Database/Query/QueryTests.swift b/Tests/Database/Query/QueryTests.swift index 1c155594..15643722 100644 --- a/Tests/Database/Query/QueryTests.swift +++ b/Tests/Database/Query/QueryTests.swift @@ -39,8 +39,9 @@ final class QueryTests: TestCase { } } -private struct TestModel: Model, Codable, Seedable, Equatable { - var id: PK = .new +@Model +private struct TestModel: Seedable, Equatable { + var id: Int var foo: String var bar: Bool diff --git a/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift b/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift index 15335207..fa4e4c35 100644 --- a/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift +++ b/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift @@ -96,15 +96,17 @@ private struct DatabaseJSON: Codable { var val2: Date } -private struct CustomKeyedModel: Model, Codable { - var id: PK = .new +@Model +private struct CustomKeyedModel { + var id: Int var val1: String = "foo" var valueTwo: Int = 0 var valueThreeInt: Int = 1 var snake_case: String = "bar" } -private struct CustomDecoderModel: Model, Codable { +@Model +private struct CustomDecoderModel { static var jsonEncoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 @@ -112,6 +114,6 @@ private struct CustomDecoderModel: Model, Codable { return encoder }() - var id: PK = .new + var id: Int var json: DatabaseJSON } diff --git a/Tests/Database/Rune/Model/ModelCrudTests.swift b/Tests/Database/Rune/Model/ModelCrudTests.swift index da70a260..4c607133 100644 --- a/Tests/Database/Rune/Model/ModelCrudTests.swift +++ b/Tests/Database/Rune/Model/ModelCrudTests.swift @@ -22,7 +22,7 @@ final class ModelCrudTests: TestCase { let model = try await TestModel(foo: "baz", bar: false).insertReturn() - let findById = try await TestModel.find(model.id()) + let findById = try await TestModel.find(model.id) XCTAssertEqual(findById, model) do { @@ -59,7 +59,7 @@ final class ModelCrudTests: TestCase { return } - try await TestModel.delete(first.id.require()) + try await TestModel.delete(first.id) let count = try await TestModel.all().count XCTAssertEqual(count, 4) @@ -94,7 +94,7 @@ final class ModelCrudTests: TestCase { func testUpdate() async throws { var model = try await TestModel.seed() - let id = try model.id.require() + let id = model.id model.foo = "baz" AssertNotEqual(try await TestModel.find(id), model) @@ -127,13 +127,15 @@ final class ModelCrudTests: TestCase { private struct TestError: Error {} -private struct TestModelCustomId: Model, Codable { - var id: PK = .new(UUID()) +@Model +private struct TestModelCustomId { + var id: UUID var foo: String } -private struct TestModel: Model, Codable, Seedable, Equatable { - var id: PK = .new +@Model +private struct TestModel: Seedable, Equatable { + var id: Int var foo: String var bar: Bool diff --git a/Tests/Database/Rune/Relations/EagerLoadableTests.swift b/Tests/Database/Rune/Relations/EagerLoadableTests.swift index 611a97bc..dfca17f3 100644 --- a/Tests/Database/Rune/Relations/EagerLoadableTests.swift +++ b/Tests/Database/Rune/Relations/EagerLoadableTests.swift @@ -20,8 +20,9 @@ final class EagerLoadableTests: TestCase { private struct TestError: Error {} -private struct TestParent: Model, Codable, Seedable, Equatable { - var id: PK = .new +@Model +private struct TestParent: Seedable, Equatable { + var id: Int var baz: String static func generate() async throws -> TestParent { @@ -29,8 +30,9 @@ private struct TestParent: Model, Codable, Seedable, Equatable { } } -private struct TestModel: Model, Codable, Seedable, Equatable { - var id: PK = .new +@Model +private struct TestModel: Seedable, Equatable { + var id: Int var foo: String var bar: Bool var testParentId: Int @@ -47,7 +49,7 @@ private struct TestModel: Model, Codable, Seedable, Equatable { parent = try await .seed() } - return TestModel(foo: faker.lorem.word(), bar: faker.number.randomBool(), testParentId: try parent.id.require()) + return TestModel(foo: faker.lorem.word(), bar: faker.number.randomBool(), testParentId: parent.id) } static func == (lhs: TestModel, rhs: TestModel) -> Bool { diff --git a/Tests/Database/Rune/Relations/RelationTests.swift b/Tests/Database/Rune/Relations/RelationTests.swift index 5cc47dcd..2e444e05 100644 --- a/Tests/Database/Rune/Relations/RelationTests.swift +++ b/Tests/Database/Rune/Relations/RelationTests.swift @@ -126,27 +126,30 @@ final class RelationTests: TestCase { } } -private struct Organization: Model, Codable { - var id: PK = .new +@Model +private struct Organization { + var id: Int var users: BelongsToMany { - belongsToMany(pivot: UserOrganization.table) + belongsToMany(UserOrganization.table) } var usersOver30: BelongsToMany { - belongsToMany(pivot: UserOrganization.table) + belongsToMany(UserOrganization.table) .where("age" >= 30) } } -private struct UserOrganization: Model, Codable { - var id: PK = .new +@Model +private struct UserOrganization { + var id: Int var userId: Int var organizationId: Int } -private struct User: Model, Codable { - var id: PK = .new +@Model +private struct User { + var id: Int let name: String let age: Int var managerId: Int? @@ -166,12 +169,13 @@ private struct User: Model, Codable { } var organizations: BelongsToMany { - belongsToMany(pivot: UserOrganization.table) + belongsToMany(UserOrganization.table) } } -private struct Repository: Model, Codable { - var id: PK = .new +@Model +private struct Repository { + var id: Int var userId: Int var user: BelongsTo { @@ -183,8 +187,9 @@ private struct Repository: Model, Codable { } } -private struct Workflow: Model, Codable { - var id: PK = .new +@Model +private struct Workflow { + var id: Int var repositoryId: Int var repository: BelongsTo { @@ -196,8 +201,9 @@ private struct Workflow: Model, Codable { } } -private struct Job: Model, Codable { - var id: PK = .new +@Model +private struct Job { + var id: Int var workflowId: Int var workflow: BelongsTo { @@ -211,8 +217,9 @@ private struct Job: Model, Codable { } } -private struct TestModel: Model, Codable { - var id: PK = .new +@Model +private struct TestModel { + var id: Int } private struct WorkflowMigration: Migration { diff --git a/Tests/Database/SQL/SQLRowTests.swift b/Tests/Database/SQL/SQLRowTests.swift index 91cfd56d..e5a33350 100644 --- a/Tests/Database/SQL/SQLRowTests.swift +++ b/Tests/Database/SQL/SQLRowTests.swift @@ -63,7 +63,8 @@ final class SQLRowTests: TestCase { } } -struct EverythingModel: Model, Codable, Equatable { +@Model +struct EverythingModel: Equatable { struct Nested: Codable, Equatable { let string: String let int: Int @@ -72,7 +73,7 @@ struct EverythingModel: Model, Codable, Equatable { enum IntEnum: Int, Codable, ModelEnum { case two = 2 } enum DoubleEnum: Double, Codable, ModelEnum { case three = 3.0 } - var id: PK = .existing(1) + var id: Int // Enum var stringEnum: StringEnum = .one diff --git a/Tests/Database/_Fixtures/Models.swift b/Tests/Database/_Fixtures/Models.swift index e9b3a5c2..ac651abb 100644 --- a/Tests/Database/_Fixtures/Models.swift +++ b/Tests/Database/_Fixtures/Models.swift @@ -1,6 +1,7 @@ import Alchemy -struct SeedModel: Model, Codable, Seedable { +@Model +struct SeedModel: Seedable { struct Migrate: Migration { func up(db: Database) async throws { try await db.createTable("seed_models") { @@ -15,7 +16,7 @@ struct SeedModel: Model, Codable, Seedable { } } - var id: PK = .new + var id: Int let name: String let email: String @@ -24,7 +25,8 @@ struct SeedModel: Model, Codable, Seedable { } } -struct OtherSeedModel: Model, Codable, Seedable { +@Model +struct OtherSeedModel: Seedable { struct Migrate: Migration { func up(db: Database) async throws { try await db.createTable("other_seed_models") { @@ -39,7 +41,7 @@ struct OtherSeedModel: Model, Codable, Seedable { } } - var id: PK = .new(UUID()) + var id: UUID = UUID() let foo: Int let bar: Bool diff --git a/Tests/Encryption/EncryptionTests.swift b/Tests/Encryption/EncryptionTests.swift index 12f61559..32d508cc 100644 --- a/Tests/Encryption/EncryptionTests.swift +++ b/Tests/Encryption/EncryptionTests.swift @@ -33,11 +33,11 @@ final class EncryptionTests: XCTestCase { let string = "FOO" let encryptedValue = try Crypt.encrypt(string: string).base64EncodedString() - let reader: FakeReader = ["foo": encryptedValue] + let reader: SQLRowReader = ["foo": encryptedValue] let encrypted = try Encrypted(key: "foo", on: reader) XCTAssertEqual(encrypted.wrappedValue, "FOO") - let fakeWriter = FakeWriter() + let fakeWriter = SQLRowWriter() var writer: SQLRowWriter = fakeWriter try encrypted.store(key: "foo", on: &writer) guard let storedValue = fakeWriter.fields["foo"] as? String else { @@ -49,49 +49,13 @@ final class EncryptionTests: XCTestCase { } func testEncryptedNotBase64Throws() { - let reader: FakeReader = ["foo": "bar"] + let reader: SQLRowReader = ["foo": "bar"] XCTAssertThrowsError(try Encrypted(key: "foo", on: reader)) } } -private final class FakeWriter: SQLRowWriter { - var fields: SQLFields = [:] - - subscript(column: String) -> SQLConvertible? { - get { fields[column] } - set { fields[column] = newValue } - } - - func put(json: E, at key: String) throws { - let jsonData = try JSONEncoder().encode(json) - self[key] = .value(.json(ByteBuffer(data: jsonData))) - } -} - -private struct FakeReader: SQLRowReader, ExpressibleByDictionaryLiteral { - var row: SQLRow - - init(dictionaryLiteral: (String, SQLValueConvertible)...) { - self.row = SQLRow(fields: dictionaryLiteral) - } - - func requireJSON(_ key: String) throws -> D { - return try JSONDecoder().decode(D.self, from: row.require(key).json(key)) - } - - func require(_ key: String) throws -> SQLValue { - try row.require(key) - } - - func contains(_ column: String) -> Bool { - row[column] != nil - } - - subscript(_ index: Int) -> SQLValue { - row[index] - } - - subscript(_ column: String) -> SQLValue? { - row[column] +extension SQLRowReader: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral: (String, SQLValueConvertible)...) { + self.init(row: SQLRow(fields: dictionaryLiteral)) } } diff --git a/Tests/Hashing/HasherTests.swift b/Tests/Hashing/HasherTests.swift index fb7ff676..9f0f175c 100644 --- a/Tests/Hashing/HasherTests.swift +++ b/Tests/Hashing/HasherTests.swift @@ -2,18 +2,18 @@ import AlchemyTest final class HasherTests: TestCase { func testBcrypt() async throws { - let hashed = try await Hash.makeAsync("foo") - let verify = try await Hash.verifyAsync("foo", hash: hashed) + let hashed = try await Hash.make("foo") + let verify = try await Hash.verify("foo", hash: hashed) XCTAssertTrue(verify) } func testBcryptCostTooLow() { - XCTAssertThrowsError(try Hash(.bcrypt(rounds: 1)).make("foo")) + XCTAssertThrowsError(try Hash(.bcrypt(rounds: 1)).makeSync("foo")) } func testSHA256() throws { - let hashed = try Hash(.sha256).make("foo") - let verify = try Hash(.sha256).verify("foo", hash: hashed) + let hashed = try Hash(.sha256).makeSync("foo") + let verify = try Hash(.sha256).verifySync("foo", hash: hashed) XCTAssertTrue(verify) } } diff --git a/Tests/Routing/ControllerTests.swift b/Tests/Routing/ControllerTests.swift index f185b0b4..84b056b9 100644 --- a/Tests/Routing/ControllerTests.swift +++ b/Tests/Routing/ControllerTests.swift @@ -3,7 +3,7 @@ import AlchemyTest final class ControllerTests: TestCase { func testController() async throws { try await Test.get("/test").assertNotFound() - app.controller(TestController()) + app.use(TestController()) try await Test.get("/test").assertOk() } @@ -14,7 +14,7 @@ final class ControllerTests: TestCase { ActionMiddleware { expect.signalTwo() }, ActionMiddleware { expect.signalThree() } ]) - app.controller(controller) + app.use(controller) try await Test.get("/middleware").assertOk() AssertTrue(expect.one) @@ -31,7 +31,7 @@ final class ControllerTests: TestCase { ]) app - .controller(controller) + .use(controller) .get("/outside") { _ async -> String in expect.signalFour() return "foo" From 54800caacd8a30e27d27704723c366cc603e1da6 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 22 Jun 2024 09:22:44 -0700 Subject: [PATCH 44/55] tests pass --- Alchemy/Auth/TokenAuthable.swift | 2 - .../Rune/Model/Coding/SQLRowEncoder.swift | 13 ++- Alchemy/Database/Rune/Model/Model+CRUD.swift | 12 +-- Alchemy/Database/Rune/Model/Model.swift | 19 ++++- .../Database/Rune/Model/ModelStorage.swift | 50 +++++++----- .../Providers/LocalFilesystem.swift | 2 +- Alchemy/Logging/Logger+Utilities.swift | 10 +-- Alchemy/Queue/Errors/JobError.swift | 21 ++--- Alchemy/Queue/Job.swift | 2 +- Alchemy/Queue/JobRegistry.swift | 2 +- Alchemy/Queue/Queue.swift | 3 +- AlchemyPlugin/Sources/Macros/ModelMacro.swift | 81 ++++++++++--------- Tests/Auth/Fixtures/TokenModel.swift | 2 +- Tests/Auth/TokenAuthableTests.swift | 4 +- .../Model/Coding/SQLRowEncoderTests.swift | 6 +- .../Database/Rune/Model/ModelCrudTests.swift | 5 +- .../Rune/Relations/RelationTests.swift | 20 ++--- Tests/Database/SQL/SQLRowTests.swift | 3 +- Tests/Database/_Fixtures/Models.swift | 4 +- Tests/Encryption/EncryptionTests.swift | 5 +- Tests/Filesystem/FilesystemTests.swift | 2 +- Tests/Queues/JobRegistryTests.swift | 9 ++- 22 files changed, 152 insertions(+), 125 deletions(-) diff --git a/Alchemy/Auth/TokenAuthable.swift b/Alchemy/Auth/TokenAuthable.swift index a578780a..b0497db6 100644 --- a/Alchemy/Auth/TokenAuthable.swift +++ b/Alchemy/Auth/TokenAuthable.swift @@ -1,5 +1,3 @@ -import Crypto - /// A protocol for automatically authenticating incoming requests /// based on their `Authentication: Bearer ...` header. When the /// request is intercepted by a related `TokenAuthMiddleware`, it diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift index 15a8bc0b..8abc6bca 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowEncoder.swift @@ -1,24 +1,21 @@ final class SQLRowEncoder: Encoder { /// Used to decode keyed values from a Model. private struct _KeyedEncodingContainer: KeyedEncodingContainerProtocol { - var writer: SQLRowWriter - - // MARK: KeyedEncodingContainerProtocol - + var encoder: SQLRowEncoder var codingPath = [CodingKey]() mutating func encodeNil(forKey key: Key) throws { - writer.put(sql: .null, at: key.stringValue) + encoder.writer.put(sql: .null, at: key.stringValue) } mutating func encode(_ value: T, forKey key: Key) throws { guard let property = value as? ModelProperty else { // Assume anything else is JSON. - try writer.put(json: value, at: key.stringValue) + try encoder.writer.put(json: value, at: key.stringValue) return } - try property.store(key: key.stringValue, on: &writer) + try property.store(key: key.stringValue, on: &encoder.writer) } mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { @@ -70,7 +67,7 @@ final class SQLRowEncoder: Encoder { // MARK: Encoder func container(keyedBy: Key.Type) -> KeyedEncodingContainer { - KeyedEncodingContainer(_KeyedEncodingContainer(writer: writer, codingPath: codingPath)) + KeyedEncodingContainer(_KeyedEncodingContainer(encoder: self, codingPath: codingPath)) } func unkeyedContainer() -> UnkeyedEncodingContainer { diff --git a/Alchemy/Database/Rune/Model/Model+CRUD.swift b/Alchemy/Database/Rune/Model/Model+CRUD.swift index ce089bb1..da75e381 100644 --- a/Alchemy/Database/Rune/Model/Model+CRUD.swift +++ b/Alchemy/Database/Rune/Model/Model+CRUD.swift @@ -113,7 +113,7 @@ extension Model { @discardableResult public func update(on db: Database = database, _ fields: [String: Any]) async throws -> Self { - let values = fields.compactMapValues { $0 as? SQLConvertible } + let values = SQLFields(fields.compactMapValues { $0 as? SQLConvertible }, uniquingKeysWith: { a, _ in a }) return try await update(on: db, values) } @@ -125,7 +125,9 @@ extension Model { @discardableResult public func update(on db: Database = database, _ encodable: E, keyMapping: KeyMapping = .snakeCase, jsonEncoder: JSONEncoder = JSONEncoder()) async throws -> Self { - try await update(encodable.sqlFields(keyMapping: keyMapping, jsonEncoder: jsonEncoder)) + let fields = try encodable.sqlFields(keyMapping: keyMapping, jsonEncoder: jsonEncoder) + print("fields \(fields)") + return try await update(encodable.sqlFields(keyMapping: keyMapping, jsonEncoder: jsonEncoder)) } // MARK: UPSERT @@ -184,6 +186,7 @@ extension Model { /// Fetches an copy of this model from a database, with any updates that may /// have been made since it was last fetched. public func refresh(on db: Database = database) async throws -> Self { + let id = try requireId() let model = try await Self.require(id, db: db) row = model.row model.mergeCache(self) @@ -237,9 +240,8 @@ extension Array where Element: Model { // MARK: UPDATE - @discardableResult - public func updateAll(on db: Database = Element.database, _ fields: [String: Any]) async throws -> Self { - let values = fields.compactMapValues { $0 as? SQLConvertible } + public func updateAll(on db: Database = Element.database, _ fields: [String: Any]) async throws { + let values = SQLFields(fields.compactMapValues { $0 as? SQLConvertible }, uniquingKeysWith: { a, _ in a }) return try await updateAll(on: db, values) } diff --git a/Alchemy/Database/Rune/Model/Model.swift b/Alchemy/Database/Rune/Model/Model.swift index 06979002..b286b069 100644 --- a/Alchemy/Database/Rune/Model/Model.swift +++ b/Alchemy/Database/Rune/Model/Model.swift @@ -10,7 +10,7 @@ public protocol Model: Identifiable, QueryResult, ModelOrOptional { associatedtype PrimaryKey: PrimaryKeyProtocol /// The identifier of this model - var id: PrimaryKey { get set } + var id: PrimaryKey { get nonmutating set } /// Storage for model metadata (relationships, original row, etc). var storage: Storage { get } @@ -71,6 +71,23 @@ extension Model { public static func on(_ database: Database) -> Query { query(on: database) } + + public func id(_ id: PrimaryKey) -> Self { + self.id = id + return self + } + + public func requireId() throws -> PrimaryKey { + guard let id = storage.id else { + throw RuneError("Model had no id!") + } + + return id + } + + public func maybeId() -> PrimaryKey? { + storage.id + } } extension Model where Self: Codable { diff --git a/Alchemy/Database/Rune/Model/ModelStorage.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift index 772a3dc6..378016f0 100644 --- a/Alchemy/Database/Rune/Model/ModelStorage.swift +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -16,8 +16,30 @@ public final class ModelStorage: Codable, Equatable { self.relationships = [:] } + // MARK: SQL + + public func write(to writer: inout SQLRowWriter) throws { + if let id { + try writer.put(id, at: M.primaryKey) + } + } + + public func read(from reader: SQLRowReader) throws { + id = try reader.require(M.PrimaryKey.self, at: M.primaryKey) + row = reader.row + } + + // MARK: Codable + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: GenericCodingKey.self) + + // 0. encode id + if let id { + try container.encode(id, forKey: .key(M.primaryKey)) + } + + // 1. encode encodable relationships for (key, relationship) in relationships { if let relationship = relationship as? Encodable, let name = key.name { try container.encode(AnyEncodable(relationship), forKey: .key(name)) @@ -26,8 +48,14 @@ public final class ModelStorage: Codable, Equatable { } public init(from decoder: Decoder) throws { - // instead, use the KeyedDecodingContainer extension below. - preconditionFailure("Directly decoding ModelStorage not supported!") + let container = try decoder.container(keyedBy: GenericCodingKey.self) + let key: GenericCodingKey = .key(M.primaryKey) + if container.contains(key) { + self.id = try container.decode(M.PrimaryKey.self, forKey: key) + } + + self.row = nil + self.relationships = [:] } // MARK: Equatable @@ -41,24 +69,6 @@ extension Model { public typealias Storage = ModelStorage } -extension KeyedDecodingContainer { - public func decode( - _ type: ModelStorage, - forKey key: Self.Key - ) throws -> ModelStorage { - M.Storage() - } -} - -extension KeyedEncodingContainer { - public mutating func encode( - _ value: ModelStorage, - forKey key: KeyedEncodingContainer.Key - ) throws { - // ignore - } -} - extension Model { public internal(set) var row: SQLRow? { get { storage.row } diff --git a/Alchemy/Filesystem/Providers/LocalFilesystem.swift b/Alchemy/Filesystem/Providers/LocalFilesystem.swift index 12465b8f..8d1a3c15 100644 --- a/Alchemy/Filesystem/Providers/LocalFilesystem.swift +++ b/Alchemy/Filesystem/Providers/LocalFilesystem.swift @@ -98,7 +98,7 @@ private struct LocalFilesystem: FilesystemProvider { guard let rootUrl = URL(string: root) else { throw FileError.invalidFileUrl } - + let url = rootUrl.appendingPathComponent(filepath.trimmingForwardSlash) // Ensure directory exists. diff --git a/Alchemy/Logging/Logger+Utilities.swift b/Alchemy/Logging/Logger+Utilities.swift index 6935fdf9..afa9e87c 100644 --- a/Alchemy/Logging/Logger+Utilities.swift +++ b/Alchemy/Logging/Logger+Utilities.swift @@ -112,12 +112,12 @@ extension Logger: Service { } static let `default`: Logger = { - if Environment.isRunFromTests { - return .null - } else if Environment.isXcode { - return .xcode + let isTests = Environment.isRunFromTests + let defaultLevel: Logger.Level = isTests ? .error : .debug + if Environment.isXcode { + return .xcode.withLevel(defaultLevel) } else { - return .debug + return .debug.withLevel(defaultLevel) } }() } diff --git a/Alchemy/Queue/Errors/JobError.swift b/Alchemy/Queue/Errors/JobError.swift index a8a2f20c..81664741 100644 --- a/Alchemy/Queue/Errors/JobError.swift +++ b/Alchemy/Queue/Errors/JobError.swift @@ -1,21 +1,10 @@ /// An error encountered when interacting with a `Job`. -public struct JobError: Error, Equatable { - private enum ErrorType: Equatable { - case unknownJobType - case general(String) - } - - private let type: ErrorType - - private init(type: ErrorType) { - self.type = type - } - +public enum JobError: Error, Equatable { + case unknownJob(String) + case misc(String) + /// Initialize with a message. init(_ message: String) { - self.init(type: .general(message)) + self = .misc(message) } - - /// Unable to decode a job; it wasn't registered to the app. - static let unknownType = JobError(type: .unknownJobType) } diff --git a/Alchemy/Queue/Job.swift b/Alchemy/Queue/Job.swift index 272c6dc9..5d5a6bfc 100644 --- a/Alchemy/Queue/Job.swift +++ b/Alchemy/Queue/Job.swift @@ -87,7 +87,7 @@ public struct JobContext { // Default implementations. extension Job { - public static var name: String { String(reflecting: Self.self) } + public static var name: String { "\(Self.self)" } public var recoveryStrategy: RecoveryStrategy { .none } public var retryBackoff: TimeAmount { .zero } diff --git a/Alchemy/Queue/JobRegistry.swift b/Alchemy/Queue/JobRegistry.swift index 4b8e24d5..87f688d0 100644 --- a/Alchemy/Queue/JobRegistry.swift +++ b/Alchemy/Queue/JobRegistry.swift @@ -24,7 +24,7 @@ final class JobRegistry { func createJob(from jobData: JobData) async throws -> Job { guard let creator = lock.withLock({ creators[jobData.jobName] }) else { Log.warning("Unknown job of type '\(jobData.jobName)'. Please register it in your Queues config or with `app.registerJob(\(jobData.jobName).self)`.") - throw JobError.unknownType + throw JobError.unknownJob(jobData.jobName) } do { diff --git a/Alchemy/Queue/Queue.swift b/Alchemy/Queue/Queue.swift index 5e509f6f..2c3b42ba 100644 --- a/Alchemy/Queue/Queue.swift +++ b/Alchemy/Queue/Queue.swift @@ -126,7 +126,8 @@ public final class Queue: Service { } catch where jobData.canRetry { try await retry() job?.failed(error: error) - } catch where (error as? JobError) == JobError.unknownType { + } catch JobError.unknownJob(let name) { + let error = JobError.unknownJob(name) // So that an old worker won't fail new, unrecognized jobs. try await retry(ignoreAttempt: true) job?.failed(error: error) diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index 2a193db8..712b5584 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -92,6 +92,10 @@ struct Resource { var storedProperties: [Property] { properties.filter(\.isStored) } + + var storedPropertiesExceptId: [Property] { + storedProperties.filter { $0.name != "id" } + } } extension Resource { @@ -141,21 +145,21 @@ extension Resource.Property { } extension Resource { + + // MARK: Model + fileprivate func generateStorage() -> Declaration { Declaration("var storage = Storage()") } fileprivate func generateInitializer() -> Declaration { Declaration("init(row: SQLRow) throws") { - let propertiesExceptId = storedProperties.filter { $0.name != "id" } - if !propertiesExceptId.isEmpty { - "let reader = SQLRowReader(row: row, keyMapping: Self.keyMapping, jsonDecoder: Self.jsonDecoder)" - for property in propertiesExceptId { - "self.\(property.name) = try reader.require(\(property.type).self, at: \(property.name.inQuotes))" - } + "let reader = SQLRowReader(row: row, keyMapping: Self.keyMapping, jsonDecoder: Self.jsonDecoder)" + for property in storedPropertiesExceptId { + "self.\(property.name) = try reader.require(\(property.type).self, at: \(property.name.inQuotes))" } - "storage.row = row" + "try storage.read(from: reader)" } .access(accessLevel == "public" ? "public" : nil) } @@ -163,46 +167,17 @@ extension Resource { fileprivate func generateFields() -> Declaration { Declaration("func fields() throws -> SQLFields") { "var writer = SQLRowWriter(keyMapping: Self.keyMapping, jsonEncoder: Self.jsonEncoder)" - for property in storedProperties { + for property in storedPropertiesExceptId { "try writer.put(\(property.name), at: \(property.name.inQuotes))" } """ + try storage.write(to: &writer) return writer.fields """ } .access(accessLevel == "public" ? "public" : nil) } - fileprivate func generateEncode() -> Declaration { - Declaration("func encode(to encoder: Encoder) throws") { - "var container = encoder.container(keyedBy: GenericCodingKey.self)" - for property in storedProperties { - "try container.encode(\(property.name), forKey: \(property.name.inQuotes))" - } - - "try storage.encode(to: encoder)" - } - .access(accessLevel == "public" ? "public" : nil) - } - - fileprivate func generateDecode() -> Declaration { - Declaration("init(from decoder: Decoder) throws") { - "let container = try decoder.container(keyedBy: GenericCodingKey.self)" - for property in storedProperties where property.name != "id" { - "self.\(property.name) = try container.decode(\(property.type).self, forKey: \(property.name.inQuotes))" - } - - if let idType = storedProperties.first(where: { $0.name == "id" })?.type { - """ - if container.contains("id") { - self.id = try container.decode(\(idType).self, forKey: "id") - } - """ - } - } - .access(accessLevel == "public" ? "public" : nil) - } - fileprivate func generateFieldLookup() -> Declaration { let fieldsString = storedProperties .map { property in @@ -219,6 +194,36 @@ extension Resource { ] """) } + + // MARK: Codable + + fileprivate func generateEncode() -> Declaration { + Declaration("func encode(to encoder: Encoder) throws") { + if !storedPropertiesExceptId.isEmpty { + "var container = encoder.container(keyedBy: GenericCodingKey.self)" + for property in storedPropertiesExceptId { + "try container.encode(\(property.name), forKey: \(property.name.inQuotes))" + } + } + + "try storage.encode(to: encoder)" + } + .access(accessLevel == "public" ? "public" : nil) + } + + fileprivate func generateDecode() -> Declaration { + Declaration("init(from decoder: Decoder) throws") { + if !storedPropertiesExceptId.isEmpty { + "let container = try decoder.container(keyedBy: GenericCodingKey.self)" + for property in storedPropertiesExceptId { + "self.\(property.name) = try container.decode(\(property.type).self, forKey: \(property.name.inQuotes))" + } + } + + "self.storage = try Storage(from: decoder)" + } + .access(accessLevel == "public" ? "public" : nil) + } } extension DeclGroupSyntax { diff --git a/Tests/Auth/Fixtures/TokenModel.swift b/Tests/Auth/Fixtures/TokenModel.swift index c61e4197..4ce18b95 100644 --- a/Tests/Auth/Fixtures/TokenModel.swift +++ b/Tests/Auth/Fixtures/TokenModel.swift @@ -8,7 +8,7 @@ struct TokenModel: TokenAuthable { var value: UUID = UUID() var userId: Int - @BelongsTo var auth: AuthModel + @BelongsTo(from: "user_id") var auth: AuthModel var user: BelongsTo { $auth diff --git a/Tests/Auth/TokenAuthableTests.swift b/Tests/Auth/TokenAuthableTests.swift index af951eaf..b46391cb 100644 --- a/Tests/Auth/TokenAuthableTests.swift +++ b/Tests/Auth/TokenAuthableTests.swift @@ -3,7 +3,7 @@ import AlchemyTest final class TokenAuthableTests: TestCase { func testTokenAuthable() async throws { try await Database.fake(migrations: [AuthModel.Migrate(), TokenModel.Migrate()]) - + app.use(TokenModel.tokenAuthMiddleware()) app.get("/user") { req -> UUID in _ = try req.get(AuthModel.self) @@ -15,7 +15,7 @@ final class TokenAuthableTests: TestCase { try await Test.get("/user") .assertUnauthorized() - + try await Test.withToken(token.value.uuidString) .get("/user") .assertOk() diff --git a/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift b/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift index fa4e4c35..24dc7c51 100644 --- a/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift +++ b/Tests/Database/Rune/Model/Coding/SQLRowEncoderTests.swift @@ -34,10 +34,9 @@ final class SQLRowEncoderTests: TestCase { date: date, uuid: uuid ) - + let jsonData = try EverythingModel.jsonEncoder.encode(json) let expectedFields: SQLFields = [ - "id": 1, "string_enum": "one", "int_enum": 2, "double_enum": 3.0, @@ -67,7 +66,8 @@ final class SQLRowEncoderTests: TestCase { func testKeyMapping() async throws { try await Database.fake(keyMapping: .useDefaultKeys) - let model = CustomKeyedModel(id: 0) + let model = CustomKeyedModel() + model.id = 0 let fields = try model.fields() XCTAssertEqual("CustomKeyedModels", CustomKeyedModel.table) XCTAssertEqual([ diff --git a/Tests/Database/Rune/Model/ModelCrudTests.swift b/Tests/Database/Rune/Model/ModelCrudTests.swift index 4c607133..36865c7b 100644 --- a/Tests/Database/Rune/Model/ModelCrudTests.swift +++ b/Tests/Database/Rune/Model/ModelCrudTests.swift @@ -88,7 +88,7 @@ final class ModelCrudTests: TestCase { XCTAssertEqual(model.foo, "bar") XCTAssertEqual(model.bar, false) - let customId = try await TestModelCustomId(foo: "bar").insertReturn() + let customId = try await TestModelCustomId(foo: "bar").id(UUID()).insertReturn() XCTAssertEqual(customId.foo, "bar") } @@ -112,7 +112,8 @@ final class ModelCrudTests: TestCase { AssertEqual(try await model.refresh().foo, "bar") do { - let unsavedModel = TestModel(id: 12345, foo: "one", bar: false) + let unsavedModel = TestModel(foo: "one", bar: false) + unsavedModel.id = 12345 _ = try await unsavedModel.refresh() XCTFail("Syncing an unsaved model should throw") } catch {} diff --git a/Tests/Database/Rune/Relations/RelationTests.swift b/Tests/Database/Rune/Relations/RelationTests.swift index 2e444e05..ab60777a 100644 --- a/Tests/Database/Rune/Relations/RelationTests.swift +++ b/Tests/Database/Rune/Relations/RelationTests.swift @@ -29,20 +29,20 @@ final class RelationTests: TestCase { =============================== */ - organization = try await Organization(id: 1).insertReturn() - try await Organization(id: 2).insert() - user = try await User(id: 3, name: "Josh", age: 29, managerId: nil).insertReturn() - try await User(id: 4, name: "Bill", age: 35, managerId: 3).insert() + organization = try await Organization().id(1).insertReturn() + try await Organization().id(2).insert() + user = try await User(name: "Josh", age: 29, managerId: nil).id(3).insertReturn() + try await User(name: "Bill", age: 35, managerId: 3).id(4).insert() try await UserOrganization(userId: 3, organizationId: 1).insert() try await UserOrganization(userId: 3, organizationId: 2).insert() try await UserOrganization(userId: 4, organizationId: 1).insert() try await UserOrganization(userId: 4, organizationId: 2).insert() - repository = try await Repository(id: 5, userId: 3).insertReturn() - try await Repository(id: 6, userId: 3).insert() - workflow = try await Workflow(id: 7, repositoryId: 5).insertReturn() - try await Workflow(id: 8, repositoryId: 5).insert() - job = try await Job(id: 9, workflowId: 7).insertReturn() - try await Job(id: 10, workflowId: 7).insert() + repository = try await Repository(userId: 3).id(5).insertReturn() + try await Repository(userId: 3).id(6).insert() + workflow = try await Workflow(repositoryId: 5).id(7).insertReturn() + try await Workflow(repositoryId: 5).id(8).insert() + job = try await Job(workflowId: 7).id(9).insertReturn() + try await Job(workflowId: 7).id(10).insert() } // MARK: - Basics diff --git a/Tests/Database/SQL/SQLRowTests.swift b/Tests/Database/SQL/SQLRowTests.swift index e5a33350..c6cab065 100644 --- a/Tests/Database/SQL/SQLRowTests.swift +++ b/Tests/Database/SQL/SQLRowTests.swift @@ -53,7 +53,7 @@ final class SQLRowTests: TestCase { "belongs_to_id": 1 ] - XCTAssertEqual(try row.decodeModel(EverythingModel.self), EverythingModel(date: date, uuid: uuid)) + XCTAssertEqual(try row.decodeModel(EverythingModel.self), EverythingModel(date: date, uuid: uuid).id(1)) } func testSubscript() { @@ -69,6 +69,7 @@ struct EverythingModel: Equatable { let string: String let int: Int } + enum StringEnum: String, Codable, ModelEnum { case one } enum IntEnum: Int, Codable, ModelEnum { case two = 2 } enum DoubleEnum: Double, Codable, ModelEnum { case three = 3.0 } diff --git a/Tests/Database/_Fixtures/Models.swift b/Tests/Database/_Fixtures/Models.swift index ac651abb..666526fa 100644 --- a/Tests/Database/_Fixtures/Models.swift +++ b/Tests/Database/_Fixtures/Models.swift @@ -30,7 +30,7 @@ struct OtherSeedModel: Seedable { struct Migrate: Migration { func up(db: Database) async throws { try await db.createTable("other_seed_models") { - $0.uuid("id").primary() + $0.increments("id").primary() $0.int("foo").notNull() $0.bool("bar").notNull() } @@ -41,7 +41,7 @@ struct OtherSeedModel: Seedable { } } - var id: UUID = UUID() + var id: Int let foo: Int let bar: Bool diff --git a/Tests/Encryption/EncryptionTests.swift b/Tests/Encryption/EncryptionTests.swift index 32d508cc..24e8e464 100644 --- a/Tests/Encryption/EncryptionTests.swift +++ b/Tests/Encryption/EncryptionTests.swift @@ -37,10 +37,9 @@ final class EncryptionTests: XCTestCase { let encrypted = try Encrypted(key: "foo", on: reader) XCTAssertEqual(encrypted.wrappedValue, "FOO") - let fakeWriter = SQLRowWriter() - var writer: SQLRowWriter = fakeWriter + var writer = SQLRowWriter() try encrypted.store(key: "foo", on: &writer) - guard let storedValue = fakeWriter.fields["foo"] as? String else { + guard let storedValue = writer.fields["foo"] as? String else { return XCTFail("a String wasn't stored") } diff --git a/Tests/Filesystem/FilesystemTests.swift b/Tests/Filesystem/FilesystemTests.swift index e8f44f17..b048e0dd 100644 --- a/Tests/Filesystem/FilesystemTests.swift +++ b/Tests/Filesystem/FilesystemTests.swift @@ -81,7 +81,7 @@ final class FilesystemTests: TestCase { func _testInvalidURL() async throws { do { - let store: Filesystem = .local(root: "\\") + let store: Filesystem = .local(root: "+https://www.apple.com") _ = try await store.exists("foo") XCTFail("Should throw an error") } catch {} diff --git a/Tests/Queues/JobRegistryTests.swift b/Tests/Queues/JobRegistryTests.swift index e746662f..2f55c0e1 100644 --- a/Tests/Queues/JobRegistryTests.swift +++ b/Tests/Queues/JobRegistryTests.swift @@ -4,7 +4,14 @@ import AlchemyTest final class JobRegistryTests: TestCase { func testRegisterJob() async throws { - let data = JobData(payload: "{}".data(using: .utf8)!, jobName: "TestJob", channel: "", attempts: 0, recoveryStrategy: .none, backoff: .seconds(0)) + let data = JobData( + payload: "{}".data(using: .utf8)!, + jobName: TestJob.name, + channel: "", + attempts: 0, + recoveryStrategy: .none, + backoff: .seconds(0) + ) app.registerJob(TestJob.self) do { _ = try await Jobs.createJob(from: data) From 8c645513624d5fab2a96894e9929a296c86e3c4a Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 22 Jun 2024 09:44:07 -0700 Subject: [PATCH 45/55] no main --- AlchemyPlugin/Sources/Macros/ApplicationMacro.swift | 2 +- Example/App.swift | 1 + Package.swift | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift index 5b5582e5..9e7b516f 100644 --- a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -18,7 +18,7 @@ struct ApplicationMacro: ExtensionMacro { let routes = try Routes.parse(declaration) return try [ - Declaration("@main extension \(`struct`.name.trimmedDescription): Application, Controller") { + Declaration("extension \(`struct`.name.trimmedDescription): Application, Controller") { routes.routeFunction() }, ] diff --git a/Example/App.swift b/Example/App.swift index 95868233..14d67882 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -1,5 +1,6 @@ import Alchemy +@main @Application struct App { func boot() throws { diff --git a/Package.swift b/Package.swift index 44ebce7a..3c835fc6 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ let package = Package( .iOS(.v16), ], products: [ - .executable(name: "Demo", targets: ["AlchemyExample"]), + .executable(name: "AlchemyExample", targets: ["AlchemyExample"]), .library(name: "Alchemy", targets: ["Alchemy"]), .library(name: "AlchemyTest", targets: ["AlchemyTest"]), ], From 7ac145c96bb57f17bb2f104a1aaf3cb0a24a7dbd Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 22 Jun 2024 09:51:10 -0700 Subject: [PATCH 46/55] logging --- Alchemy/Logging/Logger+Utilities.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Alchemy/Logging/Logger+Utilities.swift b/Alchemy/Logging/Logger+Utilities.swift index afa9e87c..1fddb3c2 100644 --- a/Alchemy/Logging/Logger+Utilities.swift +++ b/Alchemy/Logging/Logger+Utilities.swift @@ -113,7 +113,7 @@ extension Logger: Service { static let `default`: Logger = { let isTests = Environment.isRunFromTests - let defaultLevel: Logger.Level = isTests ? .error : .debug + let defaultLevel: Logger.Level = isTests ? .error : .info if Environment.isXcode { return .xcode.withLevel(defaultLevel) } else { From b40c29a628b7d226cbe09aee5e7caf13f59fcff0 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 22 Jun 2024 16:28:20 -0700 Subject: [PATCH 47/55] now with regex --- Alchemy/Validation/Validator.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Alchemy/Validation/Validator.swift b/Alchemy/Validation/Validator.swift index 07e541b0..be9a8a15 100644 --- a/Alchemy/Validation/Validator.swift +++ b/Alchemy/Validation/Validator.swift @@ -31,9 +31,8 @@ public struct Validator: @unchecked Sendable { extension Validator { public static let email = Validator("Invalid email.") { - let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" - let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) - return emailPred.evaluate(with: $0) + try Regex("[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}") + .firstMatch(in: $0) != nil } } From 6deead3dcbcacbf00514f4aee3d3200ca6727499 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 22 Jun 2024 16:42:34 -0700 Subject: [PATCH 48/55] update --- Tests/Filesystem/FilesystemTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Filesystem/FilesystemTests.swift b/Tests/Filesystem/FilesystemTests.swift index b048e0dd..d75a6643 100644 --- a/Tests/Filesystem/FilesystemTests.swift +++ b/Tests/Filesystem/FilesystemTests.swift @@ -81,7 +81,7 @@ final class FilesystemTests: TestCase { func _testInvalidURL() async throws { do { - let store: Filesystem = .local(root: "+https://www.apple.com") + let store: Filesystem = .local(root: "\\+https://www.apple.com") _ = try await store.exists("foo") XCTFail("Should throw an error") } catch {} From 96e27eb95c5fbaa5ef216e388270fec559a3ba63 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 22 Jun 2024 19:04:26 -0700 Subject: [PATCH 49/55] fix relationship --- Alchemy/AlchemyX/Application+AlchemyX.swift | 10 ++-------- Alchemy/Auth/TokenAuthable.swift | 9 +++------ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/Alchemy/AlchemyX/Application+AlchemyX.swift b/Alchemy/AlchemyX/Application+AlchemyX.swift index 97f96366..6337ab72 100644 --- a/Alchemy/AlchemyX/Application+AlchemyX.swift +++ b/Alchemy/AlchemyX/Application+AlchemyX.swift @@ -64,15 +64,11 @@ extension Controller { @Model struct Token: TokenAuthable { - typealias Authorizes = User - var id: UUID var value: String = UUID().uuidString let userId: UUID - var user: BelongsTo { - belongsTo() - } + @BelongsTo var user: User } @Model @@ -82,9 +78,7 @@ struct User { var password: String var phone: String? - var tokens: HasMany { - hasMany() - } + @HasMany var tokens: [Token] var dto: AlchemyX.User { AlchemyX.User( diff --git a/Alchemy/Auth/TokenAuthable.swift b/Alchemy/Auth/TokenAuthable.swift index b0497db6..65639edc 100644 --- a/Alchemy/Auth/TokenAuthable.swift +++ b/Alchemy/Auth/TokenAuthable.swift @@ -38,13 +38,11 @@ public protocol TokenAuthable: Model { /// this type will be pulled from the database and /// associated with the request. associatedtype Authorizes: Model - associatedtype AuthorizesRelation: Relationship /// The user in question. - var user: AuthorizesRelation { get } + var user: Authorizes { get async throws } - /// The name of the row that stores the token's value. Defaults to - /// `"value"`. + /// The name of the row that stores the token's value. Defaults to "value"`. static var valueKeyString: String { get } } @@ -77,7 +75,6 @@ public struct TokenAuthMiddleware: Middleware { guard let model = try await T .where(T.valueKeyString == bearerAuth.token) - .with(\.user) .first() else { throw HTTPError(.unauthorized) @@ -86,7 +83,7 @@ public struct TokenAuthMiddleware: Middleware { return try await next( request .set(model) - .set(model.user()) + .set(model.user) ) } } From dc48f675b1b9bd13ad55cb79f455c5af05894bc5 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 23 Jun 2024 05:08:58 -0700 Subject: [PATCH 50/55] cleanup relation tests --- Alchemy/Database/Rune/Model/OneOrMany.swift | 6 +- .../Rune/Relations/BelongsToMany.swift | 38 ++++----- Alchemy/Database/Rune/Relations/HasMany.swift | 20 ++--- .../Rune/Relations/HasManyThrough.swift | 6 +- .../Sources/Macros/RelationshipMacro.swift | 2 +- Tests/Auth/Fixtures/TokenModel.swift | 6 +- ...ionTests.swift => RelationshipTests.swift} | 77 +++++++------------ 7 files changed, 66 insertions(+), 89 deletions(-) rename Tests/Database/Rune/Relations/{RelationTests.swift => RelationshipTests.swift} (78%) diff --git a/Alchemy/Database/Rune/Model/OneOrMany.swift b/Alchemy/Database/Rune/Model/OneOrMany.swift index e0a09fc6..f99009b5 100644 --- a/Alchemy/Database/Rune/Model/OneOrMany.swift +++ b/Alchemy/Database/Rune/Model/OneOrMany.swift @@ -1,10 +1,12 @@ public protocol OneOrMany { - associatedtype M: Model + associatedtype M: Model = Self var array: [M] { get } init(models: [M]) throws } -extension Array: OneOrMany where Element: Model { +public protocol Many: OneOrMany {} + +extension Array: Many, OneOrMany where Element: Model { public typealias M = Element public init(models: [Element]) throws { diff --git a/Alchemy/Database/Rune/Relations/BelongsToMany.swift b/Alchemy/Database/Rune/Relations/BelongsToMany.swift index e37781e9..cda76960 100644 --- a/Alchemy/Database/Rune/Relations/BelongsToMany.swift +++ b/Alchemy/Database/Rune/Relations/BelongsToMany.swift @@ -1,19 +1,19 @@ import Collections extension Model { - public typealias BelongsToMany = BelongsToManyRelationship - - public func belongsToMany(on db: Database = To.M.database, - _ pivotTable: String? = nil, - from fromKey: String? = nil, - to toKey: String? = nil, - pivotFrom: String? = nil, - pivotTo: String? = nil) -> BelongsToMany { + public typealias BelongsToMany = BelongsToManyRelationship + + public func belongsToMany(on db: Database = To.M.database, + _ pivotTable: String? = nil, + from fromKey: String? = nil, + to toKey: String? = nil, + pivotFrom: String? = nil, + pivotTo: String? = nil) -> BelongsToMany { BelongsToMany(db: db, from: self, pivotTable, fromKey: fromKey, toKey: toKey, pivotFrom: pivotFrom, pivotTo: pivotTo) } } -public class BelongsToManyRelationship: Relationship { +public class BelongsToManyRelationship: Relationship { private var pivot: Through { guard let pivot = throughs.first else { preconditionFailure("BelongsToManyRelationship must always have at least 1 through.") @@ -23,7 +23,7 @@ public class BelongsToManyRelationship: Relationship: Relationship: Relationship: Relationship = HasManyRelationship + public typealias HasMany = HasManyRelationship - public func hasMany(_ type: To.Type = To.self, - on db: Database = To.M.database, - from fromKey: String? = nil, - to toKey: String? = nil) -> HasMany { + public func hasMany(_ type: To.Type = To.self, + on db: Database = To.M.database, + from fromKey: String? = nil, + to toKey: String? = nil) -> HasMany { HasMany(db: db, from: self, fromKey: fromKey, toKey: toKey) } } -public class HasManyRelationship: Relationship { +public class HasManyRelationship: Relationship { public init(db: Database = To.M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { let fromKey: SQLKey = .infer(From.primaryKey).specify(fromKey) let toKey: SQLKey = db.inferReferenceKey(From.self).specify(toKey) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) } - public func connect(_ model: M) async throws { + public func connect(_ model: To.M) async throws { try await connect([model]) } - public func connect(_ models: [M]) async throws { + public func connect(_ models: [To.M]) async throws { let value = try requireFromValue() try await models.updateAll(["\(toKey)": value]) } - public func replace(_ models: [M]) async throws { + public func replace(_ models: [To.M]) async throws { try await disconnectAll() try await connect(models) } - public func disconnect(_ model: M) async throws { + public func disconnect(_ model: To.M) async throws { try await model.update(["\(toKey)": SQLValue.null]) } diff --git a/Alchemy/Database/Rune/Relations/HasManyThrough.swift b/Alchemy/Database/Rune/Relations/HasManyThrough.swift index 39a4fc40..96a2e14a 100644 --- a/Alchemy/Database/Rune/Relations/HasManyThrough.swift +++ b/Alchemy/Database/Rune/Relations/HasManyThrough.swift @@ -1,7 +1,7 @@ extension Model { - public typealias HasManyThrough = HasManyThroughRelationship + public typealias HasManyThrough = HasManyThroughRelationship - public func hasManyThrough(db: Database = To.M.database, + public func hasManyThrough(db: Database = To.M.database, _ through: String, fromKey: String? = nil, toKey: String? = nil, @@ -18,7 +18,7 @@ extension HasManyRelationship { } } -public final class HasManyThroughRelationship: Relationship { +public final class HasManyThroughRelationship: Relationship { public init(hasMany: HasManyRelationship, through table: String, fromKey: String?, toKey: String?) { super.init(db: hasMany.db, from: hasMany.from, fromKey: hasMany.fromKey, toKey: hasMany.toKey) through(table, from: fromKey, to: toKey) diff --git a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift index 2bc9d79f..1845f4f0 100644 --- a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift +++ b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift @@ -36,7 +36,7 @@ public enum RelationshipMacro: AccessorMacro, PeerMacro { let arguments = node.arguments.map { "\($0.trimmedDescription)" } ?? "" return [ - Declaration("var $\(declaration.name): \(node.name)<\(declaration.type).M>") { + Declaration("var $\(declaration.name): \(node.name)<\(declaration.type)>") { """ \(node.name.lowercaseFirst)(\(arguments)) .named(\(declaration.name.inQuotes)) diff --git a/Tests/Auth/Fixtures/TokenModel.swift b/Tests/Auth/Fixtures/TokenModel.swift index 4ce18b95..952d2992 100644 --- a/Tests/Auth/Fixtures/TokenModel.swift +++ b/Tests/Auth/Fixtures/TokenModel.swift @@ -8,11 +8,7 @@ struct TokenModel: TokenAuthable { var value: UUID = UUID() var userId: Int - @BelongsTo(from: "user_id") var auth: AuthModel - - var user: BelongsTo { - $auth - } + @BelongsTo(from: "user_id") var user: AuthModel struct Migrate: Migration { func up(db: Database) async throws { diff --git a/Tests/Database/Rune/Relations/RelationTests.swift b/Tests/Database/Rune/Relations/RelationshipTests.swift similarity index 78% rename from Tests/Database/Rune/Relations/RelationTests.swift rename to Tests/Database/Rune/Relations/RelationshipTests.swift index ab60777a..c5622b21 100644 --- a/Tests/Database/Rune/Relations/RelationTests.swift +++ b/Tests/Database/Rune/Relations/RelationshipTests.swift @@ -2,7 +2,7 @@ import Alchemy import AlchemyTest -final class RelationTests: TestCase { +final class RelationshipTests: TestCase { private var organization: Organization! private var user: User! private var repository: Repository! @@ -48,17 +48,17 @@ final class RelationTests: TestCase { // MARK: - Basics func testHasMany() async throws { - let repositories = try await user.refresh().repositories.get() + let repositories = try await user.refresh().repositories XCTAssertEqual(repositories.map(\.id), [5, 6]) } func testHasOne() async throws { - let manager = try await user.report() + let manager = try await user.report XCTAssertEqual(manager?.id, 4) } func testBelongsTo() async throws { - let user = try await repository.user() + let user = try await repository.user XCTAssertEqual(user.id, 3) } @@ -68,7 +68,7 @@ final class RelationTests: TestCase { } func testPivot() async throws { - let organizations = try await user.organizations.value() + let organizations = try await user.organizations XCTAssertEqual(organizations.map(\.id), [1, 2]) } @@ -80,31 +80,31 @@ final class RelationTests: TestCase { // MARK: - Eager Loading func testEagerLoad() async throws { - let user = try await User.where("id" == 3).with(\.repositories).first() + let user = try await User.where("id" == 3).with(\.$repositories).first() XCTAssertNotNil(user) - XCTAssertNoThrow(try user?.repositories.require()) + XCTAssertNoThrow(try user?.$repositories.require()) } func testAutoCache() async throws { - XCTAssertThrowsError(try user.repositories.require()) - _ = try await user.repositories.value() - XCTAssertTrue(user.repositories.isLoaded) - XCTAssertNoThrow(try user.repositories.require()) + XCTAssertThrowsError(try user.$repositories.require()) + _ = try await user.$repositories.value() + XCTAssertTrue(user.$repositories.isLoaded) + XCTAssertNoThrow(try user.$repositories.require()) } func testWhereCache() async throws { - _ = try await organization.users.value() - XCTAssertTrue(organization.users.isLoaded) + _ = try await organization.users + XCTAssertTrue(organization.$users.isLoaded) XCTAssertFalse(organization.usersOver30.isLoaded) } func testSync() async throws { - let report = try await user.report() + let report = try await user.report XCTAssertEqual(report?.id, 4) try await report?.update(["manager_id": SQLValue.null]) - XCTAssertTrue(user.report.isLoaded) - AssertEqual(try await user.report()?.id, 4) - AssertNil(try await user.report.load()) + XCTAssertTrue(user.$report.isLoaded) + AssertEqual(try await user.report?.id, 4) + AssertNil(try await user.$report.load()) } // MARK: - CRUD @@ -130,11 +130,9 @@ final class RelationTests: TestCase { private struct Organization { var id: Int - var users: BelongsToMany { - belongsToMany(UserOrganization.table) - } + @BelongsToMany(UserOrganization.table) var users: [User] - var usersOver30: BelongsToMany { + var usersOver30: BelongsToMany<[User]> { belongsToMany(UserOrganization.table) .where("age" >= 30) } @@ -154,23 +152,16 @@ private struct User { let age: Int var managerId: Int? - var report: HasOne { - hasOne(to: "manager_id") - } - - var repositories: HasMany { - hasMany() - } + @HasOne(to: "manager_id") var report: User? + @HasMany var repositories: [Repository] - var jobs: HasManyThrough { + var jobs: HasManyThrough<[Job]> { hasMany(to: "workflow_id") .through(Repository.table, from: "user_id", to: "id") .through(Workflow.table, from: "repository_id", to: "id") } - var organizations: BelongsToMany { - belongsToMany(UserOrganization.table) - } + @BelongsToMany(UserOrganization.table) var organizations: [Organization] } @Model @@ -178,13 +169,8 @@ private struct Repository { var id: Int var userId: Int - var user: BelongsTo { - belongsTo() - } - - var workflows: HasMany { - hasMany() - } + @BelongsTo var user: User + @HasMany var workflows: [Workflow] } @Model @@ -192,13 +178,8 @@ private struct Workflow { var id: Int var repositoryId: Int - var repository: BelongsTo { - belongsTo() - } - - var jobs: HasMany { - hasMany() - } + @BelongsTo var repository: Repository + @HasMany var jobs: [Job] } @Model @@ -206,9 +187,7 @@ private struct Job { var id: Int var workflowId: Int - var workflow: BelongsTo { - belongsTo() - } + @BelongsTo var workflow: Workflow var user: BelongsToThrough { belongsTo() From c7f72a4014b7a0a9ec97702a487cc6efb580b527 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 23 Jun 2024 05:53:54 -0700 Subject: [PATCH 51/55] fix type inference and default id value --- .../Rune/Model/Coding/SQLRowReader.swift | 4 +++ Alchemy/Database/Rune/Model/ModelField.swift | 5 ++-- .../Database/Rune/Model/ModelStorage.swift | 4 +-- Alchemy/Utilities/GenericCodingKey.swift | 6 +++++ AlchemyPlugin/Sources/Macros/ModelMacro.swift | 26 +++++++++++-------- Example/App.swift | 3 ++- .../Database/Rune/Model/ModelCrudTests.swift | 4 +-- 7 files changed, 33 insertions(+), 19 deletions(-) diff --git a/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift b/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift index 32702781..5a82dd1c 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift @@ -30,6 +30,10 @@ public struct SQLRowReader { } } + public func require(_ keyPath: KeyPath, at key: String) throws -> D { + try require(D.self, at: key) + } + public func contains(_ column: String) -> Bool { row[keyMapping.encode(column)] != nil } diff --git a/Alchemy/Database/Rune/Model/ModelField.swift b/Alchemy/Database/Rune/Model/ModelField.swift index 989268e7..d200ca31 100644 --- a/Alchemy/Database/Rune/Model/ModelField.swift +++ b/Alchemy/Database/Rune/Model/ModelField.swift @@ -3,16 +3,15 @@ import Collections public struct ModelField: Identifiable { public var id: String { name } public let name: String - public let type: Any.Type public let `default`: Any? - public init(_ name: String, type: T.Type, default: T? = nil) { + public init(_ name: String, path: KeyPath, default: T? = nil) { self.name = name - self.type = type self.default = `default` } } extension Model { + public typealias Field = ModelField public typealias FieldLookup = OrderedDictionary, ModelField> } diff --git a/Alchemy/Database/Rune/Model/ModelStorage.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift index 378016f0..b59422fb 100644 --- a/Alchemy/Database/Rune/Model/ModelStorage.swift +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -10,8 +10,8 @@ public final class ModelStorage: Codable, Equatable { public var relationships: [CacheKey: Any] - public init() { - self.id = nil + public init(id: M.PrimaryKey? = nil) { + self.id = id self.row = nil self.relationships = [:] } diff --git a/Alchemy/Utilities/GenericCodingKey.swift b/Alchemy/Utilities/GenericCodingKey.swift index daf00026..9eec8dbd 100644 --- a/Alchemy/Utilities/GenericCodingKey.swift +++ b/Alchemy/Utilities/GenericCodingKey.swift @@ -22,3 +22,9 @@ public struct GenericCodingKey: CodingKey, ExpressibleByStringLiteral { self.init(stringValue: value) } } + +extension KeyedDecodingContainer where K == GenericCodingKey { + public func decode(_ keyPath: KeyPath, forKey key: String) throws -> D { + try decode(D.self, forKey: .key(key)) + } +} diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index 712b5584..2caf9d9e 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -72,12 +72,12 @@ struct Resource { struct Property { let keyword: String let name: String - let type: String + let type: String? let defaultValue: String? let isStored: Bool var isOptional: Bool { - type.last == "?" + type?.last == "?" } } @@ -96,6 +96,10 @@ struct Resource { var storedPropertiesExceptId: [Property] { storedProperties.filter { $0.name != "id" } } + + var idProperty: Property? { + storedProperties.filter { $0.name == "id" }.first + } } extension Resource { @@ -125,12 +129,8 @@ extension Resource.Property { throw AlchemyMacroError("Unable to detect property name") } - guard let typeAnnotation = patternBinding.typeAnnotation else { - throw AlchemyMacroError("Property '\(identifierPattern.identifier.trimmedDescription)' had no type annotation") - } - let name = "\(identifierPattern.identifier.text)" - let type = "\(typeAnnotation.type.trimmedDescription)" + let type = patternBinding.typeAnnotation?.type.trimmedDescription let defaultValue = patternBinding.initializer.map { "\($0.value.trimmed)" } let isStored = patternBinding.accessorBlock == nil @@ -149,14 +149,18 @@ extension Resource { // MARK: Model fileprivate func generateStorage() -> Declaration { - Declaration("var storage = Storage()") + if let idProperty, let defaultValue = idProperty.defaultValue { + Declaration("var storage = Storage(id: \(defaultValue))") + } else { + Declaration("var storage = Storage()") + } } fileprivate func generateInitializer() -> Declaration { Declaration("init(row: SQLRow) throws") { "let reader = SQLRowReader(row: row, keyMapping: Self.keyMapping, jsonDecoder: Self.jsonDecoder)" for property in storedPropertiesExceptId { - "self.\(property.name) = try reader.require(\(property.type).self, at: \(property.name.inQuotes))" + "self.\(property.name) = try reader.require(\\Self.\(property.name), at: \(property.name.inQuotes))" } "try storage.read(from: reader)" @@ -184,7 +188,7 @@ extension Resource { let key = "\\\(name).\(property.name)" let defaultValue = property.defaultValue let defaultArgument = defaultValue.map { ", default: \($0)" } ?? "" - let value = ".init(\(property.name.inQuotes), type: \(property.type).self\(defaultArgument))" + let value = "Field(\(property.name.inQuotes), path: \(key)\(defaultArgument))" return "\(key): \(value)" } .joined(separator: ",\n") @@ -216,7 +220,7 @@ extension Resource { if !storedPropertiesExceptId.isEmpty { "let container = try decoder.container(keyedBy: GenericCodingKey.self)" for property in storedPropertiesExceptId { - "self.\(property.name) = try container.decode(\(property.type).self, forKey: \(property.name.inQuotes))" + "self.\(property.name) = try container.decode(\\Self.\(property.name), forKey: \(property.name.inQuotes))" } } diff --git a/Example/App.swift b/Example/App.swift index 14d67882..642e099b 100644 --- a/Example/App.swift +++ b/Example/App.swift @@ -42,7 +42,8 @@ struct UserController { struct Todo { var id: Int let name: String - var isDone: Bool = false + var isDone = false + var val = UUID() let tags: [String]? @HasOne var hasOne: Todo diff --git a/Tests/Database/Rune/Model/ModelCrudTests.swift b/Tests/Database/Rune/Model/ModelCrudTests.swift index 36865c7b..8c67b385 100644 --- a/Tests/Database/Rune/Model/ModelCrudTests.swift +++ b/Tests/Database/Rune/Model/ModelCrudTests.swift @@ -88,7 +88,7 @@ final class ModelCrudTests: TestCase { XCTAssertEqual(model.foo, "bar") XCTAssertEqual(model.bar, false) - let customId = try await TestModelCustomId(foo: "bar").id(UUID()).insertReturn() + let customId = try await TestModelCustomId(foo: "bar").insertReturn() XCTAssertEqual(customId.foo, "bar") } @@ -130,7 +130,7 @@ private struct TestError: Error {} @Model private struct TestModelCustomId { - var id: UUID + var id = UUID() var foo: String } From 03042cb850f28ba23f0430a6f992d887a3028895 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 23 Jun 2024 05:59:33 -0700 Subject: [PATCH 52/55] bad --- Alchemy/Validation/Validator.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Alchemy/Validation/Validator.swift b/Alchemy/Validation/Validator.swift index be9a8a15..a8c24e1f 100644 --- a/Alchemy/Validation/Validator.swift +++ b/Alchemy/Validation/Validator.swift @@ -29,13 +29,6 @@ public struct Validator: @unchecked Sendable { } } -extension Validator { - public static let email = Validator("Invalid email.") { - try Regex("[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}") - .firstMatch(in: $0) != nil - } -} - extension Validator { public static func between(_ range: ClosedRange) -> Validator { Validator { range.contains($0) } From 5ca1f94ddc799f9c7ba9de2225887e1e102ec2b7 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 23 Jun 2024 06:08:49 -0700 Subject: [PATCH 53/55] cleanup --- Alchemy/Database/Rune/Model/Model+CRUD.swift | 16 ++++++++-------- Alchemy/Database/Rune/Model/Model.swift | 19 ++++++++----------- .../Database/Rune/Model/ModelStorage.swift | 18 +++++++++--------- .../Database/Rune/Relations/BelongsTo.swift | 2 +- .../Rune/Relations/BelongsToMany.swift | 4 ++-- .../Rune/Relations/BelongsToThrough.swift | 4 ++-- Alchemy/Database/Rune/Relations/HasMany.swift | 2 +- .../Rune/Relations/HasManyThrough.swift | 2 +- Alchemy/Database/Rune/Relations/HasOne.swift | 2 +- .../Rune/Relations/HasOneThrough.swift | 2 +- 10 files changed, 34 insertions(+), 37 deletions(-) diff --git a/Alchemy/Database/Rune/Model/Model+CRUD.swift b/Alchemy/Database/Rune/Model/Model+CRUD.swift index da75e381..190ba6c3 100644 --- a/Alchemy/Database/Rune/Model/Model+CRUD.swift +++ b/Alchemy/Database/Rune/Model/Model+CRUD.swift @@ -21,8 +21,8 @@ extension Model { } /// Fetch the first model with the given id. - public static func find(on db: Database = database, _ id: PrimaryKey) async throws -> Self? { - try await `where`(on: db, primaryKey == id).first() + public static func find(on db: Database = database, _ id: ID) async throws -> Self? { + try await `where`(on: db, idKey == id).first() } /// Fetch the first model that matches the given where clause. @@ -50,7 +50,7 @@ extension Model { /// /// - Parameter error: The error to throw should no element be /// found. Defaults to `RuneError.notFound`. - public static func require(_ id: PrimaryKey, error: Error = RuneError.notFound, db: Database = database) async throws -> Self { + public static func require(_ id: ID, error: Error = RuneError.notFound, db: Database = database) async throws -> Self { guard let model = try await find(on: db, id) else { throw error } @@ -172,8 +172,8 @@ extension Model { } /// Delete the first model with the given id. - public static func delete(on db: Database = database, _ id: Self.PrimaryKey) async throws { - try await query(on: db).where(primaryKey == id).delete() + public static func delete(on db: Database = database, _ id: Self.ID) async throws { + try await query(on: db).where(idKey == id).delete() } /// Delete all models of this type from a database. @@ -250,7 +250,7 @@ extension Array where Element: Model { let fields = touchUpdatedAt(on: db, fields) try await Element.willUpdate(self) try await Element.query(on: db) - .where(Element.primaryKey, in: ids) + .where(Element.idKey, in: ids) .update(fields) try await Element.didUpdate(self) } @@ -280,7 +280,7 @@ extension Array where Element: Model { let ids = map(\.id) try await Element.willDelete(self) try await Element.query(on: db) - .where(Element.primaryKey, in: ids) + .where(Element.idKey, in: ids) .delete() forEach { ($0 as? any Model & SoftDeletes)?.deletedAt = Date() } @@ -297,7 +297,7 @@ extension Array where Element: Model { let byId = keyed(by: \.id) let refreshed = try await Element.query() - .where(Element.primaryKey, in: byId.keys.array) + .where(Element.idKey, in: byId.keys.array) .get() // Transfer over any loaded relationships. diff --git a/Alchemy/Database/Rune/Model/Model.swift b/Alchemy/Database/Rune/Model/Model.swift index b286b069..39f0acf0 100644 --- a/Alchemy/Database/Rune/Model/Model.swift +++ b/Alchemy/Database/Rune/Model/Model.swift @@ -5,12 +5,9 @@ import Pluralize /// supporting relationships & more. /// /// Use @Model to apply this protocol. -public protocol Model: Identifiable, QueryResult, ModelOrOptional { - /// The type of this object's primary key. - associatedtype PrimaryKey: PrimaryKeyProtocol - +public protocol Model: Identifiable, QueryResult, ModelOrOptional where ID: PrimaryKeyProtocol { /// The identifier of this model - var id: PrimaryKey { get nonmutating set } + var id: ID { get nonmutating set } /// Storage for model metadata (relationships, original row, etc). var storage: Storage { get } @@ -26,7 +23,7 @@ public protocol Model: Identifiable, QueryResult, ModelOrOptional { static var table: String { get } /// The primary key column of this table. Defaults to `"id"`. - static var primaryKey: String { get } + static var idKey: String { get } /// The keys to to check for conflicts on when UPSERTing. Defaults to /// `[Self.primaryKey]`. @@ -54,8 +51,8 @@ extension Model { public static var database: Database { DB } public static var keyMapping: KeyMapping { database.keyMapping } public static var table: String { keyMapping.encode("\(Self.self)").pluralized } - public static var primaryKey: String { "id" } - public static var upsertConflictKeys: [String] { [primaryKey] } + public static var idKey: String { "id" } + public static var upsertConflictKeys: [String] { [idKey] } public static var jsonDecoder: JSONDecoder { JSONDecoder() } public static var jsonEncoder: JSONEncoder { JSONEncoder() } @@ -72,12 +69,12 @@ extension Model { query(on: database) } - public func id(_ id: PrimaryKey) -> Self { + public func id(_ id: ID) -> Self { self.id = id return self } - public func requireId() throws -> PrimaryKey { + public func requireId() throws -> ID { guard let id = storage.id else { throw RuneError("Model had no id!") } @@ -85,7 +82,7 @@ extension Model { return id } - public func maybeId() -> PrimaryKey? { + public func maybeId() -> ID? { storage.id } } diff --git a/Alchemy/Database/Rune/Model/ModelStorage.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift index b59422fb..eaa93897 100644 --- a/Alchemy/Database/Rune/Model/ModelStorage.swift +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -1,16 +1,16 @@ public final class ModelStorage: Codable, Equatable { - public var id: M.PrimaryKey? + public var id: M.ID? public var row: SQLRow? { didSet { - if let value = try? row?.require(M.primaryKey) { - self.id = try? M.PrimaryKey(value: value) + if let id = row?[M.idKey] { + self.id = try? M.ID(value: id) } } } public var relationships: [CacheKey: Any] - public init(id: M.PrimaryKey? = nil) { + public init(id: M.ID? = nil) { self.id = id self.row = nil self.relationships = [:] @@ -20,12 +20,12 @@ public final class ModelStorage: Codable, Equatable { public func write(to writer: inout SQLRowWriter) throws { if let id { - try writer.put(id, at: M.primaryKey) + try writer.put(id, at: M.idKey) } } public func read(from reader: SQLRowReader) throws { - id = try reader.require(M.PrimaryKey.self, at: M.primaryKey) + id = try reader.require(M.ID.self, at: M.idKey) row = reader.row } @@ -36,7 +36,7 @@ public final class ModelStorage: Codable, Equatable { // 0. encode id if let id { - try container.encode(id, forKey: .key(M.primaryKey)) + try container.encode(id, forKey: .key(M.idKey)) } // 1. encode encodable relationships @@ -49,9 +49,9 @@ public final class ModelStorage: Codable, Equatable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: GenericCodingKey.self) - let key: GenericCodingKey = .key(M.primaryKey) + let key: GenericCodingKey = .key(M.idKey) if container.contains(key) { - self.id = try container.decode(M.PrimaryKey.self, forKey: key) + self.id = try container.decode(M.ID.self, forKey: key) } self.row = nil diff --git a/Alchemy/Database/Rune/Relations/BelongsTo.swift b/Alchemy/Database/Rune/Relations/BelongsTo.swift index c4849c90..1a2a59bb 100644 --- a/Alchemy/Database/Rune/Relations/BelongsTo.swift +++ b/Alchemy/Database/Rune/Relations/BelongsTo.swift @@ -12,7 +12,7 @@ extension Model { public class BelongsToRelationship: Relationship { public init(db: Database = To.M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { let fromKey: SQLKey = db.inferReferenceKey(To.M.self).specify(fromKey) - let toKey: SQLKey = .infer(To.M.primaryKey).specify(toKey) + let toKey: SQLKey = .infer(To.M.idKey).specify(toKey) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) } diff --git a/Alchemy/Database/Rune/Relations/BelongsToMany.swift b/Alchemy/Database/Rune/Relations/BelongsToMany.swift index cda76960..3a5d2a41 100644 --- a/Alchemy/Database/Rune/Relations/BelongsToMany.swift +++ b/Alchemy/Database/Rune/Relations/BelongsToMany.swift @@ -31,8 +31,8 @@ public class BelongsToManyRelationship: Relationship Self { - let from: SQLKey = .infer(From.primaryKey).specify(throughFromKey) + let from: SQLKey = .infer(From.idKey).specify(throughFromKey) let to: SQLKey = db.inferReferenceKey(To.M.self).specify(throughToKey) let throughReference = db.inferReferenceKey(table) @@ -47,7 +47,7 @@ public final class BelongsToThroughRelationship Self { - let from: SQLKey = .infer(From.primaryKey).specify(throughFromKey) + let from: SQLKey = .infer(From.idKey).specify(throughFromKey) let to: SQLKey = db.inferReferenceKey(To.M.self).specify(throughToKey) let throughReference = db.inferReferenceKey(model) diff --git a/Alchemy/Database/Rune/Relations/HasMany.swift b/Alchemy/Database/Rune/Relations/HasMany.swift index cf593a48..c14c3ee2 100644 --- a/Alchemy/Database/Rune/Relations/HasMany.swift +++ b/Alchemy/Database/Rune/Relations/HasMany.swift @@ -11,7 +11,7 @@ extension Model { public class HasManyRelationship: Relationship { public init(db: Database = To.M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { - let fromKey: SQLKey = .infer(From.primaryKey).specify(fromKey) + let fromKey: SQLKey = .infer(From.idKey).specify(fromKey) let toKey: SQLKey = db.inferReferenceKey(From.self).specify(toKey) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) } diff --git a/Alchemy/Database/Rune/Relations/HasManyThrough.swift b/Alchemy/Database/Rune/Relations/HasManyThrough.swift index 96a2e14a..ae1d8c9f 100644 --- a/Alchemy/Database/Rune/Relations/HasManyThrough.swift +++ b/Alchemy/Database/Rune/Relations/HasManyThrough.swift @@ -43,7 +43,7 @@ public final class HasManyThroughRelationship: Relationshi from = from.infer(db.inferReferenceKey(through.table).string) } - let to: SQLKey = .infer(model.primaryKey).specify(throughToKey) + let to: SQLKey = .infer(model.idKey).specify(throughToKey) toKey = toKey.infer(db.inferReferenceKey(model).string) return _through(table: model.table, from: from, to: to) } diff --git a/Alchemy/Database/Rune/Relations/HasOne.swift b/Alchemy/Database/Rune/Relations/HasOne.swift index 807736c1..c4fbea10 100644 --- a/Alchemy/Database/Rune/Relations/HasOne.swift +++ b/Alchemy/Database/Rune/Relations/HasOne.swift @@ -11,7 +11,7 @@ extension Model { public class HasOneRelationship: Relationship { public init(db: Database = To.M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { - let fromKey: SQLKey = .infer(From.primaryKey).specify(fromKey) + let fromKey: SQLKey = .infer(From.idKey).specify(fromKey) let toKey: SQLKey = db.inferReferenceKey(From.self).specify(toKey) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) } diff --git a/Alchemy/Database/Rune/Relations/HasOneThrough.swift b/Alchemy/Database/Rune/Relations/HasOneThrough.swift index d9a430ea..31ce7571 100644 --- a/Alchemy/Database/Rune/Relations/HasOneThrough.swift +++ b/Alchemy/Database/Rune/Relations/HasOneThrough.swift @@ -43,7 +43,7 @@ public final class HasOneThroughRelationship: from = from.infer(db.inferReferenceKey(through.table).string) } - let to: SQLKey = .infer(model.primaryKey).specify(throughToKey) + let to: SQLKey = .infer(model.idKey).specify(throughToKey) toKey = toKey.infer(db.inferReferenceKey(model).string) return _through(table: model.table, from: from, to: to) } From b1ec1aa5a99f6b51c836a2e34ac196a897d674f0 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 23 Jun 2024 07:13:58 -0700 Subject: [PATCH 54/55] update --- .../Database/Rune/Model/ModelStorage.swift | 2 +- .../Rune/Relations/EagerLoadable.swift | 4 +- .../Rune/Relations/Relationship.swift | 8 +- .../AlchemyPluginError.swift | 0 .../{Utilities => Helpers}/Declaration.swift | 0 AlchemyPlugin/Sources/Helpers/Model.swift | 104 +++++++++ .../{Utilities => Helpers}/Routes.swift | 0 .../String+Utilities.swift | 4 + .../Sources/Helpers/SwiftSyntax+Helpers.swift | 19 ++ AlchemyPlugin/Sources/Macros/IDMacro.swift | 2 +- AlchemyPlugin/Sources/Macros/JobMacro.swift | 7 +- AlchemyPlugin/Sources/Macros/ModelMacro.swift | 218 +++++------------- .../Sources/Macros/RelationshipMacro.swift | 22 +- 13 files changed, 198 insertions(+), 192 deletions(-) rename AlchemyPlugin/Sources/{Utilities => Helpers}/AlchemyPluginError.swift (100%) rename AlchemyPlugin/Sources/{Utilities => Helpers}/Declaration.swift (100%) create mode 100644 AlchemyPlugin/Sources/Helpers/Model.swift rename AlchemyPlugin/Sources/{Utilities => Helpers}/Routes.swift (100%) rename AlchemyPlugin/Sources/{Utilities => Helpers}/String+Utilities.swift (75%) create mode 100644 AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift diff --git a/Alchemy/Database/Rune/Model/ModelStorage.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift index eaa93897..ed8db75a 100644 --- a/Alchemy/Database/Rune/Model/ModelStorage.swift +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -41,7 +41,7 @@ public final class ModelStorage: Codable, Equatable { // 1. encode encodable relationships for (key, relationship) in relationships { - if let relationship = relationship as? Encodable, let name = key.name { + if let relationship = relationship as? Encodable, let name = key.key { try container.encode(AnyEncodable(relationship), forKey: .key(name)) } } diff --git a/Alchemy/Database/Rune/Relations/EagerLoadable.swift b/Alchemy/Database/Rune/Relations/EagerLoadable.swift index f7677a6d..ddae7215 100644 --- a/Alchemy/Database/Rune/Relations/EagerLoadable.swift +++ b/Alchemy/Database/Rune/Relations/EagerLoadable.swift @@ -13,13 +13,13 @@ public protocol EagerLoadable { } public struct CacheKey: Hashable { - public let name: String? + public let key: String? public let value: String } extension EagerLoadable { public var cacheKey: CacheKey { - CacheKey(name: nil, value: "\(Self.self)") + CacheKey(key: nil, value: "\(Self.self)") } public var isLoaded: Bool { diff --git a/Alchemy/Database/Rune/Relations/Relationship.swift b/Alchemy/Database/Rune/Relations/Relationship.swift index 244fe9e0..689f1fa5 100644 --- a/Alchemy/Database/Rune/Relations/Relationship.swift +++ b/Alchemy/Database/Rune/Relations/Relationship.swift @@ -12,7 +12,7 @@ public class Relationship: Query, EagerLoadabl var throughs: [Through] /// Relationships will be encoded at this key. - var name: String? = nil + var key: String? = nil public override var sql: SQL { sql(for: [from]) @@ -29,7 +29,7 @@ public class Relationship: Query, EagerLoadabl let key = "\(Self.self)_\(fromKey)_\(toKey)" let throughKeys = throughs.map { "\($0.table)_\($0.from)_\($0.to)" } let whereKeys = wheres.map { "\($0.hashValue)" } - return CacheKey(name: name, value: ([key] + throughKeys + whereKeys).joined(separator: ":")) + return CacheKey(key: key, value: ([key] + throughKeys + whereKeys).joined(separator: ":")) } public init(db: Database, from: From, fromKey: SQLKey, toKey: SQLKey) { @@ -91,8 +91,8 @@ public class Relationship: Query, EagerLoadabl return value } - public func named(_ name: String) -> Self { - self.name = name + public func key(_ key: String) -> Self { + self.key = key return self } } diff --git a/AlchemyPlugin/Sources/Utilities/AlchemyPluginError.swift b/AlchemyPlugin/Sources/Helpers/AlchemyPluginError.swift similarity index 100% rename from AlchemyPlugin/Sources/Utilities/AlchemyPluginError.swift rename to AlchemyPlugin/Sources/Helpers/AlchemyPluginError.swift diff --git a/AlchemyPlugin/Sources/Utilities/Declaration.swift b/AlchemyPlugin/Sources/Helpers/Declaration.swift similarity index 100% rename from AlchemyPlugin/Sources/Utilities/Declaration.swift rename to AlchemyPlugin/Sources/Helpers/Declaration.swift diff --git a/AlchemyPlugin/Sources/Helpers/Model.swift b/AlchemyPlugin/Sources/Helpers/Model.swift new file mode 100644 index 00000000..2df3c7de --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/Model.swift @@ -0,0 +1,104 @@ +import SwiftSyntax + +struct Model { + struct Property { + /// either let or var + let keyword: String + let name: String + let type: String? + let defaultValue: String? + let isStored: Bool + } + + /// The type's access level - public, private, etc + let accessLevel: String? + /// The type name + let name: String + /// The type's properties + let properties: [Property] + + /// The type's stored properties + var storedProperties: [Property] { + properties.filter(\.isStored) + } + + var storedPropertiesExceptId: [Property] { + storedProperties.filter { $0.name != "id" } + } + + var idProperty: Property? { + storedProperties.filter { $0.name == "id" }.first + } +} + +extension Model { + static func parse(syntax: DeclSyntaxProtocol) throws -> Model { + guard let `struct` = syntax.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("For now, @Model can only be applied to a struct") + } + + return Model( + accessLevel: `struct`.accessLevel, + name: `struct`.structName, + properties: try `struct`.instanceMembers.map(Model.Property.parse) + ) + } +} + +extension Model.Property { + static func parse(variable: VariableDeclSyntax) throws -> Model.Property { + let patternBindings = variable.bindings.compactMap { PatternBindingSyntax.init($0) } + let keyword = variable.bindingSpecifier.text + + guard let patternBinding = patternBindings.first else { + throw AlchemyMacroError("Property had no pattern bindings") + } + + guard let identifierPattern = patternBinding.pattern.as(IdentifierPatternSyntax.self) else { + throw AlchemyMacroError("Unable to detect property name") + } + + let name = "\(identifierPattern.identifier.text)" + let type = patternBinding.typeAnnotation?.type.trimmedDescription + let defaultValue = patternBinding.initializer.map { "\($0.value.trimmed)" } + let isStored = patternBinding.accessorBlock == nil + + return Model.Property( + keyword: keyword, + name: name, + type: type, + defaultValue: defaultValue, + isStored: isStored + ) + } +} + +extension DeclGroupSyntax { + fileprivate var accessLevel: String? { + modifiers.first?.trimmedDescription + } + + var functions: [FunctionDeclSyntax] { + memberBlock.members.compactMap { $0.decl.as(FunctionDeclSyntax.self) } + } + + var initializers: [InitializerDeclSyntax] { + memberBlock.members.compactMap { $0.decl.as(InitializerDeclSyntax.self) } + } + + var variables: [VariableDeclSyntax] { + memberBlock.members.compactMap { $0.decl.as(VariableDeclSyntax.self) } + } + + fileprivate var instanceMembers: [VariableDeclSyntax] { + variables + .filter { !$0.isStatic } + .filter { $0.attributes.isEmpty } + } +} + +extension StructDeclSyntax { + fileprivate var structName: String { + name.text + } +} diff --git a/AlchemyPlugin/Sources/Utilities/Routes.swift b/AlchemyPlugin/Sources/Helpers/Routes.swift similarity index 100% rename from AlchemyPlugin/Sources/Utilities/Routes.swift rename to AlchemyPlugin/Sources/Helpers/Routes.swift diff --git a/AlchemyPlugin/Sources/Utilities/String+Utilities.swift b/AlchemyPlugin/Sources/Helpers/String+Utilities.swift similarity index 75% rename from AlchemyPlugin/Sources/Utilities/String+Utilities.swift rename to AlchemyPlugin/Sources/Helpers/String+Utilities.swift index a51c2d30..39e8e5bd 100644 --- a/AlchemyPlugin/Sources/Utilities/String+Utilities.swift +++ b/AlchemyPlugin/Sources/Helpers/String+Utilities.swift @@ -3,6 +3,10 @@ extension String { var capitalizeFirst: String { prefix(1).capitalized + dropFirst() } + + var lowercaseFirst: String { + prefix(1).lowercased() + dropFirst() + } } extension Collection { diff --git a/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift b/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift new file mode 100644 index 00000000..c636bc06 --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift @@ -0,0 +1,19 @@ +import SwiftSyntax + +extension VariableDeclSyntax { + var isStatic: Bool { + modifiers.contains { $0.name.trimmedDescription == "static" } + } + + var name: String { + bindings.compactMap { + $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.trimmedDescription + }.first ?? "unknown" + } + + var type: String { + bindings.compactMap { + $0.typeAnnotation?.type.trimmedDescription + }.first ?? "unknown" + } +} diff --git a/AlchemyPlugin/Sources/Macros/IDMacro.swift b/AlchemyPlugin/Sources/Macros/IDMacro.swift index 7d6f7a01..412416cb 100644 --- a/AlchemyPlugin/Sources/Macros/IDMacro.swift +++ b/AlchemyPlugin/Sources/Macros/IDMacro.swift @@ -14,7 +14,7 @@ struct IDMacro: AccessorMacro { throw AlchemyMacroError("@ID can only be applied to a stored property.") } - let property = try Resource.Property.parse(variable: variable) + let property = try Model.Property.parse(variable: variable) guard property.keyword == "var" else { throw AlchemyMacroError("Property 'id' must be a var.") } diff --git a/AlchemyPlugin/Sources/Macros/JobMacro.swift b/AlchemyPlugin/Sources/Macros/JobMacro.swift index b683f279..7147c4f5 100644 --- a/AlchemyPlugin/Sources/Macros/JobMacro.swift +++ b/AlchemyPlugin/Sources/Macros/JobMacro.swift @@ -8,16 +8,15 @@ struct JobMacro: PeerMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { guard - let function = declaration.as(FunctionDeclSyntax.self), - function.isStatic + let function = declaration.as(FunctionDeclSyntax.self) +// function.isStatic else { - throw AlchemyMacroError("@Job can only be applied to static functions") + throw AlchemyMacroError("@Job can only be applied to functions") } let name = function.name.text return [ Declaration("struct $\(name): Job, Codable") { - for parameter in function.parameters { "let \(parameter.name): \(parameter.type)" } diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift index 2caf9d9e..2efdc27a 100644 --- a/AlchemyPlugin/Sources/Macros/ModelMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -1,28 +1,7 @@ import SwiftSyntax import SwiftSyntaxMacros -struct ModelMacro: MemberMacro, ExtensionMacro, MemberAttributeMacro { - - // MARK: ExtensionMacro - - static func expansion( - of node: AttributeSyntax, - attachedTo declaration: some DeclGroupSyntax, - providingExtensionsOf type: some TypeSyntaxProtocol, - conformingTo protocols: [TypeSyntax], - in context: some MacroExpansionContext - ) throws -> [ExtensionDeclSyntax] { - let resource = try Resource.parse(syntax: declaration) - return try [ - Declaration("extension \(resource.name): Model, Codable") { - resource.generateInitializer() - resource.generateFields() - resource.generateEncode() - resource.generateDecode() - } - ] - .map { try $0.extensionDeclSyntax() } - } +struct ModelMacro: MemberMacro, MemberAttributeMacro, ExtensionMacro { // MARK: Member Macro @@ -31,12 +10,12 @@ struct ModelMacro: MemberMacro, ExtensionMacro, MemberAttributeMacro { providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - let resource = try Resource.parse(syntax: declaration) + let resource = try Model.parse(syntax: declaration) return [ resource.generateStorage(), - resource.generateFieldLookup(), + declaration.hasFieldLookupFunction ? nil : resource.generateFieldLookup(), ] - .map { $0.declSyntax() } + .compactMap { $0?.declSyntax() } } // MARK: MemberAttributeMacro @@ -47,116 +26,52 @@ struct ModelMacro: MemberMacro, ExtensionMacro, MemberAttributeMacro { providingAttributesFor member: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [AttributeSyntax] { - guard let member = member.as(VariableDeclSyntax.self) else { - return [] - } - - guard !member.isStatic else { + guard let member = member.as(VariableDeclSyntax.self), !member.isStatic else { return [] } - let property = try Resource.Property.parse(variable: member) - if property.name == "id" { - guard property.keyword == "var" else { - throw AlchemyMacroError("Property 'id' must be a var.") - } - - return ["@ID"] - } else { - return [] + let property = try Model.Property.parse(variable: member) + guard property.name == "id" else { return [] } + guard property.keyword == "var" else { + throw AlchemyMacroError("Property 'id' must be a var.") } - } -} -struct Resource { - struct Property { - let keyword: String - let name: String - let type: String? - let defaultValue: String? - let isStored: Bool - - var isOptional: Bool { - type?.last == "?" - } + return ["@ID"] } - /// The type's access level - public, private, etc - let accessLevel: String? - /// The type name - let name: String - /// The type's properties - let properties: [Property] - - /// The type's stored properties - var storedProperties: [Property] { - properties.filter(\.isStored) - } - - var storedPropertiesExceptId: [Property] { - storedProperties.filter { $0.name != "id" } - } - - var idProperty: Property? { - storedProperties.filter { $0.name == "id" }.first - } -} - -extension Resource { - static func parse(syntax: DeclSyntaxProtocol) throws -> Resource { - guard let `struct` = syntax.as(StructDeclSyntax.self) else { - throw AlchemyMacroError("For now, @Model can only be applied to a struct") - } - - return Resource( - accessLevel: `struct`.accessLevel, - name: `struct`.structName, - properties: try `struct`.instanceMembers.map(Resource.Property.parse) - ) - } -} - -extension Resource.Property { - static func parse(variable: VariableDeclSyntax) throws -> Resource.Property { - let patternBindings = variable.bindings.compactMap { PatternBindingSyntax.init($0) } - let keyword = variable.bindingSpecifier.text - - guard let patternBinding = patternBindings.first else { - throw AlchemyMacroError("Property had no pattern bindings") - } - - guard let identifierPattern = patternBinding.pattern.as(IdentifierPatternSyntax.self) else { - throw AlchemyMacroError("Unable to detect property name") - } - - let name = "\(identifierPattern.identifier.text)" - let type = patternBinding.typeAnnotation?.type.trimmedDescription - let defaultValue = patternBinding.initializer.map { "\($0.value.trimmed)" } - let isStored = patternBinding.accessorBlock == nil + // MARK: ExtensionMacro - return Resource.Property( - keyword: keyword, - name: name, - type: type, - defaultValue: defaultValue, - isStored: isStored - ) + static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + let resource = try Model.parse(syntax: declaration) + return try [ + Declaration("extension \(resource.name): Model, Codable") { + if !declaration.hasModelInit { resource.generateModelInit() } + if !declaration.hasFieldsFunction { resource.generateFields() } + if !declaration.hasDecodeInit { resource.generateDecode() } + if !declaration.hasEncodeFunction { resource.generateEncode() } + } + ] + .map { try $0.extensionDeclSyntax() } } } -extension Resource { +extension Model { // MARK: Model fileprivate func generateStorage() -> Declaration { - if let idProperty, let defaultValue = idProperty.defaultValue { - Declaration("var storage = Storage(id: \(defaultValue))") - } else { - Declaration("var storage = Storage()") - } + let id = idProperty.flatMap(\.defaultValue).map { "id: \($0)" } ?? "" + return Declaration("var storage = Storage(\(id))") + .access(accessLevel == "public" ? "public" : nil) } - fileprivate func generateInitializer() -> Declaration { + fileprivate func generateModelInit() -> Declaration { Declaration("init(row: SQLRow) throws") { "let reader = SQLRowReader(row: row, keyMapping: Self.keyMapping, jsonDecoder: Self.jsonDecoder)" for property in storedPropertiesExceptId { @@ -183,20 +98,23 @@ extension Resource { } fileprivate func generateFieldLookup() -> Declaration { - let fieldsString = storedProperties - .map { property in - let key = "\\\(name).\(property.name)" - let defaultValue = property.defaultValue - let defaultArgument = defaultValue.map { ", default: \($0)" } ?? "" - let value = "Field(\(property.name.inQuotes), path: \(key)\(defaultArgument))" - return "\(key): \(value)" - } - .joined(separator: ",\n") - return Declaration(""" - public static let fieldLookup: FieldLookup = [ - \(fieldsString) + Declaration( + """ + static let fieldLookup: FieldLookup = [ + \( + storedProperties + .map { property in + let key = "\\\(name).\(property.name)" + let defaultValue = property.defaultValue + let defaultArgument = defaultValue.map { ", default: \($0)" } ?? "" + let value = "Field(\(property.name.inQuotes), path: \(key)\(defaultArgument))" + return "\(key): \(value)" + } + .joined(separator: ",\n") + ) ] - """) + """ + ).access(accessLevel == "public" ? "public" : nil) } // MARK: Codable @@ -231,41 +149,23 @@ extension Resource { } extension DeclGroupSyntax { - var hasInit: Bool { - !initializers.isEmpty - } - - var initializers: [InitializerDeclSyntax] { - memberBlock - .members - .compactMap { $0.decl.as(InitializerDeclSyntax.self) } + fileprivate var hasModelInit: Bool { + initializers.map(\.trimmedDescription).contains { $0.contains("init(row: SQLRow)") } } - var accessLevel: String? { - modifiers.first?.trimmedDescription + fileprivate var hasDecodeInit: Bool { + initializers.map(\.trimmedDescription).contains { $0.contains("init(from decoder: Decoder)") } } - var members: [VariableDeclSyntax] { - memberBlock - .members - .compactMap { $0.decl.as(VariableDeclSyntax.self) } + fileprivate var hasEncodeFunction: Bool { + functions.map(\.trimmedDescription).contains { $0.contains("func encode(to encoder: Encoder)") } } - var instanceMembers: [VariableDeclSyntax] { - members - .filter { !$0.isStatic } - .filter { $0.attributes.isEmpty } + fileprivate var hasFieldsFunction: Bool { + functions.map(\.trimmedDescription).contains { $0.contains("func fields() throws -> SQLFields") } } -} - -extension VariableDeclSyntax { - var isStatic: Bool { - modifiers.contains { $0.name.trimmedDescription == "static" } - } -} -extension StructDeclSyntax { - var structName: String { - name.text + fileprivate var hasFieldLookupFunction: Bool { + functions.map(\.trimmedDescription).contains { $0.contains("fieldLookup: FieldLookup") } } } diff --git a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift index 1845f4f0..cd9852f9 100644 --- a/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift +++ b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift @@ -39,30 +39,10 @@ public enum RelationshipMacro: AccessorMacro, PeerMacro { Declaration("var $\(declaration.name): \(node.name)<\(declaration.type)>") { """ \(node.name.lowercaseFirst)(\(arguments)) - .named(\(declaration.name.inQuotes)) + .key(\(declaration.name.inQuotes)) """ } ] .map { $0.declSyntax() } } } - -extension String { - var lowercaseFirst: String { - prefix(1).lowercased() + dropFirst() - } -} - -extension VariableDeclSyntax { - var name: String { - bindings.compactMap { - $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.trimmedDescription - }.first ?? "unknown" - } - - var type: String { - bindings.compactMap { - $0.typeAnnotation?.type.trimmedDescription - }.first ?? "unknown" - } -} From 05be476a05d2ed547434874f7e420e1728970801 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 23 Jun 2024 07:34:31 -0700 Subject: [PATCH 55/55] cleanup macros --- AlchemyPlugin/Sources/AlchemyPlugin.swift | 11 +- ...ginError.swift => AlchemyMacroError.swift} | 0 AlchemyPlugin/Sources/Helpers/Endpoint.swift | 83 ++++++ .../Sources/Helpers/EndpointGroup.swift | 22 ++ .../Sources/Helpers/EndpointParameter.swift | 41 +++ AlchemyPlugin/Sources/Helpers/Routes.swift | 243 ------------------ .../Sources/Helpers/String+Utilities.swift | 12 + .../Sources/Helpers/SwiftSyntax+Helpers.swift | 58 +++++ .../Sources/Macros/ApplicationMacro.swift | 2 +- .../Sources/Macros/ControllerMacro.swift | 4 +- .../Sources/Macros/HTTPMethodMacro.swift | 52 ++-- AlchemyPlugin/Sources/Macros/JobMacro.swift | 31 +-- 12 files changed, 256 insertions(+), 303 deletions(-) rename AlchemyPlugin/Sources/Helpers/{AlchemyPluginError.swift => AlchemyMacroError.swift} (100%) create mode 100644 AlchemyPlugin/Sources/Helpers/Endpoint.swift create mode 100644 AlchemyPlugin/Sources/Helpers/EndpointGroup.swift create mode 100644 AlchemyPlugin/Sources/Helpers/EndpointParameter.swift delete mode 100644 AlchemyPlugin/Sources/Helpers/Routes.swift diff --git a/AlchemyPlugin/Sources/AlchemyPlugin.swift b/AlchemyPlugin/Sources/AlchemyPlugin.swift index a041417a..4623c2e2 100644 --- a/AlchemyPlugin/Sources/AlchemyPlugin.swift +++ b/AlchemyPlugin/Sources/AlchemyPlugin.swift @@ -6,13 +6,22 @@ import SwiftSyntaxMacros @main struct AlchemyPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ + + // MARK: Jobs + JobMacro.self, + + // MARK: Rune + ModelMacro.self, IDMacro.self, + RelationshipMacro.self, + + // MARK: Routing + ApplicationMacro.self, ControllerMacro.self, HTTPMethodMacro.self, - RelationshipMacro.self, ] } diff --git a/AlchemyPlugin/Sources/Helpers/AlchemyPluginError.swift b/AlchemyPlugin/Sources/Helpers/AlchemyMacroError.swift similarity index 100% rename from AlchemyPlugin/Sources/Helpers/AlchemyPluginError.swift rename to AlchemyPlugin/Sources/Helpers/AlchemyMacroError.swift diff --git a/AlchemyPlugin/Sources/Helpers/Endpoint.swift b/AlchemyPlugin/Sources/Helpers/Endpoint.swift new file mode 100644 index 00000000..2c058b91 --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/Endpoint.swift @@ -0,0 +1,83 @@ +import SwiftSyntax + +struct Endpoint { + /// Attributes to be applied to this endpoint. These take precedence + /// over attributes at the API scope. + let method: String + let path: String + let pathParameters: [String] + let options: String? + /// The name of the function defining this endpoint. + let name: String + let parameters: [EndpointParameter] + let isAsync: Bool + let isThrows: Bool + let responseType: String? +} + +extension Endpoint { + static func parse(_ function: FunctionDeclSyntax) throws -> Endpoint? { + guard let (method, path, pathParameters, options) = parseMethodAndPath(function) else { + return nil + } + + return Endpoint( + method: method, + path: path, + pathParameters: pathParameters, + options: options, + name: function.functionName, + parameters: function.parameters.compactMap { + EndpointParameter($0, httpMethod: method, pathParameters: pathParameters) + }, + isAsync: function.isAsync, + isThrows: function.isThrows, + responseType: function.returnType + ) + } + + private static func parseMethodAndPath( + _ function: FunctionDeclSyntax + ) -> (method: String, path: String, pathParameters: [String], options: String?)? { + var method, path, options: String? + for attribute in function.functionAttributes { + if case let .argumentList(list) = attribute.arguments { + let name = attribute.attributeName.trimmedDescription + switch name { + case "GET", "DELETE", "PATCH", "POST", "PUT", "OPTIONS", "HEAD", "TRACE", "CONNECT": + method = name + path = list.first?.expression.description.withoutQuotes + options = list.dropFirst().first?.expression.description.withoutQuotes + case "HTTP": + method = list.first.map { "RAW(value: \($0.expression.description))" } + path = list.dropFirst().first?.expression.description.withoutQuotes + options = list.dropFirst().dropFirst().first?.expression.description.withoutQuotes + default: + continue + } + } + } + + guard let method, let path else { + return nil + } + + return (method, path, path.pathParameters, options) + } +} + +extension String { + fileprivate var pathParameters: [String] { + components(separatedBy: "/").compactMap(\.extractParameter) + } + + private var extractParameter: String? { + if hasPrefix(":") { + String(dropFirst()) + } else if hasPrefix("{") && hasSuffix("}") { + String(dropFirst().dropLast()) + } else { + nil + } + } +} diff --git a/AlchemyPlugin/Sources/Helpers/EndpointGroup.swift b/AlchemyPlugin/Sources/Helpers/EndpointGroup.swift new file mode 100644 index 00000000..25d3403f --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/EndpointGroup.swift @@ -0,0 +1,22 @@ +import Foundation +import SwiftSyntax + +struct EndpointGroup { + /// The name of the type defining the API. + let name: String + /// Attributes to be applied to every endpoint of this API. + let endpoints: [Endpoint] +} + +extension EndpointGroup { + static func parse(_ decl: some DeclSyntaxProtocol) throws -> EndpointGroup { + guard let type = decl.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("@Routes must be applied to structs for now") + } + + return EndpointGroup( + name: type.name.text, + endpoints: try type.functions.compactMap( { try Endpoint.parse($0) }) + ) + } +} diff --git a/AlchemyPlugin/Sources/Helpers/EndpointParameter.swift b/AlchemyPlugin/Sources/Helpers/EndpointParameter.swift new file mode 100644 index 00000000..83012abe --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/EndpointParameter.swift @@ -0,0 +1,41 @@ +import SwiftSyntax + +/// Parsed from function parameters; indicates parts of the request. +struct EndpointParameter { + enum Kind { + case body + case field + case query + case header + case path + } + + let label: String? + let name: String + let type: String + let kind: Kind + let validation: String? + + init(_ parameter: FunctionParameterSyntax, httpMethod: String, pathParameters: [String]) { + self.label = parameter.label + self.name = parameter.name + self.type = parameter.typeName + self.validation = parameter.parameterAttributes + .first { $0.name == "Validate" } + .map { $0.trimmedDescription } + + let attributeNames = parameter.parameterAttributes.map(\.name) + self.kind = + if attributeNames.contains("Path") { .path } + else if attributeNames.contains("Body") { .body } + else if attributeNames.contains("Header") { .header } + else if attributeNames.contains("Field") { .field } + else if attributeNames.contains("URLQuery") { .query } + // if name matches a path param, infer this belongs in path + else if pathParameters.contains(name) { .path } + // if method is GET, HEAD, DELETE, infer query + else if ["GET", "HEAD", "DELETE"].contains(httpMethod) { .query } + // otherwise infer it's a body field + else { .field } + } +} diff --git a/AlchemyPlugin/Sources/Helpers/Routes.swift b/AlchemyPlugin/Sources/Helpers/Routes.swift deleted file mode 100644 index c7f674bb..00000000 --- a/AlchemyPlugin/Sources/Helpers/Routes.swift +++ /dev/null @@ -1,243 +0,0 @@ -import Foundation -import SwiftSyntax - -struct Routes { - struct Endpoint { - /// Attributes to be applied to this endpoint. These take precedence - /// over attributes at the API scope. - let method: String - let path: String - let pathParameters: [String] - let options: String? - /// The name of the function defining this endpoint. - let name: String - let parameters: [EndpointParameter] - let isAsync: Bool - let isThrows: Bool - let responseType: String? - } - - /// The name of the type defining the API. - let name: String - /// Attributes to be applied to every endpoint of this API. - let endpoints: [Endpoint] -} - -extension Routes { - static func parse(_ decl: some DeclSyntaxProtocol) throws -> Routes { - guard let type = decl.as(StructDeclSyntax.self) else { - throw AlchemyMacroError("@Routes must be applied to structs for now") - } - - return Routes( - name: type.name.text, - endpoints: try type.functions.compactMap( { try Endpoint.parse($0) }) - ) - } -} - -extension Routes.Endpoint { - var functionSignature: String { - let parameters = parameters.map { - let name = [$0.label, $0.name] - .compactMap { $0 } - .joined(separator: " ") - return "\(name): \($0.type)" - } - - let returnType = responseType.map { " -> \($0)" } ?? "" - return parameters.joined(separator: ", ").inParentheses + " async throws" + returnType - } - - static func parse(_ function: FunctionDeclSyntax) throws -> Routes.Endpoint? { - guard let (method, path, pathParameters, options) = parseMethodAndPath(function) else { - return nil - } - - return Routes.Endpoint( - method: method, - path: path, - pathParameters: pathParameters, - options: options, - name: function.functionName, - parameters: try function.parameters.compactMap { - EndpointParameter($0, httpMethod: method, pathParameters: pathParameters) - }.validated(), - isAsync: function.isAsync, - isThrows: function.isThrows, - responseType: function.returnType - ) - } - - private static func parseMethodAndPath( - _ function: FunctionDeclSyntax - ) -> (method: String, path: String, pathParameters: [String], options: String?)? { - var method, path, options: String? - for attribute in function.functionAttributes { - if case let .argumentList(list) = attribute.arguments { - let name = attribute.attributeName.trimmedDescription - switch name { - case "GET", "DELETE", "PATCH", "POST", "PUT", "OPTIONS", "HEAD", "TRACE", "CONNECT": - method = name - path = list.first?.expression.description.withoutQuotes - options = list.dropFirst().first?.expression.description.withoutQuotes - case "HTTP": - method = list.first.map { "RAW(value: \($0.expression.description))" } - path = list.dropFirst().first?.expression.description.withoutQuotes - options = list.dropFirst().dropFirst().first?.expression.description.withoutQuotes - default: - continue - } - } - } - - guard let method, let path else { - return nil - } - - return (method, path, path.papyrusPathParameters, options) - } -} - -extension [EndpointParameter] { - fileprivate func validated() throws -> [EndpointParameter] { - let bodies = filter { $0.kind == .body } - let fields = filter { $0.kind == .field } - - guard fields.count == 0 || bodies.count == 0 else { - throw AlchemyMacroError("Can't have Body and Field!") - } - - guard bodies.count <= 1 else { - throw AlchemyMacroError("Can only have one Body!") - } - - return self - } -} - -/// Parsed from function parameters; indicates parts of the request. -struct EndpointParameter { - enum Kind { - case body - case field - case query - case header - case path - } - - let label: String? - let name: String - let type: String - let kind: Kind - let validation: String? - - init(_ parameter: FunctionParameterSyntax, httpMethod: String, pathParameters: [String]) { - self.label = parameter.label - self.name = parameter.name - self.type = parameter.typeName - self.validation = parameter.parameterAttributes - .first { $0.name == "Validate" } - .map { $0.trimmedDescription } - - let attributeNames = parameter.parameterAttributes.map(\.name) - self.kind = - if attributeNames.contains("Path") { .path } - else if attributeNames.contains("Body") { .body } - else if attributeNames.contains("Header") { .header } - else if attributeNames.contains("Field") { .field } - else if attributeNames.contains("URLQuery") { .query } - // if name matches a path param, infer this belongs in path - else if pathParameters.contains(name) { .path } - // if method is GET, HEAD, DELETE, infer query - else if ["GET", "HEAD", "DELETE"].contains(httpMethod) { .query } - // otherwise infer it's a body field - else { .field } - } -} - -extension StructDeclSyntax { - var functions: [FunctionDeclSyntax] { - memberBlock - .members - .compactMap { $0.decl.as(FunctionDeclSyntax.self) } - } -} - -extension FunctionDeclSyntax { - - // MARK: Function effects & attributes - - var functionName: String { - name.text - } - - var parameters: [FunctionParameterSyntax] { - signature - .parameterClause - .parameters - .compactMap { FunctionParameterSyntax($0) } - } - - var functionAttributes: [AttributeSyntax] { - attributes.compactMap { $0.as(AttributeSyntax.self) } - } - - // MARK: Return Data - - var returnType: String? { - signature.returnClause?.type.trimmedDescription - } -} - -extension FunctionParameterSyntax { - var label: String? { - secondName != nil ? firstName.text : nil - } - - var name: String { - (secondName ?? firstName).text - } - - var typeName: String { - trimmed.type.trimmedDescription - } - - var parameterAttributes: [AttributeSyntax] { - attributes.compactMap { $0.as(AttributeSyntax.self) } - } -} - -extension AttributeSyntax { - var name: String { - attributeName.trimmedDescription - } -} - -extension String { - var withoutQuotes: String { - filter { $0 != "\"" } - } - - var inQuotes: String { - "\"\(self)\"" - } - - var inParentheses: String { - "(\(self))" - } - - var papyrusPathParameters: [String] { - components(separatedBy: "/").compactMap(\.extractParameter) - } - - private var extractParameter: String? { - if hasPrefix(":") { - String(dropFirst()) - } else if hasPrefix("{") && hasSuffix("}") { - String(dropFirst().dropLast()) - } else { - nil - } - } -} diff --git a/AlchemyPlugin/Sources/Helpers/String+Utilities.swift b/AlchemyPlugin/Sources/Helpers/String+Utilities.swift index 39e8e5bd..b9921f9e 100644 --- a/AlchemyPlugin/Sources/Helpers/String+Utilities.swift +++ b/AlchemyPlugin/Sources/Helpers/String+Utilities.swift @@ -7,6 +7,18 @@ extension String { var lowercaseFirst: String { prefix(1).lowercased() + dropFirst() } + + var withoutQuotes: String { + filter { $0 != "\"" } + } + + var inQuotes: String { + "\"\(self)\"" + } + + var inParentheses: String { + "(\(self))" + } } extension Collection { diff --git a/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift b/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift index c636bc06..cdf9e01d 100644 --- a/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift +++ b/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift @@ -17,3 +17,61 @@ extension VariableDeclSyntax { }.first ?? "unknown" } } + +extension FunctionDeclSyntax { + + // MARK: Function effects & attributes + + var functionName: String { + name.text + } + + var parameters: [FunctionParameterSyntax] { + signature + .parameterClause + .parameters + .compactMap { FunctionParameterSyntax($0) } + } + + var functionAttributes: [AttributeSyntax] { + attributes.compactMap { $0.as(AttributeSyntax.self) } + } + + var isAsync: Bool { + signature.effectSpecifiers?.asyncSpecifier != nil + } + + var isThrows: Bool { + signature.effectSpecifiers?.throwsSpecifier != nil + } + + // MARK: Return Data + + var returnType: String? { + signature.returnClause?.type.trimmedDescription + } +} + +extension FunctionParameterSyntax { + var label: String? { + secondName != nil ? firstName.text : nil + } + + var name: String { + (secondName ?? firstName).text + } + + var typeName: String { + trimmed.type.trimmedDescription + } + + var parameterAttributes: [AttributeSyntax] { + attributes.compactMap { $0.as(AttributeSyntax.self) } + } +} + +extension AttributeSyntax { + var name: String { + attributeName.trimmedDescription + } +} diff --git a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift index 9e7b516f..d2fa2e7f 100644 --- a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -16,7 +16,7 @@ struct ApplicationMacro: ExtensionMacro { throw AlchemyMacroError("@Application can only be applied to a struct") } - let routes = try Routes.parse(declaration) + let routes = try EndpointGroup.parse(declaration) return try [ Declaration("extension \(`struct`.name.trimmedDescription): Application, Controller") { routes.routeFunction() diff --git a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift index 32b52e61..135b860a 100644 --- a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift @@ -16,7 +16,7 @@ struct ControllerMacro: ExtensionMacro { throw AlchemyMacroError("@Controller can only be applied to a struct") } - let routes = try Routes.parse(declaration) + let routes = try EndpointGroup.parse(declaration) return try [ Declaration("extension \(`struct`.name.trimmedDescription): Controller") { routes.routeFunction() @@ -26,7 +26,7 @@ struct ControllerMacro: ExtensionMacro { } } -extension Routes { +extension EndpointGroup { func routeFunction() -> Declaration { Declaration("func route(_ router: Router)") { for endpoint in endpoints { diff --git a/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift index 479126ca..1eb8c225 100644 --- a/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift +++ b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift @@ -11,7 +11,7 @@ struct HTTPMethodMacro: PeerMacro { throw AlchemyMacroError("@\(node.name) can only be applied to functions") } - guard let endpoint = try Routes.Endpoint.parse(function) else { + guard let endpoint = try Endpoint.parse(function) else { throw AlchemyMacroError("Unable to parse function for @\(node.name)") } @@ -22,9 +22,26 @@ struct HTTPMethodMacro: PeerMacro { } } -extension Routes.Endpoint { +extension Endpoint { fileprivate func routeDeclaration() -> Declaration { - let arguments = parameters + Declaration("var $\(name): Route") { + let options = options.map { "\n options: \($0)," } ?? "" + let closureArgument = parameters.isEmpty ? "_" : "req" + let returnType = responseType ?? "Void" + """ + Route( + method: .\(method), + path: \(path.inQuotes),\(options) + handler: { \(closureArgument) -> \(returnType) in + \(parseExpressionsString) + } + ) + """ + } + } + + fileprivate var argumentsString: String { + parameters .map { parameter in if parameter.type == "Request" { parameter.argumentLabel + "req" @@ -33,7 +50,9 @@ extension Routes.Endpoint { } } .joined(separator: ", ") + } + fileprivate var parseExpressionsString: String { var expressions: [String] = [] for parameter in parameters where parameter.type != "Request" { if let validation = parameter.validation { @@ -45,31 +64,8 @@ extension Routes.Endpoint { } let returnExpression = responseType != nil ? "return " : "" - expressions.append(returnExpression + effectsExpression + name + arguments.inParentheses) - - return Declaration("var $\(name): Route") { - let options = options.map { "\n options: \($0)," } ?? "" - let closureArgument = arguments.isEmpty ? "_" : "req" - let returnType = responseType ?? "Void" - """ - Route( - method: .\(method), - path: \(path.inQuotes),\(options) - handler: { \(closureArgument) -> \(returnType) in - \(expressions.joined(separator: "\n ")) - } - ) - """ - } - } -} - -extension Routes.Endpoint { - fileprivate var routeParametersExpression: String { - [path.inQuotes, options.map { "options: \($0)" }] - .compactMap { $0 } - .joined(separator: ", ") - .inParentheses + expressions.append(returnExpression + effectsExpression + name + argumentsString.inParentheses) + return expressions.joined(separator: "\n ") } fileprivate var effectsExpression: String { diff --git a/AlchemyPlugin/Sources/Macros/JobMacro.swift b/AlchemyPlugin/Sources/Macros/JobMacro.swift index 7147c4f5..b6f8c4e4 100644 --- a/AlchemyPlugin/Sources/Macros/JobMacro.swift +++ b/AlchemyPlugin/Sources/Macros/JobMacro.swift @@ -7,10 +7,7 @@ struct JobMacro: PeerMacro { providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard - let function = declaration.as(FunctionDeclSyntax.self) -// function.isStatic - else { + guard let function = declaration.as(FunctionDeclSyntax.self) else { throw AlchemyMacroError("@Job can only be applied to functions") } @@ -38,29 +35,7 @@ struct JobMacro: PeerMacro { } extension FunctionDeclSyntax { - var isStatic: Bool { - modifiers.map(\.name.text).contains("static") - } - - var isAsync: Bool { - signature.effectSpecifiers?.asyncSpecifier != nil - } - - var isThrows: Bool { - signature.effectSpecifiers?.throwsSpecifier != nil - } - - var jobParametersSignature: String { - parameters.map { - let name = [$0.label, $0.name] - .compactMap { $0 } - .joined(separator: " ") - return "\(name): \($0.type)" - } - .joined(separator: ", ") - } - - var jobPassthroughParameterSyntax: String { + fileprivate var jobPassthroughParameterSyntax: String { parameters.map { let name = [$0.label, $0.name] .compactMap { $0 } @@ -70,7 +45,7 @@ extension FunctionDeclSyntax { .joined(separator: ", ") } - var callPrefixes: [String] { + fileprivate var callPrefixes: [String] { [ isThrows ? "try" : nil, isAsync ? "await" : nil,