diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e4978f70..38bc932d 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 diff --git a/Alchemy/AlchemyMacros.swift b/Alchemy/AlchemyMacros.swift new file mode 100644 index 00000000..f32fb91b --- /dev/null +++ b/Alchemy/AlchemyMacros.swift @@ -0,0 +1,128 @@ +@attached(extension, conformances: Application, Controller, names: named(route)) +public macro Application() = #externalMacro(module: "AlchemyPlugin", type: "ApplicationMacro") + +@attached(extension, conformances: Controller, names: named(route)) +public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "ControllerMacro") + +@attached(peer, names: prefixed(`$`)) +public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro") + +// MARK: Rune - Model + +@attached(memberAttribute) +@attached(member, names: named(storage), named(fieldLookup)) +@attached(extension, conformances: Model, Codable, names: named(init), named(fields), named(encode)) +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 BelongsToMany( + _ pivot: String? = nil, + from: String? = nil, + to: String? = nil, + pivotFrom: String? = nil, + pivotTo: String? = nil +) = #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") +@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") + +// MARK: Route Parameters + +@propertyWrapper public struct Path { + public var wrappedValue: L + + public init(wrappedValue: L) { + self.wrappedValue = wrappedValue + } +} + +@propertyWrapper public struct Header { + public var wrappedValue: L + + public init(wrappedValue: L) { + self.wrappedValue = wrappedValue + } +} + +@propertyWrapper public struct URLQuery { + public var wrappedValue: L + + public init(wrappedValue: L) { + self.wrappedValue = wrappedValue + } +} + +@propertyWrapper public struct Field { + public var wrappedValue: C + + public init(wrappedValue: C) { + self.wrappedValue = wrappedValue + } +} + +@propertyWrapper public struct Body { + public var wrappedValue: C + + public init(wrappedValue: C) { + self.wrappedValue = wrappedValue + } +} + diff --git a/Alchemy/AlchemyX/Application+AlchemyX.swift b/Alchemy/AlchemyX/Application+AlchemyX.swift new file mode 100644 index 00000000..6337ab72 --- /dev/null +++ b/Alchemy/AlchemyX/Application+AlchemyX.swift @@ -0,0 +1,119 @@ +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() } } +} + +@Model +struct Token: TokenAuthable { + var id: UUID + var value: String = UUID().uuidString + let userId: UUID + + @BelongsTo var user: User +} + +@Model +struct User { + var id: UUID + var email: String + var password: String + var phone: String? + + @HasMany var tokens: [Token] + + 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 new file mode 100644 index 00000000..35af0957 --- /dev/null +++ b/Alchemy/AlchemyX/Database+Resource.swift @@ -0,0 +1,180 @@ +import AlchemyX +import Collections + +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) { + let columns = OrderedSet(try await columns(of: table)) + let adds = resourceSchema.keys.subtracting(columns) + let drops = type == .sqlite ? [] : columns.subtracting(resourceSchema.keys) + + guard !adds.isEmpty || !drops.isEmpty else { + Log.info("Resource '\(resource)' is up to date.".green) + return + } + + 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) + } + } + + for drop in drops { + $0.drop(column: drop) + } + } + } else { + Log.info("Creating table \(table)") + try await createTable(table) { + for (column, field) in resourceSchema { + $0.column(column, field: field) + } + } + } + } +} + +extension Resource { + fileprivate static var table: String { + "\(Self.self)".lowercased().pluralized + } + + fileprivate static func schema(keyMapping: KeyMapping) -> OrderedDictionary { + OrderedDictionary( + (fields.values + [.userId]) + .map { (keyMapping.encode($0.name), $0) }, + 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 + } + + fileprivate static var userId: ResourceField { + .init("userId", type: UUID.self) + } +} + +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 columns(of table: String) async throws -> [String] { + switch type { + case .sqlite: + 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") + } + } +} diff --git a/Alchemy/AlchemyX/Router+Papyrus.swift b/Alchemy/AlchemyX/Router+Papyrus.swift new file mode 100644 index 00000000..78c94d6e --- /dev/null +++ b/Alchemy/AlchemyX/Router+Papyrus.swift @@ -0,0 +1,52 @@ +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) { 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( + 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/AlchemyX/Router+Resource.swift b/Alchemy/AlchemyX/Router+Resource.swift new file mode 100644 index 00000000..b3c5f398 --- /dev/null +++ b/Alchemy/AlchemyX/Router+Resource.swift @@ -0,0 +1,158 @@ +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( + _ 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 + .use(Token.tokenAuthMiddleware()) + .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 + .ownedBy(req.user) + .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) + 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 { + 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?) throws -> Query { + try table.where("id" == id).ownedBy(req.user) + } +} + +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 + case .lessThan: .lessThan + case .lessThanEquals: .lessThanOrEqualTo + case .notEquals: .notEqualTo + } + + let field = keyMapping.encode(filter.field) + let value = filter.op == .contains ? "%\(filter.value)%" : filter.value + return `where`(field, op, value) + } + + fileprivate func sort(_ sort: QueryParameters.Sort, keyMapping: KeyMapping) -> Self { + let field = keyMapping.encode(sort.field) + 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/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..ff560504 100644 --- a/Alchemy/Application/Application.swift +++ b/Alchemy/Application/Application.swift @@ -1,110 +1,112 @@ -/// 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 -/// struct App: Application { -/// func boot() { -/// get("/hello") { _ in -/// "Hello, world!" -/// } +/// @Application +/// struct App { +/// +/// @GET("/hello") +/// func sayHello(name: String) -> String { +/// "Hello, \(name)!" /// } /// } /// 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 } +// 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 filesystem configuration of the application. - var filesystems: Filesystems { get } + func boot() throws { + // + } - /// The loggers of you application. - var loggers: Loggers { get } + func bootRouter() { + (self as? Controller)?.route(self) + } - /// The queue configuration of the application. - var queues: Queues { get } + // 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() + bootRouter() 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 +114,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 +126,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 +135,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/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/Auth/TokenAuthable.swift b/Alchemy/Auth/TokenAuthable.swift index 60575525..65639edc 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 @@ -40,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: Relation /// 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 } } @@ -79,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) @@ -88,7 +83,7 @@ public struct TokenAuthMiddleware: Middleware { return try await next( request .set(model) - .set(model.user()) + .set(model.user) ) } } 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/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/Database/Database.swift b/Alchemy/Database/Database.swift index a8054b09..dc3c698c 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 @@ -19,7 +22,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 +80,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 +93,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 +117,33 @@ 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") + } + } + } +} + +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/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/Migrations/Database+Migration.swift b/Alchemy/Database/Migrations/Database+Migration.swift index 11830033..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 @@ -37,7 +38,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 +65,7 @@ extension Database { return } + Log.info("Running migrations.") let lastBatch = try await getLastBatch() for m in migrations { let start = Date() 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/Providers/MySQL/MySQLDatabaseProvider.swift b/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift index e5f10bc6..ff6222bd 100644 --- a/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift +++ b/Alchemy/Database/Providers/MySQL/MySQLDatabaseProvider.swift @@ -1,9 +1,10 @@ import AsyncKit import NIOSSL 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 +45,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/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/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/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) 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 8097418e..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,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: SQLFields) 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. @@ -178,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.") } @@ -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)) } @@ -220,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.") } @@ -245,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.") } @@ -295,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.") } @@ -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 { @@ -379,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) } } 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..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.first?.column 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 660f89f0..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, SQLRowWriter { +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(.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 { @@ -40,12 +37,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: [String: SQLConvertible] = [:] + private var 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 +49,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. @@ -65,16 +58,16 @@ 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 + defer { writer.fields = [:] } + return writer.fields } // MARK: Encoder func container(keyedBy: Key.Type) -> KeyedEncodingContainer { - KeyedEncodingContainer(_KeyedEncodingContainer(writer: self, codingPath: codingPath)) + KeyedEncodingContainer(_KeyedEncodingContainer(encoder: self, codingPath: codingPath)) } func unkeyedContainer() -> UnkeyedEncodingContainer { @@ -84,17 +77,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..5a82dd1c 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowReader.swift @@ -1,35 +1,48 @@ -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 = .useDefaultKeys, jsonDecoder: 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)) + 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)) + } } - 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) + } + } + + public func require(_ keyPath: KeyPath, at key: String) throws -> D { + try require(D.self, at: 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..28079690 100644 --- a/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift +++ b/Alchemy/Database/Rune/Model/Coding/SQLRowWriter.swift @@ -1,11 +1,41 @@ -public protocol SQLRowWriter { - subscript(_ column: String) -> SQLConvertible? { get set } - mutating func put(json: E, at key: String) throws +public struct SQLRowWriter { + public internal(set) var fields: SQLFields + let keyMapping: KeyMapping + let jsonEncoder: JSONEncoder + + public init(keyMapping: KeyMapping = .useDefaultKeys, jsonEncoder: JSONEncoder = JSONEncoder()) { + self.fields = [:] + self.keyMapping = keyMapping + self.jsonEncoder = jsonEncoder + } + + 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 mutating 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 mutating func put(_ value: ModelProperty, at key: String) throws { + try value.store(key: key, on: &self) + } + + public mutating func put(_ value: some Encodable, at key: String) throws { + if let value = value as? ModelProperty { + try put(value, at: key) + } else { + try put(json: value, at: key) + } } public mutating func put(_ int: F, at key: String) { diff --git a/Alchemy/Database/Rune/Model/Model+CRUD.swift b/Alchemy/Database/Rune/Model/Model+CRUD.swift index eb38394e..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 } @@ -85,8 +85,6 @@ extension Model { throw RuneError.notFound } - self.row = model.row - self.id.value = model.id.value return model } @@ -115,19 +113,21 @@ 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) } @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) } @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 @@ -141,8 +141,6 @@ extension Model { throw RuneError.notFound } - self.row = model.row - self.id.value = model.id.value return model } @@ -174,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. @@ -188,7 +186,8 @@ 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 id = try requireId() + let model = try await Self.require(id, db: db) row = model.row model.mergeCache(self) return model @@ -205,11 +204,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) } } @@ -232,7 +228,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) @@ -244,18 +240,17 @@ 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) } - 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) try await Element.query(on: db) - .where(Element.primaryKey, in: ids) + .where(Element.idKey, in: ids) .update(fields) try await Element.didUpdate(self) } @@ -285,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() } @@ -300,13 +295,9 @@ 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) + .where(Element.idKey, in: byId.keys.array) .get() // Transfer over any loaded relationships. @@ -319,7 +310,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 +320,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..39f0acf0 100644 --- a/Alchemy/Database/Rune/Model/Model.swift +++ b/Alchemy/Database/Rune/Model/Model.swift @@ -3,15 +3,17 @@ 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. -public protocol Model: Identifiable, QueryResult, ModelOrOptional { - /// The type of this object's primary key. - associatedtype PrimaryKey: PrimaryKeyProtocol +/// +/// Use @Model to apply this protocol. +public protocol Model: Identifiable, QueryResult, ModelOrOptional where ID: PrimaryKeyProtocol { + /// The identifier of this model + var id: ID { get nonmutating set } - /// The identifier / primary key of this type. - var id: PK { get 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 -> [String: SQLConvertible] + func fields() throws -> SQLFields /// The database on which this model is saved & queried by default. static var database: Database { get } @@ -21,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]`. @@ -49,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() } @@ -66,6 +68,23 @@ extension Model { public static func on(_ database: Database) -> Query { query(on: database) } + + public func id(_ id: ID) -> Self { + self.id = id + return self + } + + public func requireId() throws -> ID { + guard let id = storage.id else { + throw RuneError("Model had no id!") + } + + return id + } + + public func maybeId() -> ID? { + storage.id + } } extension Model where Self: Codable { @@ -73,13 +92,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..d200ca31 --- /dev/null +++ b/Alchemy/Database/Rune/Model/ModelField.swift @@ -0,0 +1,17 @@ +import Collections + +public struct ModelField: Identifiable { + public var id: String { name } + public let name: String + public let `default`: Any? + + public init(_ name: String, path: KeyPath, default: T? = nil) { + self.name = name + self.default = `default` + } +} + +extension Model { + public typealias Field = ModelField + public typealias FieldLookup = OrderedDictionary, ModelField> +} diff --git a/Alchemy/Database/Rune/Model/ModelProperty.swift b/Alchemy/Database/Rune/Model/ModelProperty.swift index 54ea070b..ad87df10 100644 --- a/Alchemy/Database/Rune/Model/ModelProperty.swift +++ b/Alchemy/Database/Rune/Model/ModelProperty.swift @@ -10,7 +10,7 @@ extension String: ModelProperty { } public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + row.put(sql: self, at: key) } } @@ -20,7 +20,7 @@ extension Bool: ModelProperty { } public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + row.put(sql: self, at: key) } } @@ -30,17 +30,17 @@ extension Float: ModelProperty { } public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + 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) + row.put(sql: self, at: key) } } @@ -71,7 +71,7 @@ extension Date: ModelProperty { } public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + row.put(sql: self, at: key) } } @@ -81,7 +81,7 @@ extension UUID: ModelProperty { } public func store(key: String, on row: inout SQLRowWriter) throws { - row.put(self, at: key) + row.put(sql: self, at: key) } } diff --git a/Alchemy/Database/Rune/Model/ModelStorage.swift b/Alchemy/Database/Rune/Model/ModelStorage.swift new file mode 100644 index 00000000..ed8db75a --- /dev/null +++ b/Alchemy/Database/Rune/Model/ModelStorage.swift @@ -0,0 +1,101 @@ +public final class ModelStorage: Codable, Equatable { + public var id: M.ID? + public var row: SQLRow? { + didSet { + if let id = row?[M.idKey] { + self.id = try? M.ID(value: id) + } + } + } + + public var relationships: [CacheKey: Any] + + public init(id: M.ID? = nil) { + self.id = id + self.row = nil + self.relationships = [:] + } + + // MARK: SQL + + public func write(to writer: inout SQLRowWriter) throws { + if let id { + try writer.put(id, at: M.idKey) + } + } + + public func read(from reader: SQLRowReader) throws { + id = try reader.require(M.ID.self, at: M.idKey) + 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.idKey)) + } + + // 1. encode encodable relationships + for (key, relationship) in relationships { + if let relationship = relationship as? Encodable, let name = key.key { + try container.encode(AnyEncodable(relationship), forKey: .key(name)) + } + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: GenericCodingKey.self) + let key: GenericCodingKey = .key(M.idKey) + if container.contains(key) { + self.id = try container.decode(M.ID.self, forKey: key) + } + + self.row = nil + self.relationships = [:] + } + + // MARK: Equatable + + public static func == (lhs: ModelStorage, rhs: ModelStorage) -> Bool { + lhs.id == rhs.id + } +} + +extension Model { + public typealias Storage = ModelStorage +} + +extension Model { + public internal(set) var row: SQLRow? { + get { storage.row } + nonmutating set { storage.row = newValue } + } + + func mergeCache(_ otherModel: Self) { + storage.relationships = otherModel.storage.relationships + } + + func cache(_ value: To, at key: CacheKey) { + storage.relationships[key] = value + } + + func cached(at key: CacheKey, _ type: To.Type = To.self) throws -> To? { + guard let value = storage.relationships[key] else { + return nil + } + + guard let value = value as? To else { + throw RuneError("Eager load cache type mismatch!") + } + + return value + } + + func cacheExists(_ key: CacheKey) -> Bool { + storage.relationships[key] != nil + } +} diff --git a/Alchemy/Database/Rune/Model/OneOrMany.swift b/Alchemy/Database/Rune/Model/OneOrMany.swift index 4405b0a0..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 { @@ -17,6 +19,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 +30,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/Database/Rune/Model/PK.swift b/Alchemy/Database/Rune/Model/PK.swift deleted file mode 100644 index 9326863f..00000000 --- a/Alchemy/Database/Rune/Model/PK.swift +++ /dev/null @@ -1,142 +0,0 @@ -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 { - var row: SQLRow? - var relationships: [String: Any] - - init() { - self.relationships = [:] - self.row = nil - } - - static var new: ModelStorage { - ModelStorage() - } -} - -extension Model { - public internal(set) var row: SQLRow? { - get { id.storage.row } - nonmutating set { id.storage.row = newValue } - } - - func mergeCache(_ otherModel: Self) { - id.storage.relationships = otherModel.id.storage.relationships - } - - func cache(_ value: To, at key: String) { - id.storage.relationships[key] = value - } - - func cached(at key: String, _ type: To.Type = To.self) throws -> To? { - guard let value = id.storage.relationships[key] else { - return nil - } - - guard let value = value as? To else { - throw RuneError("Eager load cache type mismatch!") - } - - return value - } - - func cacheExists(_ key: String) -> Bool { - id.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/BelongsTo.swift b/Alchemy/Database/Rune/Relations/BelongsTo.swift index 757fffb1..1a2a59bb 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,10 +9,10 @@ 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) + 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 79b1fcd4..3a5d2a41 100644 --- a/Alchemy/Database/Rune/Relations/BelongsToMany.swift +++ b/Alchemy/Database/Rune/Relations/BelongsToMany.swift @@ -1,38 +1,50 @@ +import Collections + extension Model { - public typealias BelongsToMany = BelongsToManyRelation - - public func belongsToMany(_ type: To.Type = To.self, - on db: Database = To.M.database, - 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) + 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 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?) { - 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 + public init( + db: Database = To.M.database, + from: From, + _ pivotTable: String? = nil, + fromKey: String? = nil, + toKey: String? = nil, + pivotFrom: String? = nil, + pivotTo: String? = nil + ) { + let fromKey: SQLKey = .infer(From.idKey).specify(fromKey) + let toKey: SQLKey = .infer(To.M.idKey).specify(toKey) + let pivot: String = pivotTable ?? From.table.singularized + "_" + To.M.table.singularized let pivotFrom: SQLKey = db.inferReferenceKey(From.self).specify(pivotFrom) - let pivotTo: SQLKey = db.inferReferenceKey(M.self).specify(pivotTo) + let pivotTo: SQLKey = db.inferReferenceKey(To.M.self).specify(pivotTo) super.init(db: db, from: from, fromKey: fromKey, toKey: toKey) _through(table: pivot, from: pivotFrom, to: pivotTo) } - public func connect(_ model: M, pivotFields: [String: SQLConvertible] = [:]) async throws { + public func connect(_ model: To.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: [To.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 +55,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: To.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: [To.M], pivotFields: SQLFields = [:]) async throws { let from = try requireFromValue() let tos = try models.map { try (requireToValue($0), $0) } @@ -70,12 +82,12 @@ public class BelongsToManyRelation: Relation { try await connect(notExisting, pivotFields: pivotFields) } - public func replace(_ models: [M], pivotFields: [String: SQLConvertible] = [:]) async throws { + public func replace(_ models: [To.M], pivotFields: SQLFields = [:]) async throws { try await disconnectAll() try await connect(models, pivotFields: pivotFields) } - public func disconnect(_ model: M) async throws { + public func disconnect(_ model: To.M) async throws { let from = try requireFromValue() let to = try requireToValue(model) try await db.table(pivot.table) diff --git a/Alchemy/Database/Rune/Relations/BelongsToThrough.swift b/Alchemy/Database/Rune/Relations/BelongsToThrough.swift index 393a01cc..43ffeb7a 100644 --- a/Alchemy/Database/Rune/Relations/BelongsToThrough.swift +++ b/Alchemy/Database/Rune/Relations/BelongsToThrough.swift @@ -1,22 +1,37 @@ 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) + let from: SQLKey = .infer(From.idKey).specify(throughFromKey) let to: SQLKey = db.inferReferenceKey(To.M.self).specify(throughToKey) let throughReference = db.inferReferenceKey(table) @@ -32,7 +47,7 @@ public final class BelongsToThroughRelation: R @discardableResult public func through(_ model: (some Model).Type, from throughFromKey: String? = nil, to throughToKey: String? = nil) -> 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/EagerLoadable.swift b/Alchemy/Database/Rune/Relations/EagerLoadable.swift index e652c61b..ddae7215 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 key: String? + public let value: String +} + extension EagerLoadable { - public var cacheKey: String { - "\(Self.self)" + public var cacheKey: CacheKey { + CacheKey(key: nil, value: "\(Self.self)") } public var isLoaded: Bool { @@ -71,17 +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(_ relationship: @escaping (Element) -> E) async throws -> Self where E.From == Element { + guard let first else { return 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 22fc7bba..c14c3ee2 100644 --- a/Alchemy/Database/Rune/Relations/HasMany.swift +++ b/Alchemy/Database/Rune/Relations/HasMany.swift @@ -1,36 +1,36 @@ extension Model { - public typealias HasMany = HasManyRelation + 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 HasManyRelation: Relation { - init(db: Database, from: From, fromKey: String?, toKey: String?) { - let fromKey: SQLKey = .infer(From.primaryKey).specify(fromKey) +public class HasManyRelationship: Relationship { + public init(db: Database = To.M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { + 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) } - 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 bc0216e1..ae1d8c9f 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) } @@ -33,7 +43,7 @@ public final class HasManyThroughRelation: Relation = HasOneRelation + public typealias HasOne = HasOneRelationship public func hasOne(_ type: To.Type = To.self, on db: Database = To.M.database, @@ -9,9 +9,9 @@ extension Model { } } -public class HasOneRelation: Relation { - init(db: Database, from: From, fromKey: String?, toKey: String?) { - let fromKey: SQLKey = .infer(From.primaryKey).specify(fromKey) +public class HasOneRelationship: Relationship { + public init(db: Database = To.M.database, from: From, fromKey: String? = nil, toKey: String? = nil) { + 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 3398736b..31ce7571 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) } @@ -33,7 +43,7 @@ public final class HasOneThroughRelation: Rela 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/Relation.swift b/Alchemy/Database/Rune/Relations/Relationship.swift similarity index 86% rename from Alchemy/Database/Rune/Relations/Relation.swift rename to Alchemy/Database/Rune/Relations/Relationship.swift index 1c7c5329..689f1fa5 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 @@ -11,6 +11,9 @@ public class Relation: Query, EagerLoadable { var lookupKey: String var throughs: [Through] + /// Relationships will be encoded at this key. + var key: String? = nil + public override var sql: SQL { sql(for: [from]) } @@ -22,11 +25,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(key: key, value: ([key] + throughKeys + whereKeys).joined(separator: ":")) } public init(db: Database, from: From, fromKey: SQLKey, toKey: SQLKey) { @@ -87,4 +90,9 @@ public class Relation: Query, EagerLoadable { return value } + + public func key(_ key: String) -> Self { + self.key = key + return self + } } 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 60160831..4b29ec28 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,17 +37,16 @@ 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 + public subscript(_ column: String, default default: SQLValue? = nil) -> SQLValue? { + fields[column]?.sqlValue ?? `default` } } 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/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) } } 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/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/Encryption/Encrypted.swift b/Alchemy/Encryption/Encrypted.swift index 01895b96..e4d46cb3 100644 --- a/Alchemy/Encryption/Encrypted.swift +++ b/Alchemy/Encryption/Encrypted.swift @@ -16,6 +16,6 @@ public struct Encrypted: ModelProperty, Codable { public func store(key: String, on row: inout 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/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/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/Filesystem/File.swift b/Alchemy/Filesystem/File.swift index 03824d5b..b534ff50 100644 --- a/Alchemy/Filesystem/File.swift +++ b/Alchemy/Filesystem/File.swift @@ -108,7 +108,7 @@ public struct File: Codable, ResponseConvertible, ModelProperty { 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/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/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/Commands/ServeCommand.swift b/Alchemy/HTTP/Commands/ServeCommand.swift index 53279795..70076432 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() } @@ -75,9 +71,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,31 +117,62 @@ 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) 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 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.method) \(req.path) \(dots.lightBlack) \(finishedAt.elapsedString.lightBlack) \(code)") + } } } @@ -192,3 +220,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/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/Content/Content.swift b/Alchemy/HTTP/Content/Content.swift index 0c678a58..0ea5c58a 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 { @@ -389,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): @@ -437,6 +439,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(sql: SQLValue.bytes(buffer), at: key) + } else { + row.put(sql: SQLValue.null, at: key) + } + case .null: + row.put(sql: SQLValue.null, at: key) + } + } +} + // MARK: Array Extensions extension Array where Element == Content { 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/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/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/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/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/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) } } } diff --git a/Alchemy/Logging/Logger+Utilities.swift b/Alchemy/Logging/Logger+Utilities.swift index 5a558a24..1fddb3c2 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)") + } } } @@ -109,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 : .info + if Environment.isXcode { + return .xcode.withLevel(defaultLevel) } else { - return .debug + return .debug.withLevel(defaultLevel) } }() } 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/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 80c5247a..5d5a6bfc 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. @@ -84,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 { "\(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/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/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/Alchemy/Routing/Controller.swift b/Alchemy/Routing/Controller.swift index 125d0352..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 controller(_ controllers: Controller...) -> Self { - controllers.forEach { - $0.route(group()) + public func use(_ controllers: Controller...) -> Self { + for controller in controllers { + let group = group(middlewares: controller.middlewares) + controller.route(group) } - + return self } } 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/Routing/Router.swift b/Alchemy/Routing/Router.swift index 8ac7fbb5..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 @@ -42,6 +44,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/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/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/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: ", ") + } +} diff --git a/Alchemy/Utilities/GenericCodingKey.swift b/Alchemy/Utilities/GenericCodingKey.swift new file mode 100644 index 00000000..9eec8dbd --- /dev/null +++ b/Alchemy/Utilities/GenericCodingKey.swift @@ -0,0 +1,30 @@ +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) + } +} + +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/Alchemy/Validation/Validate.swift b/Alchemy/Validation/Validate.swift new file mode 100644 index 00000000..86fe74b8 --- /dev/null +++ b/Alchemy/Validation/Validate.swift @@ -0,0 +1,41 @@ +@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) + } + } +} + +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/Alchemy/Validation/Validator.swift b/Alchemy/Validation/Validator.swift new file mode 100644 index 00000000..a8c24e1f --- /dev/null +++ b/Alchemy/Validation/Validator.swift @@ -0,0 +1,36 @@ +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 = { 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 { + guard try await isValid(value) else { + let message = message ?? "Invalid content." + throw ValidationError(message) + } + } +} + +extension Validator { + public static func between(_ range: ClosedRange) -> Validator { + Validator { range.contains($0) } + } +} diff --git a/AlchemyPlugin/Sources/AlchemyPlugin.swift b/AlchemyPlugin/Sources/AlchemyPlugin.swift new file mode 100644 index 00000000..4623c2e2 --- /dev/null +++ b/AlchemyPlugin/Sources/AlchemyPlugin.swift @@ -0,0 +1,28 @@ +#if canImport(SwiftCompilerPlugin) + +import SwiftCompilerPlugin +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, + ] +} + +#endif diff --git a/AlchemyPlugin/Sources/Helpers/AlchemyMacroError.swift b/AlchemyPlugin/Sources/Helpers/AlchemyMacroError.swift new file mode 100644 index 00000000..a9c8b737 --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/AlchemyMacroError.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/Helpers/Declaration.swift b/AlchemyPlugin/Sources/Helpers/Declaration.swift new file mode 100644 index 00000000..f3259e99 --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/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/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/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/Helpers/String+Utilities.swift b/AlchemyPlugin/Sources/Helpers/String+Utilities.swift new file mode 100644 index 00000000..b9921f9e --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/String+Utilities.swift @@ -0,0 +1,28 @@ +extension String { + // Need this since `capitalized` lowercases everything else. + var capitalizeFirst: String { + prefix(1).capitalized + dropFirst() + } + + var lowercaseFirst: String { + prefix(1).lowercased() + dropFirst() + } + + var withoutQuotes: String { + filter { $0 != "\"" } + } + + var inQuotes: String { + "\"\(self)\"" + } + + var inParentheses: String { + "(\(self))" + } +} + +extension Collection { + var nilIfEmpty: Self? { + isEmpty ? self : nil + } +} diff --git a/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift b/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift new file mode 100644 index 00000000..cdf9e01d --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift @@ -0,0 +1,77 @@ +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" + } +} + +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 new file mode 100644 index 00000000..d2fa2e7f --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -0,0 +1,27 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +struct ApplicationMacro: 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("@Application can only be applied to a struct") + } + + let routes = try EndpointGroup.parse(declaration) + return try [ + Declaration("extension \(`struct`.name.trimmedDescription): Application, Controller") { + routes.routeFunction() + }, + ] + .map { try $0.extensionDeclSyntax() } + } +} diff --git a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift new file mode 100644 index 00000000..135b860a --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift @@ -0,0 +1,37 @@ +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 EndpointGroup.parse(declaration) + return try [ + Declaration("extension \(`struct`.name.trimmedDescription): Controller") { + routes.routeFunction() + }, + ] + .map { try $0.extensionDeclSyntax() } + } +} + +extension EndpointGroup { + func routeFunction() -> Declaration { + Declaration("func route(_ router: Router)") { + for endpoint in endpoints { + "router.use($\(endpoint.name))" + } + } + } +} 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/Macros/HTTPMethodMacro.swift b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift new file mode 100644 index 00000000..1eb8c225 --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift @@ -0,0 +1,105 @@ +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 endpoint = try Endpoint.parse(function) else { + throw AlchemyMacroError("Unable to parse function for @\(node.name)") + } + + return [ + endpoint.routeDeclaration() + ] + .map { $0.declSyntax() } + } +} + +extension Endpoint { + fileprivate func routeDeclaration() -> Declaration { + 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" + } else { + parameter.argumentLabel + parameter.name + } + } + .joined(separator: ", ") + } + + fileprivate var parseExpressionsString: String { + var expressions: [String] = [] + 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()") + } else { + expressions.append("let \(parameter.name) = \(parameter.parseExpression)") + } + } + + let returnExpression = responseType != nil ? "return " : "" + expressions.append(returnExpression + effectsExpression + name + argumentsString.inParentheses) + return expressions.joined(separator: "\n ") + } + + fileprivate var effectsExpression: String { + let effectsExpressions = [ + isThrows ? "try" : nil, + isAsync ? "await" : nil, + ].compactMap { $0 } + + return effectsExpressions.isEmpty ? "" : effectsExpressions.joined(separator: " ") + " " + } +} + +extension EndpointParameter { + fileprivate var argumentLabel: String { + let argumentLabel = label == "_" ? nil : label ?? name + return argumentLabel.map { "\($0): " } ?? "" + } + + fileprivate 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/IDMacro.swift b/AlchemyPlugin/Sources/Macros/IDMacro.swift new file mode 100644 index 00000000..412416cb --- /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 Model.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 new file mode 100644 index 00000000..b6f8c4e4 --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/JobMacro.swift @@ -0,0 +1,55 @@ +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 function = declaration.as(FunctionDeclSyntax.self) else { + 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)" + } + + Declaration("func handle(context: Context) async throws") { + let name = function.name.text + let prefix = function.callPrefixes.isEmpty ? "" : function.callPrefixes.joined(separator: " ") + " " + """ + \(prefix)JobContext.$current + .withValue(context) { + \(prefix)\(name)(\(function.jobPassthroughParameterSyntax)) + } + """ + } + }, + ] + .map { $0.declSyntax() } + } +} + +extension FunctionDeclSyntax { + fileprivate var jobPassthroughParameterSyntax: String { + parameters.map { + let name = [$0.label, $0.name] + .compactMap { $0 } + .joined(separator: " ") + return "\(name): \($0.name)" + } + .joined(separator: ", ") + } + + fileprivate var callPrefixes: [String] { + [ + isThrows ? "try" : nil, + isAsync ? "await" : nil, + ] + .compactMap { $0 } + } +} diff --git a/AlchemyPlugin/Sources/Macros/ModelMacro.swift b/AlchemyPlugin/Sources/Macros/ModelMacro.swift new file mode 100644 index 00000000..2efdc27a --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/ModelMacro.swift @@ -0,0 +1,171 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +struct ModelMacro: MemberMacro, MemberAttributeMacro, ExtensionMacro { + + // MARK: Member Macro + + static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + let resource = try Model.parse(syntax: declaration) + return [ + resource.generateStorage(), + declaration.hasFieldLookupFunction ? nil : resource.generateFieldLookup(), + ] + .compactMap { $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), !member.isStatic 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.") + } + + return ["@ID"] + } + + // 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 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 Model { + + // MARK: Model + + fileprivate func generateStorage() -> Declaration { + let id = idProperty.flatMap(\.defaultValue).map { "id: \($0)" } ?? "" + return Declaration("var storage = Storage(\(id))") + .access(accessLevel == "public" ? "public" : nil) + } + + fileprivate func generateModelInit() -> 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(\\Self.\(property.name), at: \(property.name.inQuotes))" + } + + "try storage.read(from: reader)" + } + .access(accessLevel == "public" ? "public" : nil) + } + + fileprivate func generateFields() -> Declaration { + Declaration("func fields() throws -> SQLFields") { + "var writer = SQLRowWriter(keyMapping: Self.keyMapping, jsonEncoder: Self.jsonEncoder)" + 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 generateFieldLookup() -> Declaration { + 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 + + 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(\\Self.\(property.name), forKey: \(property.name.inQuotes))" + } + } + + "self.storage = try Storage(from: decoder)" + } + .access(accessLevel == "public" ? "public" : nil) + } +} + +extension DeclGroupSyntax { + fileprivate var hasModelInit: Bool { + initializers.map(\.trimmedDescription).contains { $0.contains("init(row: SQLRow)") } + } + + fileprivate var hasDecodeInit: Bool { + initializers.map(\.trimmedDescription).contains { $0.contains("init(from decoder: Decoder)") } + } + + fileprivate var hasEncodeFunction: Bool { + functions.map(\.trimmedDescription).contains { $0.contains("func encode(to encoder: Encoder)") } + } + + fileprivate var hasFieldsFunction: Bool { + functions.map(\.trimmedDescription).contains { $0.contains("func fields() throws -> SQLFields") } + } + + 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 new file mode 100644 index 00000000..cd9852f9 --- /dev/null +++ b/AlchemyPlugin/Sources/Macros/RelationshipMacro.swift @@ -0,0 +1,48 @@ +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).value() + } + """ + ] + } + + // 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") + } + + let arguments = node.arguments.map { "\($0.trimmedDescription)" } ?? "" + return [ + Declaration("var $\(declaration.name): \(node.name)<\(declaration.type)>") { + """ + \(node.name.lowercaseFirst)(\(arguments)) + .key(\(declaration.name.inQuotes)) + """ + } + ] + .map { $0.declSyntax() } + } +} 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/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/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/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 new file mode 100644 index 00000000..642e099b --- /dev/null +++ b/Example/App.swift @@ -0,0 +1,79 @@ +import Alchemy + +@main +@Application +struct App { + func boot() throws { + use(UserController()) + } + + @POST("/hello") + func helloWorld(email: String) -> String { + "Hello, \(email)!" + } + + @GET("/todos") + func getTodos() async throws -> [Todo] { + try await Todo.all() + .with(\.$hasOne.$hasOne) + .with(\.$hasMany) + } + + @Job + static func expensiveWork(name: String) { + print("This is expensive!") + } +} + +@Controller +struct UserController { + @HTTP("FOO", "/bar", options: .stream) + func bar() { + + } + + @GET("/foo") + func foo(field1: String, request: Request) -> Int { + 123 + } +} + +@Model +struct Todo { + var id: Int + let name: String + var isDone = false + var val = UUID() + let tags: [String]? + + @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 { + var databases: Databases { + Databases( + default: "sqlite", + databases: [ + "sqlite": .sqlite(path: "../AlchemyXDemo/Server/test.db").logRawSQL() + ] + ) + } + + var queues: Queues { + Queues( + default: "memory", + queues: [ + "memory": .memory, + ] + ) + } +} diff --git a/Package.swift b/Package.swift index e166070d..3c835fc6 100644 --- a/Package.swift +++ b/Package.swift @@ -1,12 +1,16 @@ -// swift-tools-version:5.8 +// swift-tools-version:5.10 + +import CompilerPluginSupport import PackageDescription let package = Package( name: "alchemy", platforms: [ .macOS(.v13), + .iOS(.v16), ], products: [ + .executable(name: "AlchemyExample", targets: ["AlchemyExample"]), .library(name: "Alchemy", targets: ["Alchemy"]), .library(name: "AlchemyTest", targets: ["AlchemyTest"]), ], @@ -16,6 +20,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"), @@ -27,11 +33,31 @@ 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: [ + + // MARK: Demo + + .executableTarget( + name: "AlchemyExample", + dependencies: [ + .byName(name: "Alchemy"), + ], + path: "Example" + ), + + // MARK: Libraries + .target( name: "Alchemy", dependencies: [ + /// Experimental + + .product(name: "AlchemyX", package: "AlchemyX"), /// Core @@ -59,10 +85,14 @@ let package = Package( /// Internal dependencies "AlchemyC", + "AlchemyPlugin", ], path: "Alchemy" ), - .target(name: "AlchemyC", path: "AlchemyC"), + .target( + name: "AlchemyC", + path: "AlchemyC" + ), .target( name: "AlchemyTest", dependencies: [ @@ -70,6 +100,9 @@ let package = Package( ], path: "AlchemyTest" ), + + // MARK: Tests + .testTarget( name: "Tests", dependencies: [ @@ -78,5 +111,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" + ), ] ) 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..952d2992 100644 --- a/Tests/Auth/Fixtures/TokenModel.swift +++ b/Tests/Auth/Fixtures/TokenModel.swift @@ -1,15 +1,14 @@ 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 - var user: BelongsTo { - belongsTo(from: "user_id") - } + @BelongsTo(from: "user_id") var user: AuthModel struct Migrate: Migration { func up(db: Database) async throws { diff --git a/Tests/Auth/TokenAuthableTests.swift b/Tests/Auth/TokenAuthableTests.swift index acc1603f..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) @@ -11,11 +11,11 @@ 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() - + try await Test.withToken(token.value.uuidString) .get("/user") .assertOk() 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 ae4671bf..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: [String: SQLConvertible] = [ - "id": 1, + let expectedFields: SQLFields = [ "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([ @@ -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..8c67b385 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) @@ -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 {} @@ -127,13 +128,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/RelationshipTests.swift similarity index 62% rename from Tests/Database/Rune/Relations/RelationTests.swift rename to Tests/Database/Rune/Relations/RelationshipTests.swift index 5cc47dcd..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! @@ -29,36 +29,36 @@ 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 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 @@ -126,83 +126,68 @@ 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 users: [User] - var usersOver30: BelongsToMany { - belongsToMany(pivot: UserOrganization.table) + var usersOver30: BelongsToMany<[User]> { + 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? - 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(pivot: UserOrganization.table) - } + @BelongsToMany(UserOrganization.table) var organizations: [Organization] } -private struct Repository: Model, Codable { - var id: PK = .new +@Model +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] } -private struct Workflow: Model, Codable { - var id: PK = .new +@Model +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] } -private struct Job: Model, Codable { - var id: PK = .new +@Model +private struct Job { + var id: Int var workflowId: Int - var workflow: BelongsTo { - belongsTo() - } + @BelongsTo var workflow: Workflow var user: BelongsToThrough { belongsTo() @@ -211,8 +196,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..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() { @@ -63,16 +63,18 @@ final class SQLRowTests: TestCase { } } -struct EverythingModel: Model, Codable, Equatable { +@Model +struct EverythingModel: Equatable { struct Nested: Codable, 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 } - 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..666526fa 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,11 +25,12 @@ 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") { - $0.uuid("id").primary() + $0.increments("id").primary() $0.int("foo").notNull() $0.bool("bar").notNull() } @@ -39,7 +41,7 @@ struct OtherSeedModel: Model, Codable, Seedable { } } - var id: PK = .new(UUID()) + var id: Int let foo: Int let bar: Bool diff --git a/Tests/Encryption/EncryptionTests.swift b/Tests/Encryption/EncryptionTests.swift index e6b9f572..24e8e464 100644 --- a/Tests/Encryption/EncryptionTests.swift +++ b/Tests/Encryption/EncryptionTests.swift @@ -33,14 +33,13 @@ 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() - var writer: SQLRowWriter = fakeWriter + var writer = SQLRowWriter() try encrypted.store(key: "foo", on: &writer) - guard let storedValue = fakeWriter.dict["foo"] as? String else { + guard let storedValue = writer.fields["foo"] as? String else { return XCTFail("a String wasn't stored") } @@ -49,49 +48,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 dict: [String: SQLConvertible] = [:] - - subscript(column: String) -> SQLConvertible? { - get { dict[column] } - set { dict[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/Filesystem/FilesystemTests.swift b/Tests/Filesystem/FilesystemTests.swift index e8f44f17..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: "\\") + let store: Filesystem = .local(root: "\\+https://www.apple.com") _ = try await store.exists("foo") XCTFail("Should throw an error") } catch {} 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/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) 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"