├── .github ├── .codecov.yml ├── CODEOWNERS ├── CONTRIBUTING.md └── workflows │ ├── api-docs.yml │ └── test.yml ├── .gitignore ├── .spi.yml ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── FluentBenchmark │ ├── Exports.swift │ ├── FluentBenchmarker.swift │ ├── SolarSystem │ │ ├── GalacticJurisdiction.swift │ │ ├── Galaxy.swift │ │ ├── Governor.swift │ │ ├── Jurisdiction.swift │ │ ├── Moon.swift │ │ ├── Planet.swift │ │ ├── PlanetTag.swift │ │ ├── SolarSystem.swift │ │ ├── Star.swift │ │ └── Tag.swift │ └── Tests │ │ ├── AggregateTests.swift │ │ ├── ArrayTests.swift │ │ ├── BatchTests.swift │ │ ├── CRUDTests.swift │ │ ├── ChildTests.swift │ │ ├── ChildrenTests.swift │ │ ├── ChunkTests.swift │ │ ├── CodableTests.swift │ │ ├── CompositeIDTests.swift │ │ ├── CompositeRelationTests.swift │ │ ├── EagerLoadTests.swift │ │ ├── EnumTests.swift │ │ ├── FilterTests.swift │ │ ├── GroupTests.swift │ │ ├── IDTests.swift │ │ ├── JoinTests.swift │ │ ├── MiddlewareTests.swift │ │ ├── MigratorTests.swift │ │ ├── ModelTests.swift │ │ ├── OptionalParentTests.swift │ │ ├── PaginationTests.swift │ │ ├── ParentTests.swift │ │ ├── PerformanceTests+Siblings.swift │ │ ├── PerformanceTests.swift │ │ ├── RangeTests.swift │ │ ├── SQLTests.swift │ │ ├── SchemaTests.swift │ │ ├── SetTests.swift │ │ ├── SiblingsTests.swift │ │ ├── SoftDeleteTests.swift │ │ ├── SortTests.swift │ │ ├── TimestampTests.swift │ │ ├── TransactionTests.swift │ │ └── UniqueTests.swift ├── FluentKit │ ├── Concurrency │ │ ├── AsyncMigration.swift │ │ ├── AsyncModelMiddleware.swift │ │ ├── Children+Concurrency.swift │ │ ├── Database+Concurrency.swift │ │ ├── EnumBuilder+Concurrency.swift │ │ ├── Model+Concurrency.swift │ │ ├── ModelResponder+Concurrency.swift │ │ ├── OptionalChild+Concurrency.swift │ │ ├── OptionalParent+Concurrency.swift │ │ ├── Parent+Concurrency.swift │ │ ├── QueryBuilder+Concurrency.swift │ │ ├── Relation+Concurrency.swift │ │ ├── SchemaBuilder+Concurrency.swift │ │ └── Siblings+Concurrency.swift │ ├── Database │ │ ├── Database+Logging.swift │ │ ├── Database.swift │ │ ├── DatabaseID.swift │ │ ├── DatabaseInput.swift │ │ ├── DatabaseOutput.swift │ │ ├── Databases.swift │ │ ├── KeyPrefixingStrategy.swift │ │ └── TransactionControlDatabase.swift │ ├── Docs.docc │ │ ├── Resources │ │ │ └── vapor-fluentkit-logo.svg │ │ ├── index.md │ │ └── theme-settings.json │ ├── Enum │ │ ├── DatabaseEnum.swift │ │ ├── EnumBuilder.swift │ │ ├── EnumMetadata.swift │ │ ├── EnumProperty.swift │ │ └── OptionalEnumProperty.swift │ ├── Exports.swift │ ├── FluentError.swift │ ├── Middleware │ │ ├── ModelMiddleware.swift │ │ └── ModelResponder.swift │ ├── Migration │ │ ├── Migration.swift │ │ ├── MigrationLog.swift │ │ ├── Migrations.swift │ │ └── Migrator.swift │ ├── Model │ │ ├── AnyModel.swift │ │ ├── EagerLoad.swift │ │ ├── Fields+Codable.swift │ │ ├── Fields.swift │ │ ├── MirrorBypass.swift │ │ ├── Model+CRUD.swift │ │ ├── Model.swift │ │ ├── ModelAlias.swift │ │ └── Schema.swift │ ├── Operators │ │ ├── FieldOperators.swift │ │ ├── Operators.swift │ │ ├── ValueOperators+Array.swift │ │ ├── ValueOperators+String.swift │ │ └── ValueOperators.swift │ ├── Properties │ │ ├── Boolean.swift │ │ ├── BooleanPropertyFormat.swift │ │ ├── Children.swift │ │ ├── CompositeChildren.swift │ │ ├── CompositeID.swift │ │ ├── CompositeOptionalChild.swift │ │ ├── CompositeOptionalParent.swift │ │ ├── CompositeParent.swift │ │ ├── Field.swift │ │ ├── FieldKey.swift │ │ ├── Group.swift │ │ ├── ID.swift │ │ ├── OptionalBoolean.swift │ │ ├── OptionalChild.swift │ │ ├── OptionalField.swift │ │ ├── OptionalParent.swift │ │ ├── Parent.swift │ │ ├── Property.swift │ │ ├── Relation.swift │ │ ├── Siblings.swift │ │ ├── Timestamp.swift │ │ └── TimestampFormat.swift │ ├── Query │ │ ├── Builder │ │ │ ├── QueryBuilder+Aggregate.swift │ │ │ ├── QueryBuilder+EagerLoad.swift │ │ │ ├── QueryBuilder+Filter.swift │ │ │ ├── QueryBuilder+Group.swift │ │ │ ├── QueryBuilder+Join+DirectRelations.swift │ │ │ ├── QueryBuilder+Join.swift │ │ │ ├── QueryBuilder+Paginate.swift │ │ │ ├── QueryBuilder+Range.swift │ │ │ ├── QueryBuilder+Set.swift │ │ │ ├── QueryBuilder+Sort.swift │ │ │ └── QueryBuilder.swift │ │ ├── Database │ │ │ ├── DatabaseQuery+Action.swift │ │ │ ├── DatabaseQuery+Aggregate.swift │ │ │ ├── DatabaseQuery+Field.swift │ │ │ ├── DatabaseQuery+Filter.swift │ │ │ ├── DatabaseQuery+Join.swift │ │ │ ├── DatabaseQuery+Range.swift │ │ │ ├── DatabaseQuery+Sort.swift │ │ │ ├── DatabaseQuery+Value.swift │ │ │ └── DatabaseQuery.swift │ │ └── QueryHistory.swift │ ├── Schema │ │ ├── DatabaseSchema.swift │ │ └── SchemaBuilder.swift │ └── Utilities │ │ ├── OptionalType.swift │ │ ├── RandomGeneratable.swift │ │ ├── SomeCodingKey.swift │ │ └── UnsafeMutableTransferBox.swift ├── FluentSQL │ ├── ConverterUtilities.swift │ ├── DatabaseQuery+SQL.swift │ ├── DatabaseSchema+SQL.swift │ ├── Docs.docc │ │ ├── Resources │ │ │ └── vapor-fluentkit-logo.svg │ │ ├── index.md │ │ └── theme-settings.json │ ├── Exports.swift │ ├── SQLDatabase+Model+Concurrency.swift │ ├── SQLDatabase+Model.swift │ ├── SQLJSONColumnPath+Deprecated.swift │ ├── SQLList+Deprecated.swift │ ├── SQLQualifiedTable+Deprecated.swift │ ├── SQLQueryConverter.swift │ └── SQLSchemaConverter.swift └── XCTFluent │ ├── Docs.docc │ ├── Resources │ │ └── vapor-fluentkit-logo.svg │ ├── index.md │ └── theme-settings.json │ ├── DummyDatabase.swift │ └── TestDatabase.swift └── Tests └── FluentKitTests ├── AsyncTests ├── AsyncFilterQueryTests.swift ├── AsyncFluentKitTests.swift └── AsyncQueryBuilderTests.swift ├── CompositeIDTests.swift ├── DummyDatabaseForTestSQLSerializer.swift ├── FilterQueryTests.swift ├── FluentKitTests.swift ├── OptionalEnumQueryTests.swift ├── OptionalFieldQueryTests.swift ├── QueryBuilderTests.swift ├── SQLTests.swift └── TestUtilities.swift /.github/.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | after_n_builds: 1 4 | wait_for_ci: false 5 | require_ci_to_pass: false 6 | comment: 7 | behavior: default 8 | layout: diff, files 9 | require_changes: true 10 | coverage: 11 | status: 12 | patch: 13 | default: 14 | branches: 15 | - ^main$ 16 | informational: true 17 | only_pulls: false 18 | paths: 19 | - ^Sources.* 20 | target: auto 21 | project: 22 | default: 23 | branches: 24 | - ^main$ 25 | informational: true 26 | only_pulls: false 27 | paths: 28 | - ^Sources.* 29 | target: auto 30 | github_checks: 31 | annotations: true 32 | ignore: 33 | - ^Sources/XCTFluent/.* 34 | - ^Sources/FluentBenchmarks/.* 35 | - ^Tests/.* 36 | - ^.build/.* 37 | slack_app: false 38 | 39 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gwynne 2 | /.github/CONTRIBUTING.md @gwynne @0xTim 3 | /.github/workflows/*.yml @gwynne @0xTim 4 | /.github/workflows/test.yml @gwynne 5 | /.spi.yml @gwynne @0xTim 6 | /.gitignore @gwynne @0xTim 7 | /LICENSE @gwynne @0xTim 8 | /README.md @gwynne @0xTim 9 | -------------------------------------------------------------------------------- /.github/workflows/api-docs.yml: -------------------------------------------------------------------------------- 1 | name: deploy-api-docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build-and-deploy: 9 | uses: vapor/api-docs/.github/workflows/build-and-deploy-docs-workflow.yml@main 10 | secrets: inherit 11 | with: 12 | package_name: fluent-kit 13 | modules: FluentKit,FluentSQL,XCTFluent 14 | pathsToInvalidate: /fluentkit/* /fluentsql/* /xctfluent/* 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .DS_Store 4 | *.xcodeproj 5 | Package.pins 6 | Package.resolved 7 | DerivedData/ 8 | .swiftpm 9 | Tests/LinuxMain.swift 10 | .vscode 11 | 12 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | metadata: 3 | authors: "Maintained by the Vapor Core Team with hundreds of contributions from the Vapor Community." 4 | external_links: 5 | documentation: "https://api.vapor.codes/fluentkit/documentation/fluentkit/" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Qutheory, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "fluent-kit", 6 | platforms: [ 7 | .macOS(.v10_15), 8 | .iOS(.v13), 9 | .watchOS(.v6), 10 | .tvOS(.v13), 11 | ], 12 | products: [ 13 | .library(name: "FluentKit", targets: ["FluentKit"]), 14 | .library(name: "FluentBenchmark", targets: ["FluentBenchmark"]), 15 | .library(name: "FluentSQL", targets: ["FluentSQL"]), 16 | .library(name: "XCTFluent", targets: ["XCTFluent"]), 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), 20 | .package(url: "https://github.com/apple/swift-log.git", from: "1.6.3"), 21 | .package(url: "https://github.com/vapor/sql-kit.git", from: "3.32.0"), 22 | ], 23 | targets: [ 24 | .target( 25 | name: "FluentKit", 26 | dependencies: [ 27 | .product(name: "NIO", package: "swift-nio"), 28 | .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), 29 | .product(name: "NIOCore", package: "swift-nio"), 30 | .product(name: "NIOPosix", package: "swift-nio"), 31 | .product(name: "Logging", package: "swift-log"), 32 | .product(name: "SQLKit", package: "sql-kit"), 33 | ], 34 | swiftSettings: swiftSettings 35 | ), 36 | .target( 37 | name: "FluentBenchmark", 38 | dependencies: [ 39 | .target(name: "FluentKit"), 40 | .target(name: "FluentSQL"), 41 | .product(name: "SQLKit", package: "sql-kit"), 42 | .product(name: "SQLKitBenchmark", package: "sql-kit"), 43 | ], 44 | swiftSettings: swiftSettings 45 | ), 46 | .target( 47 | name: "FluentSQL", 48 | dependencies: [ 49 | .product(name: "SQLKit", package: "sql-kit"), 50 | .target(name: "FluentKit"), 51 | ], 52 | swiftSettings: swiftSettings 53 | ), 54 | .target( 55 | name: "XCTFluent", 56 | dependencies: [ 57 | .product(name: "NIOEmbedded", package: "swift-nio"), 58 | .target(name: "FluentKit"), 59 | ], 60 | swiftSettings: swiftSettings 61 | ), 62 | .testTarget( 63 | name: "FluentKitTests", 64 | dependencies: [ 65 | .target(name: "FluentBenchmark"), 66 | .target(name: "FluentSQL"), 67 | .target(name: "XCTFluent"), 68 | ], 69 | swiftSettings: swiftSettings 70 | ), 71 | ] 72 | ) 73 | 74 | var swiftSettings: [SwiftSetting] { [ 75 | .enableUpcomingFeature("ExistentialAny"), 76 | .enableUpcomingFeature("ConciseMagicFile"), 77 | .enableUpcomingFeature("ForwardTrailingClosures"), 78 | .enableUpcomingFeature("DisableOutwardActorInference"), 79 | .enableUpcomingFeature("MemberImportVisibility"), 80 | .enableExperimentalFeature("StrictConcurrency=complete"), 81 | ] } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | FluentKit 3 |
4 |
5 | Documentation 6 | Team Chat 7 | MIT License 8 | Continuous Integration 9 | 10 | Swift 5.10+ 11 |

12 | 13 |
14 | 15 | An Object-Relational Mapper (ORM) for Swift. It allows you to write type safe, database agnostic models and queries. It takes advantage of Swift's type system to provide a powerful, yet easy to use API. 16 | 17 | An example query looks like: 18 | 19 | ```swift 20 | let planets = try await Planet.query(on: database) 21 | .filter(\.$type == .gasGiant) 22 | .sort(\.$name) 23 | .with(\.$star) 24 | .all() 25 | ``` 26 | 27 | For more information, see the [Fluent documentation](https://docs.vapor.codes/fluent/overview/). 28 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Exports.swift: -------------------------------------------------------------------------------- 1 | @_documentation(visibility: internal) @_exported import FluentKit 2 | @_documentation(visibility: internal) @_exported import XCTest 3 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/FluentBenchmarker.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import XCTest 4 | 5 | public final class FluentBenchmarker { 6 | public let databases: Databases 7 | public var database: any Database 8 | 9 | public init(databases: Databases) { 10 | precondition(databases.ids().count >= 2, "FluentBenchmarker Databases instance must have 2 or more registered databases") 11 | 12 | self.databases = databases 13 | self.database = self.databases.database( 14 | logger: .init(label: "codes.vapor.fluent.benchmarker"), 15 | on: self.databases.eventLoopGroup.any() 16 | )! 17 | } 18 | 19 | public func testAll() throws { 20 | try self.testAggregate() 21 | try self.testArray() 22 | try self.testBatch() 23 | try self.testChild() 24 | try self.testChildren() 25 | try self.testCodable() 26 | try self.testChunk() 27 | try self.testCompositeID() 28 | try self.testCRUD() 29 | try self.testEagerLoad() 30 | try self.testEnum() 31 | try self.testFilter() 32 | try self.testGroup() 33 | try self.testID() 34 | try self.testJoin() 35 | try self.testMiddleware() 36 | try self.testMigrator() 37 | try self.testModel() 38 | try self.testOptionalParent() 39 | try self.testPagination() 40 | try self.testParent() 41 | try self.testPerformance() 42 | try self.testRange() 43 | try self.testSchema() 44 | try self.testSet() 45 | try self.testSiblings() 46 | try self.testSoftDelete() 47 | try self.testSort() 48 | try self.testSQL() 49 | try self.testTimestamp() 50 | try self.testTransaction() 51 | try self.testUnique() 52 | } 53 | 54 | // MARK: Utilities 55 | 56 | func runTest( 57 | _ name: String, 58 | _ migrations: [any Migration], 59 | _ test: () throws -> () 60 | ) throws { 61 | try self.runTest(name, migrations, { _ in try test() }) 62 | } 63 | 64 | func runTest( 65 | _ name: String, 66 | _ migrations: [any Migration], 67 | _ test: (any Database) throws -> () 68 | ) throws { 69 | // This re-initialization is required to make the middleware tests work thanks to ridiculous design flaws 70 | self.database = self.databases.database( 71 | logger: .init(label: "codes.vapor.fluent.benchmarker"), 72 | on: self.databases.eventLoopGroup.any() 73 | )! 74 | try self.runTest(name, migrations, on: self.database, test) 75 | } 76 | 77 | func runTest( 78 | _ name: String, 79 | _ migrations: [any Migration], 80 | on database: any Database, 81 | _ test: (any Database) throws -> () 82 | ) throws { 83 | database.logger.notice("Running \(name)...") 84 | 85 | // Prepare migrations. 86 | do { 87 | for migration in migrations { 88 | try migration.prepare(on: database).wait() 89 | } 90 | } catch { 91 | database.logger.error("\(name): Error: \(String(reflecting: error))") 92 | throw error 93 | } 94 | 95 | let result = Result { try test(database) } 96 | 97 | // Revert migrations 98 | do { 99 | for migration in migrations.reversed() { 100 | try migration.revert(on: database).wait() 101 | } 102 | } catch { 103 | // ignore revert errors if the test itself failed 104 | guard case .failure(_) = result else { 105 | database.logger.error("\(name): Error: \(String(reflecting: error))") 106 | throw error 107 | } 108 | } 109 | 110 | if case .failure(let error) = result { 111 | database.logger.error("\(name): Error: \(String(reflecting: error))") 112 | throw error 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/GalacticJurisdiction.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | 5 | public final class GalacticJurisdiction: Model, @unchecked Sendable { 6 | public static let schema = "galaxy_jurisdictions" 7 | 8 | public final class IDValue: Fields, Hashable, @unchecked Sendable { 9 | @Parent(key: "galaxy_id") 10 | public var galaxy: Galaxy 11 | 12 | @Parent(key: "jurisdiction_id") 13 | public var jurisdiction: Jurisdiction 14 | 15 | @Field(key: "rank") 16 | public var rank: Int 17 | 18 | public init() {} 19 | 20 | public convenience init(galaxy: Galaxy, jurisdiction: Jurisdiction, rank: Int) throws { 21 | try self.init(galaxyId: galaxy.requireID(), jurisdictionId: jurisdiction.requireID(), rank: rank) 22 | } 23 | 24 | public init(galaxyId: Galaxy.IDValue, jurisdictionId: Jurisdiction.IDValue, rank: Int) { 25 | self.$galaxy.id = galaxyId 26 | self.$jurisdiction.id = jurisdictionId 27 | self.rank = rank 28 | } 29 | 30 | public static func == (lhs: IDValue, rhs: IDValue) -> Bool { 31 | lhs.$galaxy.id == rhs.$galaxy.id && lhs.$jurisdiction.id == rhs.$jurisdiction.id && lhs.rank == rhs.rank 32 | } 33 | 34 | public func hash(into hasher: inout Hasher) { 35 | hasher.combine(self.$galaxy.id) 36 | hasher.combine(self.$jurisdiction.id) 37 | hasher.combine(self.rank) 38 | } 39 | } 40 | 41 | @CompositeID() 42 | public var id: IDValue? 43 | 44 | public init() {} 45 | 46 | public init(id: IDValue) { 47 | self.id = id 48 | } 49 | } 50 | 51 | public struct GalacticJurisdictionMigration: Migration { 52 | public init() {} 53 | 54 | public func prepare(on database: any Database) -> EventLoopFuture { 55 | database.schema(GalacticJurisdiction.schema) 56 | .field("galaxy_id", .uuid, .required, .references(Galaxy.schema, .id, onDelete: .cascade, onUpdate: .cascade)) 57 | .field("jurisdiction_id", .uuid, .required, .references(Jurisdiction.schema, .id, onDelete: .cascade, onUpdate: .cascade)) 58 | .field("rank", .int, .required) 59 | .compositeIdentifier(over: "galaxy_id", "jurisdiction_id", "rank") 60 | .create() 61 | } 62 | 63 | public func revert(on database: any Database) -> EventLoopFuture { 64 | database.schema(GalacticJurisdiction.schema) 65 | .delete() 66 | } 67 | } 68 | 69 | public struct GalacticJurisdictionSeed: Migration { 70 | public init() {} 71 | 72 | public func prepare(on database: any Database) -> EventLoopFuture { 73 | database.eventLoop.flatSubmit { 74 | Galaxy.query(on: database).all().and( 75 | Jurisdiction.query(on: database).all()) 76 | }.flatMap { galaxies, jurisdictions in 77 | [ 78 | ("Milky Way", "Old", 0), 79 | ("Milky Way", "Corporate", 1), 80 | ("Andromeda", "Military", 0), 81 | ("Andromeda", "Corporate", 1), 82 | ("Andromeda", "None", 2), 83 | ("Pinwheel Galaxy", "Q", 0), 84 | ("Messier 82", "None", 0), 85 | ("Messier 82", "Military", 1), 86 | ] 87 | .reduce(database.eventLoop.makeSucceededVoidFuture()) { future, data in 88 | future.flatMap { 89 | GalacticJurisdiction.init(id: try! .init( 90 | galaxy: galaxies.first(where: { $0.name == data.0 })!, 91 | jurisdiction: jurisdictions.first(where: { $0.title == data.1 })!, 92 | rank: data.2 93 | )).create(on: database) 94 | } 95 | } 96 | } 97 | } 98 | 99 | public func revert(on database: any Database) -> EventLoopFuture { 100 | GalacticJurisdiction.query(on: database).delete() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/Galaxy.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | public final class Galaxy: Model, @unchecked Sendable { 7 | public static let schema = "galaxies" 8 | 9 | @ID 10 | public var id: UUID? 11 | 12 | @Field(key: "name") 13 | public var name: String 14 | 15 | @Children(for: \.$galaxy) 16 | public var stars: [Star] 17 | 18 | @Siblings(through: GalacticJurisdiction.self, from: \.$id.$galaxy, to: \.$id.$jurisdiction) 19 | public var jurisdictions: [Jurisdiction] 20 | 21 | public init() {} 22 | 23 | public init(id: UUID? = nil, name: String) { 24 | self.id = id 25 | self.name = name 26 | } 27 | } 28 | 29 | public struct GalaxyMigration: AsyncMigration { 30 | public init() {} 31 | 32 | public func prepare(on database: any Database) async throws { 33 | try await database.schema("galaxies") 34 | .id() 35 | .field("name", .string, .required) 36 | .create() 37 | } 38 | 39 | public func revert(on database: any Database) async throws { 40 | try await database.schema("galaxies").delete() 41 | } 42 | } 43 | 44 | public struct GalaxySeed: AsyncMigration { 45 | public init() {} 46 | 47 | public func prepare(on database: any Database) async throws { 48 | try await [ 49 | "Andromeda", 50 | "Milky Way", 51 | "Pinwheel Galaxy", 52 | "Messier 82" 53 | ] 54 | .map { Galaxy(name: $0) } 55 | .create(on: database) 56 | } 57 | 58 | public func revert(on database: any Database) async throws { 59 | try await Galaxy.query(on: database).delete() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/Governor.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | public final class Governor: Model, @unchecked Sendable { 7 | public static let schema = "governors" 8 | 9 | @ID(key: .id) 10 | public var id: UUID? 11 | 12 | @Field(key: "name") 13 | public var name: String 14 | 15 | @Parent(key: "planet_id") 16 | public var planet: Planet 17 | 18 | public init() { } 19 | 20 | public init(id: IDValue? = nil, name: String) { 21 | self.id = id 22 | self.name = name 23 | } 24 | 25 | public init(id: IDValue? = nil, name: String, planetId: UUID) { 26 | self.id = id 27 | self.name = name 28 | self.$planet.id = planetId 29 | } 30 | } 31 | 32 | public struct GovernorMigration: Migration { 33 | public func prepare(on database: any Database) -> EventLoopFuture { 34 | database.schema(Governor.schema) 35 | .field(.id, .uuid, .identifier(auto: false), .required) 36 | .field("name", .string, .required) 37 | .field("planet_id", .uuid, .required, .references("planets", "id")) 38 | .unique(on: "planet_id") 39 | .create() 40 | } 41 | 42 | public func revert(on database: any Database) -> EventLoopFuture { 43 | database.schema(Governor.schema).delete() 44 | } 45 | } 46 | 47 | public struct GovernorSeed: Migration { 48 | public init() { } 49 | 50 | public func prepare(on database: any Database) -> EventLoopFuture { 51 | Planet.query(on: database).all().flatMap { planets in 52 | .andAllSucceed(planets.map { planet in 53 | let governor: Governor? 54 | switch planet.name { 55 | case "Mars": 56 | governor = .init(name: "John Doe") 57 | case "Earth": 58 | governor = .init(name: "Jane Doe") 59 | default: 60 | return database.eventLoop.makeSucceededVoidFuture() 61 | } 62 | return planet.$governor.create(governor!, on: database) 63 | }, on: database.eventLoop) 64 | } 65 | } 66 | 67 | public func revert(on database: any Database) -> EventLoopFuture { 68 | Governor.query(on: database).delete() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/Jurisdiction.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | public final class Jurisdiction: Model, @unchecked Sendable { 7 | public static let schema = "jurisdictions" 8 | 9 | @ID(key: .id) 10 | public var id: UUID? 11 | 12 | @Field(key: "title") 13 | public var title: String 14 | 15 | @Siblings(through: GalacticJurisdiction.self, from: \.$id.$jurisdiction, to: \.$id.$galaxy) 16 | public var galaxies: [Galaxy] 17 | 18 | public init() {} 19 | 20 | public init(id: IDValue? = nil, title: String) { 21 | self.id = id 22 | self.title = title 23 | } 24 | } 25 | 26 | public struct JurisdictionMigration: Migration { 27 | public init() {} 28 | 29 | public func prepare(on database: any Database) -> EventLoopFuture { 30 | database.schema(Jurisdiction.schema) 31 | .field(.id, .uuid, .identifier(auto: false), .required) 32 | .field("title", .string, .required) 33 | .unique(on: "title") 34 | .create() 35 | } 36 | 37 | public func revert(on database: any Database) -> EventLoopFuture { 38 | database.schema(Jurisdiction.schema) 39 | .delete() 40 | } 41 | } 42 | 43 | public struct JurisdictionSeed: Migration { 44 | public init() {} 45 | 46 | public func prepare(on database: any Database) -> EventLoopFuture { 47 | [ 48 | "Old", 49 | "Corporate", 50 | "Military", 51 | "None", 52 | "Q", 53 | ] 54 | .map { Jurisdiction(title: $0) } 55 | .create(on: database) 56 | } 57 | 58 | public func revert(on database: any Database) -> EventLoopFuture { 59 | Jurisdiction.query(on: database) 60 | .delete() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/Moon.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | 5 | public final class Moon: Model, @unchecked Sendable { 6 | public static let schema = "moons" 7 | 8 | @ID(key: .id) 9 | public var id: UUID? 10 | 11 | @Field(key: "name") 12 | public var name: String 13 | 14 | @Field(key: "craters") 15 | public var craters: Int 16 | 17 | @Field(key: "comets") 18 | public var comets: Int 19 | 20 | @Parent(key: "planet_id") 21 | public var planet: Planet 22 | 23 | public init() { } 24 | 25 | public init( 26 | id: IDValue? = nil, 27 | name: String, 28 | craters: Int, 29 | comets: Int 30 | ) { 31 | self.id = id 32 | self.name = name 33 | self.craters = craters 34 | self.comets = comets 35 | } 36 | } 37 | 38 | public struct MoonMigration: Migration { 39 | public init() { } 40 | 41 | public func prepare(on database: any Database) -> EventLoopFuture { 42 | database.schema("moons") 43 | .field("id", .uuid, .identifier(auto: false)) 44 | .field("name", .string, .required) 45 | .field("craters", .int, .required) 46 | .field("comets", .int, .required) 47 | .field("planet_id", .uuid, .required, .references("planets", "id")) 48 | .create() 49 | } 50 | 51 | public func revert(on database: any Database) -> EventLoopFuture { 52 | database.schema("moons").delete() 53 | } 54 | } 55 | 56 | public final class MoonSeed: Migration { 57 | public init() { } 58 | 59 | public func prepare(on database: any Database) -> EventLoopFuture { 60 | Planet.query(on: database).all().flatMap { planets in 61 | .andAllSucceed(planets.map { planet in 62 | let moons: [Moon] 63 | switch planet.name { 64 | case "Earth": 65 | moons = [ 66 | .init(name: "Moon", craters: 10, comets: 10) 67 | ] 68 | case "Mars": 69 | moons = [ 70 | .init(name: "Deimos", craters: 1, comets: 5), 71 | .init(name: "Phobos", craters: 20, comets: 3) 72 | ] 73 | case "Jupiter": 74 | moons = [ 75 | .init(name: "Io", craters: 10, comets: 10), 76 | .init(name: "Europa", craters: 10, comets: 10), 77 | .init(name: "Ganymede", craters: 10, comets: 10), 78 | .init(name: "callisto", craters: 10, comets: 10), 79 | ] 80 | case "Saturn": 81 | moons = [ 82 | .init(name: "Titan", craters: 10, comets: 10), 83 | .init(name: "Prometheus", craters: 10, comets: 10), 84 | .init(name: "Atlas", craters: 9, comets: 8), 85 | .init(name: "Janus", craters: 15, comets: 9) 86 | ] 87 | default: 88 | moons = [] 89 | } 90 | return planet.$moons.create(moons, on: database) 91 | }, on: database.eventLoop) 92 | } 93 | } 94 | 95 | public func revert(on database: any Database) -> EventLoopFuture { 96 | Moon.query(on: database).delete() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/Planet.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | public final class Planet: Model, @unchecked Sendable { 7 | public static let schema = "planets" 8 | 9 | @ID(key: .id) 10 | public var id: UUID? 11 | 12 | @Field(key: "name") 13 | public var name: String 14 | 15 | @Parent(key: "star_id") 16 | public var star: Star 17 | 18 | @OptionalParent(key: "possible_star_id") 19 | public var possibleStar: Star? 20 | 21 | @Children(for: \.$planet) 22 | public var moons: [Moon] 23 | 24 | @OptionalChild(for: \.$planet) 25 | public var governor: Governor? 26 | 27 | @Siblings(through: PlanetTag.self, from: \.$planet, to: \.$tag) 28 | public var tags: [Tag] 29 | 30 | @Timestamp(key: "deleted_at", on: .delete) 31 | var deletedAt: Date? 32 | 33 | public init() { } 34 | 35 | public init(id: IDValue? = nil, name: String) { 36 | self.id = id 37 | self.name = name 38 | } 39 | 40 | public init(id: IDValue? = nil, name: String, starId: UUID) { 41 | self.id = id 42 | self.name = name 43 | self.$star.id = starId 44 | } 45 | } 46 | 47 | public struct PlanetMigration: Migration { 48 | public func prepare(on database: any Database) -> EventLoopFuture { 49 | database.schema("planets") 50 | .field("id", .uuid, .identifier(auto: false)) 51 | .field("name", .string, .required) 52 | .field("star_id", .uuid, .required, .references("stars", "id")) 53 | .field("possible_star_id", .uuid, .references("stars", "id")) 54 | .field("deleted_at", .datetime) 55 | .create() 56 | } 57 | 58 | public func revert(on database: any Database) -> EventLoopFuture { 59 | database.schema("planets").delete() 60 | } 61 | } 62 | 63 | public struct PlanetSeed: Migration { 64 | public init() { } 65 | 66 | public func prepare(on database: any Database) -> EventLoopFuture { 67 | Star.query(on: database).all().flatMap { stars in 68 | .andAllSucceed(stars.map { star in 69 | let planets: [Planet] 70 | switch star.name { 71 | case "Sol": 72 | planets = [ 73 | .init(name: "Mercury"), 74 | .init(name: "Venus"), 75 | .init(name: "Earth"), 76 | .init(name: "Mars"), 77 | .init(name: "Jupiter"), 78 | .init(name: "Saturn"), 79 | .init(name: "Uranus"), 80 | .init(name: "Nepture"), 81 | ] 82 | case "Alpha Centauri": 83 | planets = [ 84 | .init(name: "Proxima Centauri b") 85 | ] 86 | default: 87 | planets = [] 88 | } 89 | return star.$planets.create(planets, on: database) 90 | }, on: database.eventLoop) 91 | } 92 | } 93 | 94 | public func revert(on database: any Database) -> EventLoopFuture { 95 | Planet.query(on: database).delete(force: true) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/PlanetTag.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | public final class PlanetTag: Model, @unchecked Sendable { 7 | public static let schema = "planet+tag" 8 | 9 | @ID(key: .id) 10 | public var id: UUID? 11 | 12 | @Parent(key: "planet_id") 13 | public var planet: Planet 14 | 15 | @Parent(key: "tag_id") 16 | public var tag: Tag 17 | 18 | @OptionalField(key: "comments") 19 | public var comments: String? 20 | 21 | public init() { } 22 | 23 | public init(id: IDValue? = nil, planetID: Planet.IDValue, tagID: Tag.IDValue, comments: String? = nil) { 24 | self.id = id 25 | self.$planet.id = planetID 26 | self.$tag.id = tagID 27 | self.comments = comments 28 | } 29 | } 30 | 31 | public struct PlanetTagMigration: Migration { 32 | public init() { } 33 | 34 | public func prepare(on database: any Database) -> EventLoopFuture { 35 | database.schema(PlanetTag.schema) 36 | .id() 37 | .field("planet_id", .uuid, .required) 38 | .field("tag_id", .uuid, .required) 39 | .field("comments", .string) 40 | .foreignKey("planet_id", references: Planet.schema, .id) 41 | .foreignKey("tag_id", references: Tag.schema, .id) 42 | .create() 43 | } 44 | 45 | public func revert(on database: any Database) -> EventLoopFuture { 46 | database.schema(PlanetTag.schema).delete() 47 | } 48 | } 49 | 50 | public struct PlanetTagSeed: Migration { 51 | public init() { } 52 | 53 | public func prepare(on database: any Database) -> EventLoopFuture { 54 | let planets = Planet.query(on: database).all() 55 | let tags = Tag.query(on: database).all() 56 | return planets.and(tags).flatMap { (planets, tags) in 57 | let inhabited = tags.filter { $0.name == "Inhabited" }.first! 58 | let gasGiant = tags.filter { $0.name == "Gas Giant" }.first! 59 | let smallRocky = tags.filter { $0.name == "Small Rocky" }.first! 60 | 61 | return .andAllSucceed(planets.map { planet in 62 | let tags: [Tag] 63 | switch planet.name { 64 | case "Mercury", "Venus", "Mars", "Proxima Centauri b": 65 | tags = [smallRocky] 66 | case "Earth": 67 | tags = [inhabited, smallRocky] 68 | case "Jupiter", "Saturn", "Uranus", "Neptune": 69 | tags = [gasGiant] 70 | default: 71 | tags = [] 72 | } 73 | return planet.$tags.attach(tags, on: database) 74 | }, on: database.eventLoop) 75 | } 76 | } 77 | 78 | public func revert(on database: any Database) -> EventLoopFuture { 79 | PlanetTag.query(on: database).delete() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/SolarSystem.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import NIOCore 3 | 4 | private let migrations: [any Migration] = [ 5 | GalaxyMigration(), 6 | StarMigration(), 7 | PlanetMigration(), 8 | GovernorMigration(), 9 | MoonMigration(), 10 | TagMigration(), 11 | PlanetTagMigration(), 12 | ] 13 | 14 | private let seeds: [any Migration] = [ 15 | GalaxySeed(), 16 | StarSeed(), 17 | PlanetSeed(), 18 | GovernorSeed(), 19 | MoonSeed(), 20 | TagSeed(), 21 | PlanetTagSeed(), 22 | ] 23 | 24 | public struct SolarSystem: Migration { 25 | let seed: Bool 26 | public init(seed: Bool = true) { 27 | self.seed = seed 28 | } 29 | 30 | public func prepare(on database: any Database) -> EventLoopFuture { 31 | let all: [any Migration] 32 | if self.seed { 33 | all = migrations + seeds 34 | } else { 35 | all = migrations 36 | } 37 | 38 | return all.reduce(database.eventLoop.makeSucceededVoidFuture()) { f, m in f.flatMap { m.prepare(on: database) } } 39 | } 40 | 41 | public func revert(on database: any Database) -> EventLoopFuture { 42 | let all: [any Migration] 43 | if self.seed { 44 | all = migrations + seeds 45 | } else { 46 | all = migrations 47 | } 48 | 49 | return all.reversed().reduce(database.eventLoop.makeSucceededVoidFuture()) { f, m in f.flatMap { m.revert(on: database) } } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/Star.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | public final class Star: Model, @unchecked Sendable { 7 | public static let schema = "stars" 8 | 9 | @ID 10 | public var id: UUID? 11 | 12 | @Field(key: "name") 13 | public var name: String 14 | 15 | @Parent(key: "galaxy_id") 16 | public var galaxy: Galaxy 17 | 18 | @Children(for: \.$star) 19 | public var planets: [Planet] 20 | 21 | @Timestamp(key: "deleted_at", on: .delete) 22 | var deletedAt: Date? 23 | 24 | public init() { } 25 | 26 | public init(id: IDValue? = nil, name: String, galaxyId: Galaxy.IDValue? = nil) { 27 | self.id = id 28 | self.name = name 29 | if let galaxyId { 30 | self.$galaxy.id = galaxyId 31 | } 32 | } 33 | } 34 | 35 | public struct StarMigration: AsyncMigration { 36 | public func prepare(on database: any Database) async throws { 37 | try await database.schema("stars") 38 | .id() 39 | .field("name", .string, .required) 40 | .field("galaxy_id", .uuid, .required, .references("galaxies", "id")) 41 | .field("deleted_at", .datetime) 42 | .create() 43 | } 44 | 45 | public func revert(on database: any Database) async throws { 46 | try await database.schema("stars").delete() 47 | } 48 | } 49 | 50 | public final class StarSeed: AsyncMigration { 51 | public init() {} 52 | 53 | public func prepare(on database: any Database) async throws { 54 | var stars: [Star] = [] 55 | 56 | for galaxy in try await Galaxy.query(on: database).all() { 57 | switch galaxy.name { 58 | case "Milky Way": 59 | stars.append(contentsOf: [ 60 | .init(name: "Sol", galaxyId: galaxy.id!), 61 | .init(name: "Alpha Centauri", galaxyId: galaxy.id!) 62 | ]) 63 | case "Andromeda": 64 | stars.append(.init(name: "Alpheratz", galaxyId: galaxy.id!)) 65 | default: 66 | break 67 | } 68 | } 69 | try await stars.create(on: database) 70 | } 71 | 72 | public func revert(on database: any Database) async throws { 73 | try await Star.query(on: database).delete(force: true) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/SolarSystem/Tag.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | public final class Tag: Model, @unchecked Sendable { 7 | public static let schema = "tags" 8 | 9 | @ID(key: .id) 10 | public var id: UUID? 11 | 12 | @Field(key: "name") 13 | public var name: String 14 | 15 | @Siblings(through: PlanetTag.self, from: \.$tag, to: \.$planet) 16 | public var planets: [Planet] 17 | 18 | public init() { } 19 | 20 | public init(id: UUID? = nil, name: String) { 21 | self.id = id 22 | self.name = name 23 | } 24 | } 25 | 26 | public struct TagMigration: Migration { 27 | public init() { } 28 | 29 | public func prepare(on database: any Database) -> EventLoopFuture { 30 | database.schema("tags") 31 | .field("id", .uuid, .identifier(auto: false)) 32 | .field("name", .string, .required) 33 | .create() 34 | } 35 | 36 | public func revert(on database: any Database) -> EventLoopFuture { 37 | database.schema("tags").delete() 38 | } 39 | } 40 | 41 | public final class TagSeed: Migration { 42 | public init() { } 43 | 44 | public func prepare(on database: any Database) -> EventLoopFuture { 45 | .andAllSucceed([ 46 | "Small Rocky", "Gas Giant", "Inhabited" 47 | ].map { 48 | Tag(name: $0) 49 | .create(on: database) 50 | }, on: database.eventLoop) 51 | } 52 | 53 | public func revert(on database: any Database) -> EventLoopFuture { 54 | Tag.query(on: database).delete() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/AggregateTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import XCTest 3 | 4 | extension FluentBenchmarker { 5 | public func testAggregate(max: Bool = true) throws { 6 | try self.testAggregate_all(max: max) 7 | try self.testAggregate_emptyDatabase(max: max) 8 | } 9 | 10 | private func testAggregate_all(max: Bool) throws { 11 | try self.runTest(#function, [ 12 | SolarSystem() 13 | ]) { 14 | // whole table 15 | let count = try Planet.query(on: self.database) 16 | .count().wait() 17 | XCTAssertEqual(count, 9) 18 | 19 | // filtered w/ results 20 | let filteredCount = try Planet.query(on: self.database) 21 | .filter(\.$name == "Earth") 22 | .count().wait() 23 | XCTAssertEqual(filteredCount, 1) 24 | 25 | // filtered empty 26 | let emptyCount = try Planet.query(on: self.database) 27 | .filter(\.$name == "Pluto") 28 | .count().wait() 29 | XCTAssertEqual(emptyCount, 0) 30 | 31 | // max 32 | if max { 33 | let maxName = try Planet.query(on: self.database) 34 | .max(\.$name).wait() 35 | XCTAssertEqual(maxName, "Venus") 36 | } 37 | 38 | // eager loads ignored 39 | let countWithEagerLoads = try Galaxy.query(on: self.database) 40 | .with(\.$stars) 41 | .count().wait() 42 | XCTAssertEqual(countWithEagerLoads, 4) 43 | 44 | // eager loads ignored again 45 | if max { 46 | let maxNameWithEagerLoads = try Galaxy.query(on: self.database) 47 | .with(\.$stars) 48 | .max(\.$name).wait() 49 | XCTAssertEqual(maxNameWithEagerLoads, "Pinwheel Galaxy") 50 | } 51 | } 52 | } 53 | 54 | private func testAggregate_emptyDatabase(max: Bool) throws { 55 | try self.runTest(#function, [ 56 | SolarSystem(seed: false) 57 | ]) { 58 | // whole table 59 | let count = try Planet.query(on: self.database) 60 | .count().wait() 61 | XCTAssertEqual(count, 0) 62 | 63 | // max 64 | if max { 65 | let maxName = try Planet.query(on: self.database) 66 | .max(\.$name).wait() 67 | // expect error? 68 | XCTAssertNil(maxName) 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/BatchTests.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import XCTest 3 | 4 | extension FluentBenchmarker { 5 | public func testBatch() throws { 6 | try self.testBatch_create() 7 | try self.testBatch_update() 8 | try self.testBatch_delete() 9 | } 10 | 11 | private func testBatch_create() throws { 12 | try runTest(#function, [ 13 | GalaxyMigration() 14 | ]) { 15 | let galaxies = Array("abcdefghijklmnopqrstuvwxyz").map { letter in 16 | return Galaxy(name: .init(letter)) 17 | } 18 | 19 | try galaxies.create(on: self.database).wait() 20 | let count = try Galaxy.query(on: self.database).count().wait() 21 | XCTAssertEqual(count, 26) 22 | } 23 | } 24 | 25 | private func testBatch_update() throws { 26 | try runTest(#function, [ 27 | GalaxyMigration(), 28 | GalaxySeed() 29 | ]) { 30 | try Galaxy.query(on: self.database).set(\.$name, to: "Foo") 31 | .update().wait() 32 | 33 | let galaxies = try Galaxy.query(on: self.database).all().wait() 34 | for galaxy in galaxies { 35 | XCTAssertEqual(galaxy.name, "Foo") 36 | } 37 | } 38 | } 39 | 40 | private func testBatch_delete() throws { 41 | try runTest(#function, [ 42 | GalaxyMigration(), 43 | ]) { 44 | let galaxies = Array("abcdefghijklmnopqrstuvwxyz").map { letter in 45 | return Galaxy(name: .init(letter)) 46 | } 47 | try EventLoopFuture.andAllSucceed(galaxies.map { 48 | $0.create(on: self.database) 49 | }, on: self.database.eventLoop).wait() 50 | 51 | let count = try Galaxy.query(on: self.database).count().wait() 52 | XCTAssertEqual(count, 26) 53 | 54 | try galaxies[..<5].delete(on: self.database).wait() 55 | 56 | let postDeleteCount = try Galaxy.query(on: self.database).count().wait() 57 | XCTAssertEqual(postDeleteCount, 21) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/CRUDTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import NIOCore 3 | import XCTest 4 | 5 | extension FluentBenchmarker { 6 | public func testCRUD() throws { 7 | try self.testCRUD_create() 8 | try self.testCRUD_read() 9 | try self.testCRUD_update() 10 | try self.testCRUD_delete() 11 | } 12 | 13 | private func testCRUD_create() throws { 14 | try self.runTest(#function, [ 15 | GalaxyMigration() 16 | ]) { 17 | let galaxy = Galaxy(name: "Messier") 18 | galaxy.name += " 82" 19 | try! galaxy.save(on: self.database).wait() 20 | XCTAssertNotNil(galaxy.id) 21 | 22 | guard let fetched = try Galaxy.query(on: self.database) 23 | .filter(\.$name == "Messier 82") 24 | .first() 25 | .wait() 26 | else { 27 | XCTFail("unexpected empty result set") 28 | return 29 | } 30 | 31 | if fetched.name != galaxy.name { 32 | XCTFail("unexpected name: \(galaxy) \(fetched)") 33 | } 34 | if fetched.id != galaxy.id { 35 | XCTFail("unexpected id: \(galaxy) \(fetched)") 36 | } 37 | } 38 | } 39 | 40 | private func testCRUD_read() throws { 41 | try runTest(#function, [ 42 | GalaxyMigration(), 43 | GalaxySeed() 44 | ]) { 45 | guard let milkyWay = try Galaxy.query(on: self.database) 46 | .filter(\.$name == "Milky Way") 47 | .first().wait() 48 | else { 49 | XCTFail("unpexected missing galaxy") 50 | return 51 | } 52 | guard milkyWay.name == "Milky Way" else { 53 | XCTFail("unexpected name") 54 | return 55 | } 56 | } 57 | } 58 | 59 | private func testCRUD_update() throws { 60 | try runTest(#function, [ 61 | GalaxyMigration() 62 | ]) { 63 | let galaxy = Galaxy(name: "Milkey Way") 64 | try galaxy.save(on: self.database).wait() 65 | galaxy.name = "Milky Way" 66 | try galaxy.save(on: self.database).wait() 67 | // Test save without changes. 68 | try galaxy.save(on: self.database).wait() 69 | 70 | // verify 71 | let galaxies = try Galaxy.query(on: self.database).filter(\.$name == "Milky Way").all().wait() 72 | guard galaxies.count == 1 else { 73 | XCTFail("unexpected galaxy count: \(galaxies)") 74 | return 75 | } 76 | guard galaxies[0].name == "Milky Way" else { 77 | XCTFail("unexpected galaxy name") 78 | return 79 | } 80 | } 81 | } 82 | 83 | private func testCRUD_delete() throws { 84 | try runTest(#function, [ 85 | GalaxyMigration(), 86 | ]) { 87 | let galaxy = Galaxy(name: "Milky Way") 88 | try galaxy.save(on: self.database).wait() 89 | try galaxy.delete(on: self.database).wait() 90 | 91 | // verify 92 | let galaxies = try Galaxy.query(on: self.database).all().wait() 93 | guard galaxies.count == 0 else { 94 | XCTFail("unexpected galaxy count: \(galaxies)") 95 | return 96 | } 97 | } 98 | } 99 | 100 | public func testAsyncCreate() throws { 101 | try runTest(#function, [ 102 | GalaxyMigration() 103 | ]) { 104 | let a = Galaxy(name: "a") 105 | let b = Galaxy(name: "b") 106 | _ = try a.save(on: self.database).and(b.save(on: self.database)).wait() 107 | let galaxies = try Galaxy.query(on: self.database).all().wait() 108 | guard galaxies.count == 2 else { 109 | XCTFail("both galaxies did not save") 110 | return 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/ChildrenTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | extension FluentBenchmarker { 7 | public func testChildren() throws { 8 | try self.testChildren_with() 9 | } 10 | 11 | private func testChildren_with() throws { 12 | try self.runTest(#function, [ 13 | FooMigration(), 14 | BarMigration(), 15 | BazMigration() 16 | ]) { 17 | let foo = Foo(name: "a") 18 | try foo.save(on: self.database).wait() 19 | let bar = Bar(bar: 42, fooID: foo.id!) 20 | try bar.save(on: self.database).wait() 21 | let baz = Baz(baz: 3.14, fooID: foo.id!) 22 | try baz.save(on: self.database).wait() 23 | 24 | let foos = try Foo.query(on: self.database) 25 | .with(\.$bars) 26 | .with(\.$bazs) 27 | .all().wait() 28 | 29 | for foo in foos { 30 | XCTAssertEqual(foo.bars[0].bar, 42) 31 | XCTAssertEqual(foo.bazs[0].baz, 3.14) 32 | } 33 | } 34 | } 35 | } 36 | 37 | 38 | private final class Foo: Model, @unchecked Sendable { 39 | static let schema = "foos" 40 | 41 | @ID(key: .id) 42 | var id: UUID? 43 | 44 | @Field(key: "name") 45 | var name: String 46 | 47 | @Children(for: \.$foo) 48 | var bars: [Bar] 49 | 50 | @Children(for: \.$foo) 51 | var bazs: [Baz] 52 | 53 | init() { } 54 | 55 | init(id: IDValue? = nil, name: String) { 56 | self.id = id 57 | self.name = name 58 | } 59 | } 60 | 61 | private struct FooMigration: Migration { 62 | func prepare(on database: any Database) -> EventLoopFuture { 63 | database.schema("foos") 64 | .field("id", .uuid, .identifier(auto: false)) 65 | .field("name", .string, .required) 66 | .create() 67 | } 68 | 69 | func revert(on database: any Database) -> EventLoopFuture { 70 | database.schema("foos").delete() 71 | } 72 | } 73 | 74 | private final class Bar: Model, @unchecked Sendable { 75 | static let schema = "bars" 76 | 77 | @ID(key: .id) 78 | var id: UUID? 79 | 80 | @Field(key: "bar") 81 | var bar: Int 82 | 83 | @Parent(key: "foo_id") 84 | var foo: Foo 85 | 86 | init() { } 87 | 88 | init(id: IDValue? = nil, bar: Int, fooID: Foo.IDValue) { 89 | self.id = id 90 | self.bar = bar 91 | self.$foo.id = fooID 92 | } 93 | } 94 | 95 | private struct BarMigration: Migration { 96 | func prepare(on database: any Database) -> EventLoopFuture { 97 | database.schema("bars") 98 | .field("id", .uuid, .identifier(auto: false)) 99 | .field("bar", .int, .required) 100 | .field("foo_id", .uuid, .required) 101 | .create() 102 | } 103 | 104 | func revert(on database: any Database) -> EventLoopFuture { 105 | database.schema("bars").delete() 106 | } 107 | } 108 | 109 | private final class Baz: Model, @unchecked Sendable { 110 | static let schema = "bazs" 111 | 112 | @ID(key: .id) 113 | var id: UUID? 114 | 115 | @Field(key: "baz") 116 | var baz: Double 117 | 118 | @Parent(key: "foo_id") 119 | var foo: Foo 120 | 121 | init() { } 122 | 123 | init(id: IDValue? = nil, baz: Double, fooID: Foo.IDValue) { 124 | self.id = id 125 | self.baz = baz 126 | self.$foo.id = fooID 127 | } 128 | } 129 | 130 | private struct BazMigration: Migration { 131 | func prepare(on database: any Database) -> EventLoopFuture { 132 | database.schema("bazs") 133 | .field("id", .uuid, .identifier(auto: false)) 134 | .field("baz", .double, .required) 135 | .field("foo_id", .uuid, .required) 136 | .create() 137 | } 138 | 139 | func revert(on database: any Database) -> EventLoopFuture { 140 | database.schema("bazs").delete() 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/ChunkTests.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import NIOConcurrencyHelpers 3 | import XCTest 4 | 5 | extension FluentBenchmarker { 6 | public func testChunk() throws { 7 | try self.testChunk_fetch() 8 | } 9 | 10 | private func testChunk_fetch() throws { 11 | try runTest(#function, [ 12 | GalaxyMigration(), 13 | ]) { 14 | 15 | let saves = (1...512).map { i -> EventLoopFuture in 16 | return Galaxy(name: "Milky Way \(i)") 17 | .save(on: self.database) 18 | } 19 | try EventLoopFuture.andAllSucceed(saves, on: self.database.eventLoop).wait() 20 | 21 | let fetched64 = NIOLockedValueBox(0) 22 | 23 | try Galaxy.query(on: self.database).chunk(max: 64) { chunk in 24 | guard chunk.count == 64 else { 25 | XCTFail("bad chunk count") 26 | return 27 | } 28 | fetched64.withLockedValue { $0 += chunk.count } 29 | }.wait() 30 | 31 | guard fetched64.withLockedValue({ $0 }) == 512 else { 32 | XCTFail("did not fetch all - only \(fetched64.withLockedValue { $0 }) out of 512") 33 | return 34 | } 35 | 36 | let fetched511 = NIOLockedValueBox(0) 37 | 38 | try Galaxy.query(on: self.database).chunk(max: 511) { chunk in 39 | guard chunk.count == 511 || chunk.count == 1 else { 40 | XCTFail("bad chunk count") 41 | return 42 | } 43 | fetched511.withLockedValue { $0 += chunk.count } 44 | }.wait() 45 | 46 | guard fetched511.withLockedValue({ $0 }) == 512 else { 47 | XCTFail("did not fetch all - only \(fetched511.withLockedValue { $0 }) out of 512") 48 | return 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/CodableTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | extension FluentBenchmarker { 7 | public func testCodable() throws { 8 | try self.testCodable_decodeError() 9 | } 10 | 11 | private func testCodable_decodeError() throws { 12 | let json = """ 13 | { 14 | "title": "Test Question", 15 | "body": "Test Question Body", 16 | "answer": "Test Answer", 17 | "project": "TestProject" 18 | } 19 | """ 20 | 21 | do { 22 | _ = try JSONDecoder().decode(Question.self, from: .init(json.utf8)) 23 | XCTFail("expected error") 24 | } catch DecodingError.typeMismatch(let type, let context) { 25 | XCTAssertEqual(ObjectIdentifier(type), ObjectIdentifier(Project.self)) 26 | XCTAssertEqual(context.codingPath.map(\.stringValue), ["project"]) 27 | } 28 | } 29 | } 30 | 31 | final class Question: Model, @unchecked Sendable { 32 | static let schema = "questions" 33 | 34 | @ID(custom: "id") 35 | var id: Int? 36 | 37 | @Field(key: "title") 38 | var title: String 39 | 40 | @Field(key: "body") 41 | var body: String 42 | 43 | @Field(key: "answer") 44 | var answer: String 45 | 46 | @Parent(key: "project_id") 47 | var project: Project 48 | 49 | init() { } 50 | 51 | init(id: Int? = nil, title: String, body: String = "", answer: String = "", projectId: Project.IDValue) { 52 | self.id = id 53 | self.title = title 54 | self.body = body 55 | self.answer = answer 56 | self.$project.id = projectId 57 | } 58 | } 59 | 60 | final class Project: Model, @unchecked Sendable { 61 | static let schema = "projects" 62 | 63 | @ID(custom: "id") 64 | var id: String? 65 | 66 | @Field(key: "title") 67 | var title: String 68 | 69 | @Field(key: "links") 70 | var links: [String] 71 | 72 | @Children(for: \.$project) 73 | var questions: [Question] 74 | 75 | init() { } 76 | 77 | init(id: String, title: String, links: [String] = []) { 78 | self.id = id 79 | self.title = title 80 | self.links = links 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/PaginationTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import XCTest 3 | 4 | extension FluentBenchmarker { 5 | public func testPagination() throws { 6 | try self.runTest(#function, [ 7 | SolarSystem() 8 | ]) { 9 | do { 10 | let planetsPage1 = try Planet.query(on: self.database) 11 | .sort(\.$name) 12 | .paginate(PageRequest(page: 1, per: 2)) 13 | .wait() 14 | 15 | XCTAssertEqual(planetsPage1.metadata.page, 1) 16 | XCTAssertEqual(planetsPage1.metadata.per, 2) 17 | XCTAssertEqual(planetsPage1.metadata.total, 9) 18 | XCTAssertEqual(planetsPage1.metadata.pageCount, 5) 19 | XCTAssertEqual(planetsPage1.items.count, 2) 20 | XCTAssertEqual(planetsPage1.items.dropFirst(0).first?.name, "Earth") 21 | XCTAssertEqual(planetsPage1.items.dropFirst(1).first?.name, "Jupiter") 22 | } 23 | do { 24 | let planetsPage2 = try Planet.query(on: self.database) 25 | .sort(\.$name) 26 | .paginate(PageRequest(page: 2, per: 2)) 27 | .wait() 28 | 29 | XCTAssertEqual(planetsPage2.metadata.page, 2) 30 | XCTAssertEqual(planetsPage2.metadata.per, 2) 31 | XCTAssertEqual(planetsPage2.metadata.total, 9) 32 | XCTAssertEqual(planetsPage2.metadata.pageCount, 5) 33 | XCTAssertEqual(planetsPage2.items.count, 2) 34 | XCTAssertEqual(planetsPage2.items.dropFirst(0).first?.name, "Mars") 35 | XCTAssertEqual(planetsPage2.items.dropFirst(1).first?.name, "Mercury") 36 | } 37 | do { 38 | let galaxiesPage = try Galaxy.query(on: self.database) 39 | .filter(\.$name == "Milky Way") 40 | .with(\.$stars) 41 | .sort(\.$name) 42 | .paginate(PageRequest(page: 1, per: 1)) 43 | .wait() 44 | 45 | XCTAssertEqual(galaxiesPage.metadata.page, 1) 46 | XCTAssertEqual(galaxiesPage.metadata.per, 1) 47 | 48 | let milkyWay = galaxiesPage.items.first 49 | XCTAssertEqual(milkyWay?.name, "Milky Way") 50 | XCTAssertEqual(milkyWay?.stars.count, 2) 51 | } 52 | } 53 | } 54 | 55 | public func testPaginationDoesntCrashWithInvalidValues() throws { 56 | try self.runTest(#function, [ 57 | SolarSystem() 58 | ]) { 59 | do { 60 | _ = try Planet.query(on: self.database) 61 | .sort(\.$name) 62 | .paginate(PageRequest(page: -1, per: 2)) 63 | .wait() 64 | } 65 | do { 66 | _ = try Planet.query(on: self.database) 67 | .sort(\.$name) 68 | .paginate(PageRequest(page: 2, per: -2)) 69 | .wait() 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/ParentTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | extension FluentBenchmarker { 7 | public func testParent() throws { 8 | try self.testParent_serialization() 9 | try self.testParent_get() 10 | try self.testParent_value() 11 | } 12 | 13 | private func testParent_serialization() throws { 14 | try self.runTest(#function, [ 15 | SolarSystem() 16 | ]) { 17 | let stars = try Star.query(on: self.database).all().wait() 18 | 19 | let encoded = try JSONEncoder().encode(stars) 20 | self.database.logger.trace("\(String(decoding: encoded, as: UTF8.self)))") 21 | let decoded = try JSONDecoder().decode([StarJSON].self, from: encoded) 22 | XCTAssertEqual(stars.map { $0.id }, decoded.map { $0.id }) 23 | XCTAssertEqual(stars.map { $0.name }, decoded.map { $0.name }) 24 | XCTAssertEqual(stars.map { $0.$galaxy.id }, decoded.map { $0.galaxy.id }) 25 | } 26 | } 27 | 28 | private func testParent_get() throws { 29 | try self.runTest(#function, [ 30 | SolarSystem() 31 | ]) { 32 | let planets = try Planet.query(on: self.database) 33 | .all().wait() 34 | 35 | for planet in planets { 36 | let star = try planet.$star.get(on: self.database).wait() 37 | switch planet.name { 38 | case "Earth", "Jupiter": 39 | XCTAssertEqual(star.name, "Sol") 40 | case "Proxima Centauri b": 41 | XCTAssertEqual(star.name, "Alpha Centauri") 42 | default: break 43 | } 44 | } 45 | } 46 | } 47 | 48 | private func testParent_value() throws { 49 | try runTest(#function, [ 50 | SolarSystem() 51 | ]) { 52 | guard let earth = try Planet.query(on: self.database) 53 | .filter(\.$name == "Earth") 54 | .first().wait() 55 | else { 56 | XCTFail("Could not load Planet earth") 57 | return 58 | } 59 | 60 | // test loading relation manually 61 | XCTAssertNil(earth.$star.value) 62 | try earth.$star.load(on: self.database).wait() 63 | XCTAssertNotNil(earth.$star.value) 64 | XCTAssertEqual(earth.star.name, "Sol") 65 | 66 | let test = Star(name: "Foo") 67 | earth.$star.value = test 68 | XCTAssertEqual(earth.star.name, "Foo") 69 | // test get uses cached value 70 | try XCTAssertEqual(earth.$star.get(on: self.database).wait().name, "Foo") 71 | // test get can reload relation 72 | try XCTAssertEqual(earth.$star.get(reload: true, on: self.database).wait().name, "Sol") 73 | 74 | // test clearing loaded relation 75 | earth.$star.value = nil 76 | XCTAssertNil(earth.$star.value) 77 | 78 | // test get loads relation if nil 79 | try XCTAssertEqual(earth.$star.get(on: self.database).wait().name, "Sol") 80 | } 81 | } 82 | } 83 | 84 | private struct StarJSON: Codable { 85 | var id: UUID 86 | var name: String 87 | struct GalaxyJSON: Codable { var id: UUID } 88 | var galaxy: GalaxyJSON 89 | } 90 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/PerformanceTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | #if !canImport(Darwin) 3 | @preconcurrency import Foundation 4 | #else 5 | import Foundation 6 | #endif 7 | import NIOCore 8 | import XCTest 9 | 10 | extension FluentBenchmarker { 11 | public func testPerformance(decimalType: DatabaseSchema.DataType = .string) throws { 12 | try self.testPerformance_largeModel(decimalType: decimalType) 13 | try self.testPerformance_siblings() 14 | } 15 | 16 | private func testPerformance_largeModel(decimalType: DatabaseSchema.DataType) throws { 17 | try runTest(#function, [ 18 | FooMigration(decimalType: decimalType) 19 | ]) { 20 | for _ in 0..<100 { 21 | let foo = Foo( 22 | bar: 42, 23 | baz: 3.14159, 24 | qux: "foobar", 25 | quux: .init(), 26 | quuz: 2.71828, 27 | corge: [1, 2, 3], 28 | grault: [4, 5, 6], 29 | garply: ["foo", "bar", "baz"], 30 | fred: 1.4142135623730950, 31 | plugh: 1337, 32 | xyzzy: 9.94987437106, 33 | thud: .init(foo: 5, bar: 23, baz: "1994") 34 | ) 35 | try foo.save(on: self.database).wait() 36 | } 37 | let foos = try Foo.query(on: self.database).all().wait() 38 | for foo in foos { 39 | XCTAssertNotNil(foo.id) 40 | } 41 | XCTAssertEqual(foos.count, 100) 42 | } 43 | } 44 | } 45 | 46 | private final class Foo: Model, @unchecked Sendable { 47 | static let schema = "foos" 48 | 49 | struct Thud: Codable { 50 | var foo: Int 51 | var bar: Double 52 | var baz: String 53 | } 54 | 55 | @ID(key: .id) var id: UUID? 56 | @Field(key: "bar") var bar: Int 57 | @Field(key: "baz") var baz: Double 58 | @Field(key: "qux") var qux: String 59 | @Field(key: "quux") var quux: Date 60 | @Field(key: "quuz") var quuz: Float 61 | @Field(key: "corge") var corge: [Int] 62 | @Field(key: "grault") var grault: [Double] 63 | @Field(key: "garply") var garply: [String] 64 | @Field(key: "fred") var fred: Decimal 65 | @Field(key: "plugh") var plugh: Int? 66 | @Field(key: "xyzzy") var xyzzy: Double? 67 | @Field(key: "thud") var thud: Thud 68 | 69 | init() { } 70 | 71 | init( 72 | id: UUID? = nil, 73 | bar: Int, 74 | baz: Double, 75 | qux: String, 76 | quux: Date, 77 | quuz: Float, 78 | corge: [Int], 79 | grault: [Double], 80 | garply: [String], 81 | fred: Decimal, 82 | plugh: Int?, 83 | xyzzy: Double?, 84 | thud: Thud 85 | ) { 86 | self.id = id 87 | self.bar = bar 88 | self.baz = baz 89 | self.qux = qux 90 | self.quux = quux 91 | self.quuz = quuz 92 | self.corge = corge 93 | self.grault = grault 94 | self.garply = garply 95 | self.fred = fred 96 | self.plugh = plugh 97 | self.xyzzy = xyzzy 98 | self.thud = thud 99 | } 100 | } 101 | 102 | private struct FooMigration: Migration { 103 | let decimalType: DatabaseSchema.DataType 104 | 105 | func prepare(on database: any Database) -> EventLoopFuture { 106 | database.schema("foos") 107 | .field("id", .uuid, .identifier(auto: false)) 108 | .field("bar", .int, .required) 109 | .field("baz", .double, .required) 110 | .field("qux", .string, .required) 111 | .field("quux", .datetime, .required) 112 | .field("quuz", .float, .required) 113 | .field("corge", .array(of: .int), .required) 114 | .field("grault", .array(of: .double), .required) 115 | .field("garply", .array(of: .string), .required) 116 | .field("fred", self.decimalType, .required) 117 | .field("plugh", .int) 118 | .field("xyzzy", .double) 119 | .field("thud", .json, .required) 120 | .create() 121 | } 122 | 123 | func revert(on database: any Database) -> EventLoopFuture { 124 | database.schema("foos").delete() 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/RangeTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import XCTest 3 | 4 | extension FluentBenchmarker { 5 | public func testRange() throws { 6 | try self.testRange_basic() 7 | } 8 | 9 | private func testRange_basic() throws { 10 | try self.runTest(#function, [ 11 | SolarSystem() 12 | ]) { 13 | do { 14 | let planets = try Planet.query(on: self.database) 15 | .range(2..<5) 16 | .sort(\.$name) 17 | .all().wait() 18 | XCTAssertEqual(planets.count, 3) 19 | XCTAssertEqual(planets.first?.name, "Mars") 20 | } 21 | do { 22 | let planets = try Planet.query(on: self.database) 23 | .range(...5) 24 | .sort(\.$name) 25 | .all().wait() 26 | XCTAssertEqual(planets.count, 6) 27 | } 28 | do { 29 | let planets = try Planet.query(on: self.database) 30 | .range(..<5) 31 | .sort(\.$name) 32 | .all().wait() 33 | XCTAssertEqual(planets.count, 5) 34 | } 35 | do { 36 | let planets = try Planet.query(on: self.database) 37 | .range(..<5) 38 | .sort(\.$name) 39 | .all().wait() 40 | XCTAssertEqual(planets.count, 5) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/SQLTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import FluentSQL 3 | import Foundation 4 | import NIOCore 5 | import NIOPosix 6 | import SQLKit 7 | import SQLKitBenchmark 8 | import XCTest 9 | 10 | extension FluentBenchmarker { 11 | public func testSQL() throws { 12 | guard let sql = self.database as? any SQLDatabase else { 13 | return 14 | } 15 | try self.testSQL_rawDecode(sql) 16 | try MultiThreadedEventLoopGroup.singleton.any().makeFutureWithTask { 17 | try await SQLBenchmarker(on: sql).runAllTests() 18 | }.wait() 19 | } 20 | 21 | private func testSQL_rawDecode(_ sql: any SQLDatabase) throws { 22 | try self.runTest(#function, [ 23 | UserMigration() 24 | ]) { 25 | let tanner = User(firstName: "Tanner", lastName: "Nelson", parentID: UUID()) 26 | try tanner.create(on: self.database).wait() 27 | 28 | // test db.first(decoding:) 29 | do { 30 | let user = try sql.raw("SELECT * FROM users").first(decodingFluent: User.self).wait() 31 | XCTAssertNotNil(user) 32 | if let user = user { 33 | XCTAssertEqual(user.id, tanner.id) 34 | XCTAssertEqual(user.firstName, tanner.firstName) 35 | XCTAssertEqual(user.lastName, tanner.lastName) 36 | XCTAssertEqual(user.$parent.id, tanner.$parent.id) 37 | } 38 | } 39 | 40 | // test db.all(decoding:) 41 | do { 42 | let users = try sql.raw("SELECT * FROM users").all(decodingFluent: User.self).wait() 43 | XCTAssertEqual(users.count, 1) 44 | if let user = users.first { 45 | XCTAssertEqual(user.id, tanner.id) 46 | XCTAssertEqual(user.firstName, tanner.firstName) 47 | XCTAssertEqual(user.lastName, tanner.lastName) 48 | XCTAssertEqual(user.$parent.id, tanner.$parent.id) 49 | } 50 | } 51 | 52 | // test row.decode() 53 | do { 54 | let users = try sql.raw("SELECT * FROM users").all().wait().map { 55 | try $0.decode(fluentModel: User.self) 56 | } 57 | XCTAssertEqual(users.count, 1) 58 | if let user = users.first { 59 | XCTAssertEqual(user.id, tanner.id) 60 | XCTAssertEqual(user.firstName, tanner.firstName) 61 | XCTAssertEqual(user.lastName, tanner.lastName) 62 | XCTAssertEqual(user.$parent.id, tanner.$parent.id) 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | 70 | private final class User: Model, @unchecked Sendable { 71 | static let schema = "users" 72 | 73 | @ID(key: .id) 74 | var id: UUID? 75 | 76 | @Field(key: "first_name") 77 | var firstName: String 78 | 79 | @Field(key: "last_name") 80 | var lastName: String 81 | 82 | @OptionalParent(key: "parent_id") 83 | var parent: User? 84 | 85 | init() { } 86 | 87 | init( 88 | id: UUID? = nil, 89 | firstName: String, 90 | lastName: String, 91 | parentID: UUID? = nil 92 | ) { 93 | self.id = id 94 | self.firstName = firstName 95 | self.lastName = lastName 96 | self.$parent.id = parentID 97 | } 98 | } 99 | 100 | private struct UserMigration: Migration { 101 | func prepare(on database: any Database) -> EventLoopFuture { 102 | database.schema("users") 103 | .id() 104 | .field("first_name", .string, .required) 105 | .field("last_name", .string, .required) 106 | .field("parent_id", .uuid) 107 | .create() 108 | } 109 | 110 | func revert(on database: any Database) -> EventLoopFuture { 111 | database.schema("users").delete() 112 | } 113 | } 114 | 115 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/SetTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | extension FluentBenchmarker { 7 | public func testSet() throws { 8 | try self.testSet_multiple() 9 | try self.testSet_optional() 10 | try self.testSet_enum() 11 | } 12 | 13 | private func testSet_multiple() throws { 14 | try runTest(#function, [ 15 | TestMigration(), 16 | ]) { 17 | // Int value set first 18 | try Test.query(on: self.database) 19 | .set(\.$intValue, to: 1) 20 | .set(\.$stringValue, to: "a string") 21 | .update().wait() 22 | 23 | // String value set first 24 | try Test.query(on: self.database) 25 | .set(\.$stringValue, to: "a string") 26 | .set(\.$intValue, to: 1) 27 | .update().wait() 28 | } 29 | } 30 | 31 | private func testSet_optional() throws { 32 | try runTest(#function, [ 33 | TestMigration(), 34 | ]) { 35 | try Test.query(on: self.database) 36 | .set(\.$intValue, to: nil) 37 | .set(\.$stringValue, to: nil) 38 | .update().wait() 39 | } 40 | } 41 | 42 | private func testSet_enum() throws { 43 | try runTest(#function, [ 44 | Test2Migration(), 45 | ]) { 46 | try Test2.query(on: self.database) 47 | .set(\.$foo, to: .bar) 48 | .update().wait() 49 | } 50 | } 51 | } 52 | 53 | private final class Test: Model, @unchecked Sendable { 54 | static let schema = "test" 55 | 56 | @ID(key: .id) 57 | var id: UUID? 58 | 59 | @OptionalField(key: "int_value") 60 | var intValue: Int? 61 | 62 | @OptionalField(key: "string_value") 63 | var stringValue: String? 64 | } 65 | 66 | private struct TestMigration: FluentKit.Migration { 67 | func prepare(on database: any Database) -> EventLoopFuture { 68 | database.schema("test") 69 | .field("id", .uuid, .identifier(auto: false)) 70 | .field("int_value", .int) 71 | .field("string_value", .string) 72 | .create() 73 | } 74 | 75 | func revert(on database: any Database) -> EventLoopFuture { 76 | database.schema("test").delete() 77 | } 78 | } 79 | 80 | private enum Foo: String, Codable { 81 | case bar, baz 82 | } 83 | 84 | private final class Test2: Model, @unchecked Sendable { 85 | static let schema = "test" 86 | 87 | @ID(key: .id) 88 | var id: UUID? 89 | 90 | @Enum(key: "foo") 91 | var foo: Foo 92 | } 93 | 94 | private struct Test2Migration: FluentKit.Migration { 95 | func prepare(on database: any Database) -> EventLoopFuture { 96 | database.enum("foo").case("bar").case("baz").create().flatMap { foo in 97 | database.schema("test") 98 | .id() 99 | .field("foo", foo) 100 | .create() 101 | } 102 | } 103 | 104 | func revert(on database: any Database) -> EventLoopFuture { 105 | database.schema("test").delete().flatMap { 106 | database.enum("foo").delete() 107 | } 108 | } 109 | } 110 | 111 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/SortTests.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | import XCTest 3 | import SQLKit 4 | 5 | extension FluentBenchmarker { 6 | public func testSort(sql: Bool = true) throws { 7 | try self.testSort_basic() 8 | if sql { 9 | try self.testSort_sql() 10 | try self.testSort_embedSql() 11 | } 12 | } 13 | 14 | private func testSort_basic() throws { 15 | try self.runTest(#function, [ 16 | SolarSystem() 17 | ]) { 18 | let ascending = try Galaxy.query(on: self.database) 19 | .sort(\.$name, .ascending) 20 | .all().wait() 21 | let descending = try Galaxy.query(on: self.database) 22 | .sort(\.$name, .descending) 23 | .all().wait() 24 | XCTAssertEqual( 25 | ascending.map(\.name), 26 | descending.reversed().map(\.name) 27 | ) 28 | } 29 | } 30 | 31 | private func testSort_sql() throws { 32 | try self.runTest(#function, [ 33 | SolarSystem() 34 | ]) { 35 | let planets = try Planet.query(on: self.database) 36 | .sort(.sql("name", .notEqual, "Earth")) 37 | .all().wait() 38 | XCTAssertEqual(planets.first?.name, "Earth") 39 | } 40 | } 41 | 42 | private func testSort_embedSql() throws { 43 | try self.runTest(#function, [ 44 | SolarSystem() 45 | ]) { 46 | let planets = try Planet.query(on: self.database) 47 | .sort(.sql(embed: "\(ident: "name")\(SQLBinaryOperator.notEqual)\(literal: "Earth")")) 48 | .all().wait() 49 | XCTAssertEqual(planets.first?.name, "Earth") 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/TransactionTests.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import XCTest 3 | import FluentKit 4 | 5 | extension FluentBenchmarker { 6 | public func testTransaction() throws { 7 | try self.testTransaction_basic() 8 | try self.testTransaction_in() 9 | } 10 | 11 | private func testTransaction_basic() throws { 12 | try self.runTest(#function, [ 13 | SolarSystem() 14 | ]) { 15 | let result = self.database.transaction { transaction in 16 | Star.query(on: transaction) 17 | .filter(\.$name == "Sol") 18 | .first() 19 | .flatMapWithEventLoop 20 | { sun, eventLoop -> EventLoopFuture in 21 | guard let sun else { return eventLoop.makeFailedFuture(FluentError.missingField(name: "Sol")) } 22 | let pluto = Planet(name: "Pluto") 23 | return sun.$planets.create(pluto, on: transaction).map { 24 | pluto 25 | } 26 | }.flatMap { pluto -> EventLoopFuture<(Planet, Tag)> in 27 | let tag = Tag(name: "Dwarf") 28 | return tag.create(on: transaction).map { 29 | (pluto, tag) 30 | } 31 | }.flatMap { (pluto, tag) in 32 | tag.$planets.attach(pluto, on: transaction) 33 | }.flatMapThrowing { 34 | throw Test() 35 | } 36 | } 37 | do { 38 | try result.wait() 39 | } catch is Test { 40 | // expected 41 | } catch { 42 | XCTFail("Unexpected error: \(error)") 43 | } 44 | 45 | let pluto = try Planet.query(on: self.database) 46 | .filter(\.$name == "Pluto") 47 | .first() 48 | .wait() 49 | XCTAssertNil(pluto) 50 | } 51 | } 52 | 53 | private func testTransaction_in() throws { 54 | try self.runTest(#function, [ 55 | SolarSystem() 56 | ]) { 57 | try self.database.transaction { transaction in 58 | XCTAssertEqual(transaction.inTransaction, true) 59 | return transaction.transaction { nested in 60 | XCTAssertEqual(nested.inTransaction, true) 61 | return nested.eventLoop.makeSucceededFuture(()) 62 | } 63 | }.wait() 64 | } 65 | } 66 | } 67 | 68 | private struct Test: Error { } 69 | -------------------------------------------------------------------------------- /Sources/FluentBenchmark/Tests/UniqueTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import NIOCore 4 | import XCTest 5 | 6 | extension FluentBenchmarker { 7 | public func testUnique() throws { 8 | try self.testUnique_fields() 9 | try self.testUnique_duplicateKey() 10 | } 11 | 12 | private func testUnique_fields() throws { 13 | try self.runTest(#function, [ 14 | FooMigration(), 15 | ]) { 16 | try Foo(bar: "a", baz: 1).save(on: self.database).wait() 17 | try Foo(bar: "a", baz: 2).save(on: self.database).wait() 18 | do { 19 | try Foo(bar: "a", baz: 1).save(on: self.database).wait() 20 | XCTFail("should have failed") 21 | } catch let error where error is any DatabaseError { 22 | // pass 23 | } 24 | } 25 | } 26 | 27 | // https://github.com/vapor/fluent-kit/issues/112 28 | public func testUnique_duplicateKey() throws { 29 | try runTest(#function, [ 30 | BarMigration(), 31 | BazMigration() 32 | ]) { 33 | // 34 | } 35 | } 36 | } 37 | 38 | private final class Foo: Model, @unchecked Sendable { 39 | static let schema = "foos" 40 | 41 | @ID(key: .id) 42 | var id: UUID? 43 | 44 | @Field(key: "bar") 45 | var bar: String 46 | 47 | @Field(key: "baz") 48 | var baz: Int 49 | 50 | init() { } 51 | 52 | init(id: IDValue? = nil, bar: String, baz: Int) { 53 | self.id = id 54 | self.bar = bar 55 | self.baz = baz 56 | } 57 | } 58 | 59 | private struct FooMigration: Migration { 60 | func prepare(on database: any Database) -> EventLoopFuture { 61 | database.schema("foos") 62 | .field("id", .uuid, .identifier(auto: false)) 63 | .field("bar", .string, .required) 64 | .field("baz", .int, .required) 65 | .unique(on: "bar", "baz") 66 | .create() 67 | } 68 | 69 | func revert(on database: any Database) -> EventLoopFuture { 70 | database.schema("foos").delete() 71 | } 72 | } 73 | 74 | private struct BarMigration: Migration { 75 | func prepare(on database: any Database) -> EventLoopFuture { 76 | database.schema("bars") 77 | .field("name", .string) 78 | .unique(on: "name") 79 | .create() 80 | } 81 | 82 | func revert(on database: any Database) -> EventLoopFuture { 83 | database.schema("bars").delete() 84 | } 85 | } 86 | 87 | private struct BazMigration: Migration { 88 | func prepare(on database: any Database) -> EventLoopFuture { 89 | database.schema("bazs") 90 | .field("name", .string) 91 | .unique(on: "name") 92 | .create() 93 | } 94 | 95 | func revert(on database: any Database) -> EventLoopFuture { 96 | database.schema("bazs").delete() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/AsyncMigration.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public protocol AsyncMigration: Migration { 4 | func prepare(on database: any Database) async throws 5 | func revert(on database: any Database) async throws 6 | } 7 | 8 | public extension AsyncMigration { 9 | func prepare(on database: any Database) -> EventLoopFuture { 10 | database.eventLoop.makeFutureWithTask { 11 | try await self.prepare(on: database) 12 | } 13 | } 14 | 15 | func revert(on database: any Database) -> EventLoopFuture { 16 | database.eventLoop.makeFutureWithTask { 17 | try await self.revert(on: database) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/AsyncModelMiddleware.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public protocol AsyncModelMiddleware: AnyModelMiddleware { 4 | associatedtype Model: FluentKit.Model 5 | 6 | func create(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws 7 | func update(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws 8 | func delete(model: Model, force: Bool, on db: any Database, next: any AnyAsyncModelResponder) async throws 9 | func softDelete(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws 10 | func restore(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws 11 | } 12 | 13 | extension AsyncModelMiddleware { 14 | public func handle( 15 | _ event: ModelEvent, 16 | _ model: any AnyModel, 17 | on db: any Database, 18 | chainingTo next: any AnyModelResponder 19 | ) -> EventLoopFuture { 20 | guard let modelType = (model as? Model) else { 21 | return next.handle(event, model, on: db) 22 | } 23 | 24 | return db.eventLoop.makeFutureWithTask { 25 | let responder = AsyncBasicModelResponder { responderEvent, responderModel, responderDB in 26 | try await next.handle(responderEvent, responderModel, on: responderDB).get() 27 | } 28 | 29 | switch event { 30 | case .create: 31 | try await self.create(model: modelType, on: db, next: responder) 32 | case .update: 33 | try await self.update(model: modelType, on: db, next: responder) 34 | case .delete(let force): 35 | try await self.delete(model: modelType, force: force, on: db, next: responder) 36 | case .softDelete: 37 | try await self.softDelete(model: modelType, on: db, next: responder) 38 | case .restore: 39 | try await self.restore(model: modelType, on: db, next: responder) 40 | } 41 | } 42 | } 43 | 44 | public func create(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws { 45 | try await next.create(model, on: db) 46 | } 47 | 48 | public func update(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws { 49 | try await next.update(model, on: db) 50 | } 51 | 52 | public func delete(model: Model, force: Bool, on db: any Database, next: any AnyAsyncModelResponder) async throws { 53 | try await next.delete(model, force: force, on: db) 54 | } 55 | 56 | public func softDelete(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws { 57 | try await next.softDelete(model, on: db) 58 | } 59 | 60 | public func restore(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws { 61 | try await next.restore(model, on: db) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/Children+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension ChildrenProperty { 4 | func load(on database: any Database) async throws { 5 | try await self.load(on: database).get() 6 | } 7 | 8 | func create(_ to: To, on database: any Database) async throws { 9 | try await self.create(to, on: database).get() 10 | } 11 | 12 | func create(_ to: [To], on database: any Database) async throws { 13 | try await self.create(to, on: database).get() 14 | } 15 | } 16 | 17 | public extension CompositeChildrenProperty { 18 | func load(on database: any Database) async throws { 19 | try await self.load(on: database).get() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/Database+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension Database { 4 | func execute( 5 | query: DatabaseQuery, 6 | onOutput: @escaping @Sendable (any DatabaseOutput) -> () 7 | ) async throws { 8 | try await self.execute(query: query, onOutput: onOutput).get() 9 | } 10 | 11 | func execute( 12 | schema: DatabaseSchema 13 | ) async throws { 14 | try await self.execute(schema: schema).get() 15 | } 16 | 17 | func execute( 18 | enum: DatabaseEnum 19 | ) async throws { 20 | try await self.execute(enum: `enum`).get() 21 | } 22 | 23 | func transaction(_ closure: @escaping @Sendable (any Database) async throws -> T) async throws -> T { 24 | try await self.transaction { db in 25 | self.eventLoop.makeFutureWithTask { 26 | try await closure(db) 27 | } 28 | }.get() 29 | } 30 | 31 | func withConnection(_ closure: @escaping @Sendable (any Database) async throws -> T) async throws -> T { 32 | try await self.withConnection { db in 33 | self.eventLoop.makeFutureWithTask { 34 | try await closure(db) 35 | } 36 | }.get() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/EnumBuilder+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension EnumBuilder { 4 | func create() async throws -> DatabaseSchema.DataType { 5 | try await self.create().get() 6 | } 7 | 8 | func read() async throws -> DatabaseSchema.DataType { 9 | try await self.read().get() 10 | } 11 | 12 | func update() async throws -> DatabaseSchema.DataType { 13 | try await self.update().get() 14 | } 15 | 16 | func delete() async throws { 17 | try await self.delete().get() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/Model+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension Model { 4 | static func find( 5 | _ id: Self.IDValue?, 6 | on database: any Database 7 | ) async throws -> Self? { 8 | try await self.find(id, on: database).get() 9 | } 10 | 11 | // MARK: - CRUD 12 | func save(on database: any Database) async throws { 13 | try await self.save(on: database).get() 14 | } 15 | 16 | func create(on database: any Database) async throws { 17 | try await self.create(on: database).get() 18 | } 19 | 20 | func update(on database: any Database) async throws { 21 | try await self.update(on: database).get() 22 | } 23 | 24 | func delete(force: Bool = false, on database: any Database) async throws { 25 | try await self.delete(force: force, on: database).get() 26 | } 27 | 28 | func restore(on database: any Database) async throws { 29 | try await self.restore(on: database).get() 30 | } 31 | } 32 | 33 | public extension Collection where Element: FluentKit.Model, Self: Sendable { 34 | func delete(force: Bool = false, on database: any Database) async throws { 35 | try await self.delete(force: force, on: database).get() 36 | } 37 | 38 | func create(on database: any Database) async throws { 39 | try await self.create(on: database).get() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/ModelResponder+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public protocol AnyAsyncModelResponder: AnyModelResponder { 4 | func handle( 5 | _ event: ModelEvent, 6 | _ model: any AnyModel, 7 | on db: any Database 8 | ) async throws 9 | } 10 | 11 | extension AnyAsyncModelResponder { 12 | func handle(_ event: ModelEvent, _ model: any AnyModel, on db: any Database) -> EventLoopFuture { 13 | db.eventLoop.makeFutureWithTask { 14 | try await self.handle(event, model, on: db) 15 | } 16 | } 17 | } 18 | 19 | extension AnyAsyncModelResponder { 20 | public func create(_ model: any AnyModel, on db: any Database) async throws { 21 | try await handle(.create, model, on: db) 22 | } 23 | 24 | public func update(_ model: any AnyModel, on db: any Database) async throws { 25 | try await handle(.update, model, on: db) 26 | } 27 | 28 | public func restore(_ model: any AnyModel, on db: any Database) async throws { 29 | try await handle(.restore, model, on: db) 30 | } 31 | 32 | public func softDelete(_ model: any AnyModel, on db: any Database) async throws { 33 | try await handle(.softDelete, model, on: db) 34 | } 35 | 36 | public func delete(_ model: any AnyModel, force: Bool, on db: any Database) async throws { 37 | try await handle(.delete(force), model, on: db) 38 | } 39 | } 40 | 41 | internal struct AsyncBasicModelResponder: AnyAsyncModelResponder { 42 | private let _handle: @Sendable (ModelEvent, any AnyModel, any Database) async throws -> Void 43 | 44 | internal func handle(_ event: ModelEvent, _ model: any AnyModel, on db: any Database) async throws { 45 | try await _handle(event, model, db) 46 | } 47 | 48 | init(handle: @escaping @Sendable (ModelEvent, any AnyModel, any Database) async throws -> Void) { 49 | self._handle = handle 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/OptionalChild+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension OptionalChildProperty { 4 | func load(on database: any Database) async throws { 5 | try await self.load(on: database).get() 6 | } 7 | 8 | func create(_ to: To, on database: any Database) async throws { 9 | try await self.create(to, on: database).get() 10 | } 11 | } 12 | 13 | public extension CompositeOptionalChildProperty { 14 | func load(on database: any Database) async throws { 15 | try await self.load(on: database).get() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/OptionalParent+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension OptionalParentProperty { 4 | func load(on database: any Database) async throws { 5 | try await self.load(on: database).get() 6 | } 7 | } 8 | 9 | public extension CompositeOptionalParentProperty { 10 | func load(on database: any Database) async throws { 11 | try await self.load(on: database).get() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/Parent+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension ParentProperty { 4 | func load(on database: any Database) async throws { 5 | try await self.load(on: database).get() 6 | } 7 | } 8 | 9 | public extension CompositeParentProperty { 10 | func load(on database: any Database) async throws { 11 | try await self.load(on: database).get() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/Relation+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension Relation { 4 | func get(reload: Bool = false, on database: any Database) async throws -> RelatedValue { 5 | try await self.get(reload: reload, on: database).get() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/SchemaBuilder+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension SchemaBuilder { 4 | func create() async throws { 5 | try await self.create().get() 6 | } 7 | 8 | func update() async throws { 9 | try await self.update().get() 10 | } 11 | 12 | func delete() async throws { 13 | try await self.delete().get() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/FluentKit/Concurrency/Siblings+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public extension SiblingsProperty { 4 | 5 | func load(on database: any Database) async throws { 6 | try await self.load(on: database).get() 7 | } 8 | 9 | // MARK: Checking state 10 | 11 | func isAttached(to: To, on database: any Database) async throws -> Bool { 12 | try await self.isAttached(to: to, on: database).get() 13 | } 14 | 15 | func isAttached(toID: To.IDValue, on database: any Database) async throws -> Bool { 16 | try await self.isAttached(toID: toID, on: database).get() 17 | } 18 | 19 | // MARK: Operations 20 | 21 | /// Attach multiple models with plain edit closure. 22 | func attach(_ tos: [To], on database: any Database, _ edit: @escaping @Sendable (Through) -> () = { _ in }) async throws { 23 | try await self.attach(tos, on: database, edit).get() 24 | } 25 | 26 | /// Attach single model with plain edit closure. 27 | func attach(_ to: To, on database: any Database, _ edit: @escaping @Sendable (Through) -> () = { _ in }) async throws { 28 | try await self.attach(to, method: .always, on: database, edit) 29 | } 30 | 31 | /// Attach single model by specific method with plain edit closure. 32 | func attach( 33 | _ to: To, method: AttachMethod, on database: any Database, 34 | _ edit: @escaping @Sendable (Through) -> () = { _ in } 35 | ) async throws { 36 | try await self.attach(to, method: method, on: database, edit).get() 37 | } 38 | 39 | /// A version of ``attach(_:on:_:)-791gu`` whose edit closure is async and can throw. 40 | /// 41 | /// This method provides "all or none" semantics- if the edit closure throws an error, any already- 42 | /// processed pivots are discarded. Only if all pivots are successfully edited are any of them saved. 43 | /// 44 | /// These semantics require us to reimplement, rather than calling through to, the ELF version. 45 | func attach( 46 | _ tos: [To], 47 | on database: any Database, 48 | _ edit: @escaping @Sendable (Through) async throws -> () 49 | ) async throws { 50 | guard let fromID = self.idValue else { 51 | throw SiblingsPropertyError.owningModelIdRequired(property: self.name) 52 | } 53 | 54 | var pivots: [Through] = [] 55 | pivots.reserveCapacity(tos.count) 56 | 57 | for to in tos { 58 | guard let toID = to.id else { 59 | throw SiblingsPropertyError.operandModelIdRequired(property: self.name) 60 | } 61 | let pivot = Through() 62 | pivot[keyPath: self.from].id = fromID 63 | pivot[keyPath: self.to].id = toID 64 | pivot[keyPath: self.to].value = to 65 | try await edit(pivot) 66 | pivots.append(pivot) 67 | } 68 | try await pivots.create(on: database) 69 | } 70 | 71 | /// A version of ``attach(_:on:_:)-791gu`` whose edit closure is async and can throw. 72 | /// 73 | /// These semantics require us to reimplement, rather than calling through to, the ELF version. 74 | func attach(_ to: To, on database: any Database, _ edit: @escaping @Sendable (Through) async throws -> ()) async throws { 75 | try await self.attach(to, method: .always, on: database, edit) 76 | } 77 | 78 | /// A version of ``attach(_:method:on:_:)-20vs`` whose edit closure is async and can throw. 79 | /// 80 | /// These semantics require us to reimplement, rather than calling through to, the ELF version. 81 | func attach( 82 | _ to: To, method: AttachMethod, on database: any Database, 83 | _ edit: @escaping @Sendable (Through) async throws -> () 84 | ) async throws { 85 | switch method { 86 | case .ifNotExists: 87 | guard try await !self.isAttached(to: to, on: database) else { return } 88 | fallthrough 89 | case .always: 90 | try await self.attach([to], on: database, edit) 91 | } 92 | } 93 | 94 | func detach(_ tos: [To], on database: any Database) async throws { 95 | try await self.detach(tos, on: database).get() 96 | } 97 | 98 | func detach(_ to: To, on database: any Database) async throws { 99 | try await self.detach(to, on: database).get() 100 | } 101 | 102 | func detachAll(on database: any Database) async throws { 103 | try await self.detachAll(on: database).get() 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Sources/FluentKit/Database/Database+Logging.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import Logging 3 | import SQLKit 4 | 5 | extension Database { 6 | public func logging(to logger: Logger) -> any Database { 7 | LoggingOverrideDatabase(database: self, logger: logger) 8 | } 9 | } 10 | 11 | private struct LoggingOverrideDatabase { 12 | let database: D 13 | let logger: Logger 14 | } 15 | 16 | extension LoggingOverrideDatabase: Database { 17 | var context: DatabaseContext { 18 | .init( 19 | configuration: self.database.context.configuration, 20 | logger: self.logger, 21 | eventLoop: self.database.context.eventLoop, 22 | history: self.database.context.history 23 | ) 24 | } 25 | 26 | func execute( 27 | query: DatabaseQuery, 28 | onOutput: @escaping @Sendable (any DatabaseOutput) -> () 29 | ) -> EventLoopFuture { 30 | self.database.execute(query: query, onOutput: onOutput) 31 | } 32 | 33 | func execute( 34 | schema: DatabaseSchema 35 | ) -> EventLoopFuture { 36 | self.database.execute(schema: schema) 37 | } 38 | 39 | func execute( 40 | enum: DatabaseEnum 41 | ) -> EventLoopFuture { 42 | self.database.execute(enum: `enum`) 43 | } 44 | 45 | var inTransaction: Bool { 46 | self.database.inTransaction 47 | } 48 | 49 | func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { 50 | self.database.transaction(closure) 51 | } 52 | 53 | func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { 54 | self.database.withConnection(closure) 55 | } 56 | } 57 | 58 | extension LoggingOverrideDatabase: SQLDatabase where D: SQLDatabase { 59 | func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> ()) -> EventLoopFuture { 60 | self.database.execute(sql: query, onRow) 61 | } 62 | func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> ()) async throws { 63 | try await self.database.execute(sql: query, onRow) 64 | } 65 | func withSession(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> R) async throws -> R { 66 | try await self.database.withSession(closure) 67 | } 68 | var dialect: any SQLDialect { self.database.dialect } 69 | var version: (any SQLDatabaseReportedVersion)? { self.database.version } 70 | var queryLogLevel: Logger.Level? { self.database.queryLogLevel } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/FluentKit/Database/Database.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import Logging 3 | 4 | public protocol Database: Sendable { 5 | var context: DatabaseContext { get } 6 | 7 | func execute( 8 | query: DatabaseQuery, 9 | onOutput: @escaping @Sendable (any DatabaseOutput) -> () 10 | ) -> EventLoopFuture 11 | 12 | func execute( 13 | schema: DatabaseSchema 14 | ) -> EventLoopFuture 15 | 16 | func execute( 17 | enum: DatabaseEnum 18 | ) -> EventLoopFuture 19 | 20 | var inTransaction: Bool { get } 21 | 22 | func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture 23 | 24 | func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture 25 | } 26 | 27 | extension Database { 28 | public func query(_ model: Model.Type) -> QueryBuilder 29 | where Model: FluentKit.Model 30 | { 31 | .init(database: self) 32 | } 33 | } 34 | 35 | extension Database { 36 | public var configuration: any DatabaseConfiguration { 37 | self.context.configuration 38 | } 39 | 40 | public var logger: Logger { 41 | self.context.logger 42 | } 43 | 44 | public var eventLoop: any EventLoop { 45 | self.context.eventLoop 46 | } 47 | 48 | public var history: QueryHistory? { 49 | self.context.history 50 | } 51 | 52 | public var pageSizeLimit: Int? { 53 | self.context.pageSizeLimit 54 | } 55 | } 56 | 57 | public protocol DatabaseDriver: Sendable { 58 | func makeDatabase(with context: DatabaseContext) -> any Database 59 | func shutdown() 60 | func shutdownAsync() async 61 | } 62 | 63 | public extension DatabaseDriver { 64 | func shutdownAsync() async { 65 | shutdown() 66 | } 67 | } 68 | 69 | public protocol DatabaseConfiguration: Sendable { 70 | var middleware: [any AnyModelMiddleware] { get set } 71 | func makeDriver(for databases: Databases) -> any DatabaseDriver 72 | } 73 | 74 | public struct DatabaseContext: Sendable { 75 | public let configuration: any DatabaseConfiguration 76 | public let logger: Logger 77 | public let eventLoop: any EventLoop 78 | public let history: QueryHistory? 79 | public let pageSizeLimit: Int? 80 | 81 | public init( 82 | configuration: any DatabaseConfiguration, 83 | logger: Logger, 84 | eventLoop: any EventLoop, 85 | history: QueryHistory? = nil, 86 | pageSizeLimit: Int? = nil 87 | ) { 88 | self.configuration = configuration 89 | self.logger = logger 90 | self.eventLoop = eventLoop 91 | self.history = history 92 | self.pageSizeLimit = pageSizeLimit 93 | } 94 | } 95 | 96 | public protocol DatabaseError { 97 | var isSyntaxError: Bool { get } 98 | var isConstraintFailure: Bool { get } 99 | var isConnectionClosed: Bool { get } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/FluentKit/Database/DatabaseID.swift: -------------------------------------------------------------------------------- 1 | public struct DatabaseID: Hashable, Codable, Sendable { 2 | public let string: String 3 | 4 | public init(string: String) { 5 | self.string = string 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/FluentKit/Database/DatabaseOutput.swift: -------------------------------------------------------------------------------- 1 | public protocol DatabaseOutput: CustomStringConvertible, Sendable { 2 | func schema(_ schema: String) -> any DatabaseOutput 3 | func contains(_ key: FieldKey) -> Bool 4 | func decodeNil(_ key: FieldKey) throws -> Bool 5 | func decode(_ key: FieldKey, as type: T.Type) throws -> T 6 | where T: Decodable 7 | } 8 | 9 | extension DatabaseOutput { 10 | public func decode(_ key: FieldKey) throws -> T 11 | where T: Decodable 12 | { 13 | try self.decode(key, as: T.self) 14 | } 15 | 16 | public func qualifiedSchema(space: String?, _ schema: String) -> any DatabaseOutput { 17 | self.schema([space, schema].compactMap({ $0 }).joined(separator: "_")) 18 | } 19 | } 20 | 21 | extension DatabaseOutput { 22 | public func prefixed(by prefix: FieldKey) -> any DatabaseOutput { 23 | PrefixedDatabaseOutput(prefix: prefix, strategy: .none, base: self) 24 | } 25 | 26 | public func prefixed(by prefix: FieldKey, using stratgey: KeyPrefixingStrategy) -> any DatabaseOutput { 27 | PrefixedDatabaseOutput(prefix: prefix, strategy: stratgey, base: self) 28 | } 29 | 30 | public func cascading(to output: any DatabaseOutput) -> any DatabaseOutput { 31 | CombinedOutput(first: self, second: output) 32 | } 33 | } 34 | 35 | private struct CombinedOutput: DatabaseOutput { 36 | let first: any DatabaseOutput, second: any DatabaseOutput 37 | 38 | func schema(_ schema: String) -> any DatabaseOutput { 39 | CombinedOutput(first: self.first.schema(schema), second: self.second.schema(schema)) 40 | } 41 | 42 | func contains(_ key: FieldKey) -> Bool { 43 | self.first.contains(key) || self.second.contains(key) 44 | } 45 | 46 | func decodeNil(_ key: FieldKey) throws -> Bool { 47 | try self.first.contains(key) ? self.first.decodeNil(key) : self.second.decodeNil(key) 48 | } 49 | 50 | func decode(_ key: FieldKey, as type: T.Type) throws -> T where T: Decodable { 51 | try self.first.contains(key) ? self.first.decode(key, as: T.self) : self.second.decode(key, as: T.self) 52 | } 53 | 54 | var description: String { 55 | self.first.description + " -> " + self.second.description 56 | } 57 | } 58 | 59 | private struct PrefixedDatabaseOutput: DatabaseOutput { 60 | let prefix: FieldKey, strategy: KeyPrefixingStrategy 61 | let base: any DatabaseOutput 62 | 63 | func schema(_ schema: String) -> any DatabaseOutput { 64 | PrefixedDatabaseOutput(prefix: self.prefix, strategy: self.strategy, base: self.base.schema(schema)) 65 | } 66 | 67 | func contains(_ key: FieldKey) -> Bool { 68 | self.base.contains(self.strategy.apply(prefix: self.prefix, to: key)) 69 | } 70 | 71 | func decodeNil(_ key: FieldKey) throws -> Bool { 72 | try self.base.decodeNil(self.strategy.apply(prefix: self.prefix, to: key)) 73 | } 74 | 75 | func decode(_ key: FieldKey, as type: T.Type) throws -> T where T : Decodable { 76 | try self.base.decode(self.strategy.apply(prefix: self.prefix, to: key), as: T.self) 77 | } 78 | 79 | var description: String { 80 | "Prefix(\(self.prefix) by \(self.strategy), of: \(self.base.description))" 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /Sources/FluentKit/Database/KeyPrefixingStrategy.swift: -------------------------------------------------------------------------------- 1 | /// A strategy describing how to apply a prefix to a ``FieldKey``. 2 | public enum KeyPrefixingStrategy: CustomStringConvertible, Sendable { 3 | /// The "do nothing" strategy - the prefix is applied to each key by simple concatenation. 4 | case none 5 | 6 | /// Each key has its first character capitalized and the prefix is applied to the result. 7 | case camelCase 8 | 9 | /// An underscore is placed between the prefix and each key. 10 | case snakeCase 11 | 12 | /// A custom strategy - for each key, the closure is called with that key and the prefix with which the 13 | /// wrapper was initialized, and must return the field key to actually use. The closure must be "pure" 14 | /// (i.e. for any given pair of inputs it must always return the same result, in the same way that hash 15 | /// values must be consistent within a single execution context). 16 | case custom(@Sendable (_ prefix: FieldKey, _ idFieldKey: FieldKey) -> FieldKey) 17 | 18 | // See `CustomStringConvertible.description`. 19 | public var description: String { 20 | switch self { 21 | case .none: 22 | ".useDefaultKeys" 23 | case .camelCase: 24 | ".camelCase" 25 | case .snakeCase: 26 | ".snakeCase" 27 | case .custom(_): 28 | ".custom(...)" 29 | } 30 | } 31 | 32 | /// Apply this prefixing strategy and the given prefix to the given key, and return the result. 33 | public func apply(prefix: FieldKey, to key: FieldKey) -> FieldKey { 34 | switch self { 35 | case .none: 36 | .prefix(prefix, key) 37 | 38 | // This strategy converts `.id` and `.aggregate` keys (but not prefixes) into generic `.string()`s. 39 | case .camelCase: 40 | switch key { 41 | case .id, .aggregate, .string(_): 42 | .prefix(prefix, .string(key.description.withUppercasedFirstCharacter())) 43 | 44 | case .prefix(let originalPrefix, let originalSuffix): 45 | .prefix(self.apply(prefix: prefix, to: originalPrefix), originalSuffix) 46 | } 47 | 48 | case .snakeCase: 49 | .prefix(.prefix(prefix, .string("_")), key) 50 | 51 | case .custom(let closure): 52 | closure(prefix, key) 53 | } 54 | } 55 | } 56 | 57 | fileprivate extension String { 58 | func withUppercasedFirstCharacter() -> String { 59 | guard !self.isEmpty else { return self } 60 | 61 | var result = self 62 | result.replaceSubrange(result.startIndex ... result.startIndex, with: result[result.startIndex].uppercased()) 63 | return result 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/FluentKit/Database/TransactionControlDatabase.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | /// Protocol for describing a database that allows fine-grained control over transcactions 4 | /// when you need more control than provided by ``Database/transaction(_:)-1x3ds`` 5 | /// 6 | /// > Warning: ⚠️ It is the developer's responsiblity to get hold of a ``Database``, execute the 7 | /// > transaction functions on that connection, and ensure that the functions aren't called across 8 | /// > different conenctions. You are also responsible for ensuring that you commit or rollback 9 | /// > queries when you're ready. 10 | /// 11 | /// Do not mix these functions and ``Database/transaction(_:)-1x3ds``. 12 | public protocol TransactionControlDatabase: Database { 13 | /// Start the transaction on the current connection. This is equivalent to an SQL `BEGIN` 14 | /// - Returns: future `Void` when the transaction has been started 15 | func beginTransaction() -> EventLoopFuture 16 | 17 | /// Commit the queries executed for the transaction and write them to the database 18 | /// This is equivalent to an SQL `COMMIT` 19 | /// - Returns: future `Void` when the transaction has been committed 20 | func commitTransaction() -> EventLoopFuture 21 | 22 | /// Rollback the current transaction's queries. You may want to trigger this when handling an error 23 | /// when trying to create models. 24 | /// This is equivalent to an SQL `ROLLBACK` 25 | /// - Returns: future `Void` when the transaction has been rollbacked 26 | func rollbackTransaction() -> EventLoopFuture 27 | } 28 | -------------------------------------------------------------------------------- /Sources/FluentKit/Docs.docc/Resources/vapor-fluentkit-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Sources/FluentKit/Docs.docc/index.md: -------------------------------------------------------------------------------- 1 | # ``FluentKit`` 2 | 3 | FluentKit is an ORM framework for Swift. It allows you to write type safe, database agnostic models and queries. It takes advantage of Swift's type system to provide a powerful, yet easy to use API. 4 | 5 | An example query looks like: 6 | 7 | ```swift 8 | let planets = try await Planet.query(on: database) 9 | .filter(\.$type == .gasGiant) 10 | .sort(\.$name) 11 | .with(\.$star) 12 | .all() 13 | ``` 14 | 15 | For more information, see the [Fluent documentation](https://docs.vapor.codes/fluent/overview/). 16 | -------------------------------------------------------------------------------- /Sources/FluentKit/Docs.docc/theme-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": { 3 | "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, 4 | "border-radius": "0", 5 | "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, 6 | "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, 7 | "color": { 8 | "fluentkit": { "dark": "hsl(200, 75%, 85%)", "light": "hsl(200, 75%, 75%)" }, 9 | "documentation-intro-fill": "radial-gradient(circle at top, var(--color-fluentkit) 30%, #000 100%)", 10 | "documentation-intro-accent": "var(--color-fluentkit)", 11 | "documentation-intro-eyebrow": "white", 12 | "documentation-intro-figure": "white", 13 | "documentation-intro-title": "white", 14 | "logo-base": { "dark": "#fff", "light": "#000" }, 15 | "logo-shape": { "dark": "#000", "light": "#fff" }, 16 | "fill": { "dark": "#000", "light": "#fff" } 17 | }, 18 | "icons": { "technology": "/fluentkit/images/FluentKit/vapor-fluentkit-logo.svg" } 19 | }, 20 | "features": { 21 | "quickNavigation": { "enable": true }, 22 | "i18n": { "enable": true } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/FluentKit/Enum/DatabaseEnum.swift: -------------------------------------------------------------------------------- 1 | public struct DatabaseEnum: Sendable { 2 | public enum Action: Sendable { 3 | case create 4 | case update 5 | case delete 6 | } 7 | 8 | public var action: Action 9 | public var name: String 10 | 11 | public var createCases: [String] 12 | public var deleteCases: [String] 13 | 14 | public init(name: String) { 15 | self.action = .create 16 | self.name = name 17 | self.createCases = [] 18 | self.deleteCases = [] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/FluentKit/Enum/EnumBuilder.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import NIOConcurrencyHelpers 3 | import SQLKit 4 | 5 | extension Database { 6 | public func `enum`(_ name: String) -> EnumBuilder { 7 | .init(database: self, name: name) 8 | } 9 | } 10 | 11 | public final class EnumBuilder: Sendable { 12 | let database: any Database 13 | let lockedEnum: NIOLockedValueBox 14 | 15 | public var `enum`: DatabaseEnum { 16 | get { self.lockedEnum.withLockedValue { $0 } } 17 | set { self.lockedEnum.withLockedValue { $0 = newValue } } 18 | } 19 | 20 | init(database: any Database, name: String) { 21 | self.database = database 22 | self.lockedEnum = .init(.init(name: name)) 23 | } 24 | 25 | public func `case`(_ name: String) -> Self { 26 | self.enum.createCases.append(name) 27 | return self 28 | } 29 | 30 | public func deleteCase(_ name: String) -> Self { 31 | self.enum.deleteCases.append(name) 32 | return self 33 | } 34 | 35 | public func create() -> EventLoopFuture { 36 | self.enum.action = .create 37 | return self.database.execute(enum: self.enum).flatMap { 38 | self.generateDatatype() 39 | } 40 | } 41 | 42 | public func read() -> EventLoopFuture { 43 | self.generateDatatype() 44 | } 45 | 46 | public func update() -> EventLoopFuture { 47 | self.enum.action = .update 48 | return self.database.execute(enum: self.enum).flatMap { 49 | self.generateDatatype() 50 | } 51 | } 52 | 53 | public func delete() -> EventLoopFuture { 54 | self.enum.action = .delete 55 | return self.database.execute(enum: self.enum).flatMap { 56 | self.deleteMetadata() 57 | } 58 | } 59 | 60 | // MARK: Private 61 | 62 | private func generateDatatype() -> EventLoopFuture { 63 | EnumMetadata.migration.prepare(on: self.database).flatMap { 64 | self.updateMetadata() 65 | }.flatMap { _ in 66 | // Fetch the latest cases. 67 | EnumMetadata.query(on: self.database).filter(\.$name == self.enum.name).all() 68 | }.map { cases in 69 | // Convert latest cases to usable DataType. 70 | .enum(.init( 71 | name: self.enum.name, 72 | cases: cases.map { $0.case } 73 | )) 74 | } 75 | } 76 | 77 | private func updateMetadata() -> EventLoopFuture { 78 | // Create all new enum cases. 79 | let create = self.enum.createCases.map { 80 | EnumMetadata(name: self.enum.name, case: $0) 81 | }.create(on: self.database) 82 | // Delete all old enum cases. 83 | let delete = EnumMetadata.query(on: self.database) 84 | .filter(\.$name == self.enum.name) 85 | .filter(\.$case ~~ self.enum.deleteCases) 86 | .delete() 87 | return create.and(delete).map { _ in } 88 | } 89 | 90 | private func deleteMetadata() -> EventLoopFuture { 91 | // Delete all cases for this enum. 92 | EnumMetadata.query(on: self.database) 93 | .filter(\.$name == self.enum.name) 94 | .delete() 95 | .flatMap 96 | { _ in 97 | EnumMetadata.query(on: self.database).count() 98 | }.flatMap { count in 99 | // If no enums are left, remove table. 100 | if count == 0 { 101 | return EnumMetadata.migration.revert(on: self.database) 102 | } else { 103 | return self.database.eventLoop.makeSucceededFuture(()) 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/FluentKit/Enum/EnumMetadata.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import Foundation 3 | 4 | final class EnumMetadata: Model, @unchecked Sendable { 5 | static let schema = "_fluent_enums" 6 | 7 | static var migration: any Migration { 8 | EnumMetadataMigration() 9 | } 10 | 11 | @ID(key: .id) 12 | var id: UUID? 13 | 14 | @Field(key: "name") 15 | var name: String 16 | 17 | @Field(key: "case") 18 | var `case`: String 19 | 20 | init() {} 21 | 22 | init(id: IDValue? = nil, name: String, `case`: String) { 23 | self.id = id 24 | self.name = name 25 | self.case = `case` 26 | } 27 | } 28 | 29 | private struct EnumMetadataMigration: Migration { 30 | func prepare(on database: any Database) -> EventLoopFuture { 31 | database.schema(EnumMetadata.schema) 32 | .id() 33 | .field("name", .string, .required) 34 | .field("case", .string, .required) 35 | .unique(on: "name", "case") 36 | .ignoreExisting() 37 | .create() 38 | } 39 | 40 | func revert(on database: any Database) -> EventLoopFuture { 41 | database.schema(EnumMetadata.schema).delete() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/FluentKit/Enum/EnumProperty.swift: -------------------------------------------------------------------------------- 1 | extension Fields { 2 | public typealias Enum = EnumProperty 3 | where Value: Codable & Sendable, 4 | Value: RawRepresentable, 5 | Value.RawValue == String 6 | } 7 | 8 | // MARK: Type 9 | 10 | @propertyWrapper 11 | public final class EnumProperty 12 | where Model: FluentKit.Fields, 13 | Value: Codable & Sendable, 14 | Value: RawRepresentable, 15 | Value.RawValue == String 16 | { 17 | public let field: FieldProperty 18 | 19 | public var projectedValue: EnumProperty { 20 | self 21 | } 22 | 23 | public var wrappedValue: Value { 24 | get { 25 | guard let value = self.value else { 26 | fatalError("Cannot access enum field before it is initialized or fetched: \(self.field.key)") 27 | } 28 | return value 29 | } 30 | set { 31 | self.value = newValue 32 | } 33 | } 34 | 35 | public init(key: FieldKey) { 36 | self.field = .init(key: key) 37 | } 38 | } 39 | 40 | // MARK: Property 41 | 42 | extension EnumProperty: AnyProperty { } 43 | 44 | extension EnumProperty: Property { 45 | public var value: Value? { 46 | get { 47 | self.field.value.map { 48 | Value(rawValue: $0)! 49 | } 50 | } 51 | set { 52 | self.field.value = newValue?.rawValue 53 | } 54 | } 55 | } 56 | 57 | // MARK: Queryable 58 | 59 | extension EnumProperty: AnyQueryableProperty { 60 | public var path: [FieldKey] { 61 | self.field.path 62 | } 63 | } 64 | 65 | extension EnumProperty: QueryableProperty { 66 | public static func queryValue(_ value: Value) -> DatabaseQuery.Value { 67 | .enumCase(value.rawValue) 68 | } 69 | } 70 | 71 | // MARK: Query-addressable 72 | 73 | extension EnumProperty: AnyQueryAddressableProperty { 74 | public var anyQueryableProperty: any AnyQueryableProperty { self } 75 | public var queryablePath: [FieldKey] { self.path } 76 | } 77 | 78 | extension EnumProperty: QueryAddressableProperty { 79 | public var queryableProperty: EnumProperty { self } 80 | } 81 | 82 | // MARK: Database 83 | 84 | extension EnumProperty: AnyDatabaseProperty { 85 | public var keys: [FieldKey] { 86 | self.field.keys 87 | } 88 | 89 | public func input(to input: any DatabaseInput) { 90 | let value: DatabaseQuery.Value 91 | if !input.wantsUnmodifiedKeys { 92 | guard let ivalue = self.field.inputValue else { return } 93 | value = ivalue 94 | } else { 95 | value = self.field.inputValue ?? .default 96 | } 97 | 98 | switch value { 99 | case .bind(let bind as String): 100 | input.set(.enumCase(bind), at: self.field.key) 101 | case .enumCase(let string): 102 | input.set(.enumCase(string), at: self.field.key) 103 | case .default: 104 | input.set(.default, at: self.field.key) 105 | default: 106 | fatalError("Unexpected input value type for '\(Model.self)'.'\(self.field.key)': \(value)") 107 | } 108 | } 109 | 110 | public func output(from output: any DatabaseOutput) throws { 111 | try self.field.output(from: output) 112 | } 113 | } 114 | 115 | // MARK: Codable 116 | 117 | extension EnumProperty: AnyCodableProperty { 118 | public func encode(to encoder: any Encoder) throws { 119 | var container = encoder.singleValueContainer() 120 | try container.encode(self.wrappedValue) 121 | } 122 | 123 | public func decode(from decoder: any Decoder) throws { 124 | let container = try decoder.singleValueContainer() 125 | self.value = try container.decode(Value.self) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/FluentKit/Enum/OptionalEnumProperty.swift: -------------------------------------------------------------------------------- 1 | extension Fields { 2 | public typealias OptionalEnum = OptionalEnumProperty 3 | where Value: Codable & Sendable, 4 | Value: RawRepresentable, 5 | Value.RawValue == String 6 | } 7 | 8 | // MARK: Type 9 | 10 | @propertyWrapper 11 | public final class OptionalEnumProperty 12 | where Model: FluentKit.Fields, 13 | WrappedValue: Codable & Sendable, 14 | WrappedValue: RawRepresentable, 15 | WrappedValue.RawValue == String 16 | { 17 | public let field: OptionalFieldProperty 18 | 19 | public var projectedValue: OptionalEnumProperty { 20 | self 21 | } 22 | 23 | public var wrappedValue: WrappedValue? { 24 | get { 25 | self.value ?? nil 26 | } 27 | set { 28 | self.value = .some(newValue) 29 | } 30 | } 31 | 32 | public init(key: FieldKey) { 33 | self.field = .init(key: key) 34 | } 35 | } 36 | 37 | // MARK: Property 38 | 39 | extension OptionalEnumProperty: AnyProperty { } 40 | 41 | extension OptionalEnumProperty: Property { 42 | public var value: WrappedValue?? { 43 | get { 44 | self.field.value.map { 45 | $0.map { 46 | WrappedValue(rawValue: $0)! 47 | } 48 | } 49 | } 50 | set { 51 | switch newValue { 52 | case .some(.some(let newValue)): 53 | self.field.value = .some(.some(newValue.rawValue)) 54 | case .some(.none): 55 | self.field.value = .some(.none) 56 | case .none: 57 | self.field.value = .none 58 | } 59 | } 60 | } 61 | } 62 | 63 | // MARK: Queryable 64 | 65 | extension OptionalEnumProperty: AnyQueryableProperty { 66 | public var path: [FieldKey] { 67 | self.field.path 68 | } 69 | } 70 | 71 | extension OptionalEnumProperty: QueryableProperty { 72 | public static func queryValue(_ value: Value) -> DatabaseQuery.Value { 73 | value.flatMap { .enumCase($0.rawValue) } ?? .null 74 | } 75 | } 76 | 77 | // MARK: Query-addressable 78 | 79 | extension OptionalEnumProperty: AnyQueryAddressableProperty { 80 | public var anyQueryableProperty: any AnyQueryableProperty { self } 81 | public var queryablePath: [FieldKey] { self.path } 82 | } 83 | 84 | extension OptionalEnumProperty: QueryAddressableProperty { 85 | public var queryableProperty: OptionalEnumProperty { self } 86 | } 87 | 88 | // MARK: Database 89 | 90 | extension OptionalEnumProperty: AnyDatabaseProperty { 91 | public var keys: [FieldKey] { 92 | self.field.keys 93 | } 94 | 95 | public func input(to input: any DatabaseInput) { 96 | let value: DatabaseQuery.Value 97 | if !input.wantsUnmodifiedKeys { 98 | guard let ivalue = self.field.inputValue else { return } 99 | value = ivalue 100 | } else { 101 | value = self.field.inputValue ?? .default 102 | } 103 | 104 | 105 | switch value { 106 | case .bind(let bind as String): 107 | input.set(.enumCase(bind), at: self.field.key) 108 | case .enumCase(let string): 109 | input.set(.enumCase(string), at: self.field.key) 110 | case .null: 111 | input.set(.null, at: self.field.key) 112 | case .default: 113 | input.set(.default, at: self.field.key) 114 | default: 115 | fatalError("Unexpected input value type for '\(Model.self)'.'\(self.field.key)': \(value)") 116 | } 117 | } 118 | 119 | public func output(from output: any DatabaseOutput) throws { 120 | try self.field.output(from: output) 121 | } 122 | } 123 | 124 | // MARK: Codable 125 | 126 | extension OptionalEnumProperty: AnyCodableProperty { 127 | public func encode(to encoder: any Encoder) throws { 128 | var container = encoder.singleValueContainer() 129 | try container.encode(self.wrappedValue) 130 | } 131 | 132 | public func decode(from decoder: any Decoder) throws { 133 | let container = try decoder.singleValueContainer() 134 | if container.decodeNil() { 135 | self.value = nil 136 | } else { 137 | self.value = try container.decode(Value.self) 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Sources/FluentKit/Exports.swift: -------------------------------------------------------------------------------- 1 | @_documentation(visibility: internal) @_exported import struct Foundation.Date 2 | @_documentation(visibility: internal) @_exported import struct Foundation.UUID 3 | 4 | @_documentation(visibility: internal) @_exported import Logging 5 | 6 | @_documentation(visibility: internal) @_exported import protocol NIO.EventLoop 7 | @_documentation(visibility: internal) @_exported import class NIO.EventLoopFuture 8 | @_documentation(visibility: internal) @_exported import struct NIO.EventLoopPromise 9 | @_documentation(visibility: internal) @_exported import protocol NIO.EventLoopGroup 10 | @_documentation(visibility: internal) @_exported import class NIO.NIOThreadPool 11 | -------------------------------------------------------------------------------- /Sources/FluentKit/Middleware/ModelMiddleware.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public protocol AnyModelMiddleware: Sendable { 4 | func handle( 5 | _ event: ModelEvent, 6 | _ model: any AnyModel, 7 | on db: any Database, 8 | chainingTo next: any AnyModelResponder 9 | ) -> EventLoopFuture 10 | } 11 | 12 | public protocol ModelMiddleware: AnyModelMiddleware { 13 | associatedtype Model: FluentKit.Model 14 | 15 | func create(model: Model, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture 16 | func update(model: Model, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture 17 | func delete(model: Model, force: Bool, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture 18 | func softDelete(model: Model, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture 19 | func restore(model: Model, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture 20 | } 21 | 22 | extension ModelMiddleware { 23 | public func handle(_ event: ModelEvent, _ model: any AnyModel, on db: any Database, chainingTo next: any AnyModelResponder) -> EventLoopFuture { 24 | guard let modelType = model as? Model else { 25 | return next.handle(event, model, on: db) 26 | } 27 | 28 | switch event { 29 | case .create: 30 | return create(model: modelType, on: db, next: next) 31 | case .update: 32 | return update(model: modelType, on: db, next: next) 33 | case .delete(let force): 34 | return delete(model: modelType, force: force, on: db, next: next) 35 | case .softDelete: 36 | return softDelete(model: modelType, on: db, next: next) 37 | case .restore: 38 | return restore(model: modelType, on: db, next: next) 39 | } 40 | } 41 | 42 | public func create(model: Model, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture { 43 | next.create(model, on: db) 44 | } 45 | 46 | public func update(model: Model, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture { 47 | next.update(model, on: db) 48 | } 49 | 50 | public func delete(model: Model, force: Bool, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture { 51 | next.delete(model, force: force, on: db) 52 | } 53 | 54 | public func softDelete(model: Model, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture { 55 | next.softDelete(model, on: db) 56 | } 57 | 58 | public func restore(model: Model, on db: any Database, next: any AnyModelResponder) -> EventLoopFuture { 59 | next.restore(model, on: db) 60 | } 61 | } 62 | 63 | extension AnyModelMiddleware { 64 | func makeResponder(chainingTo responder: any AnyModelResponder) -> any AnyModelResponder { 65 | ModelMiddlewareResponder(middleware: self, responder: responder) 66 | } 67 | } 68 | 69 | extension Array where Element == any AnyModelMiddleware { 70 | internal func chainingTo( 71 | _ type: Model.Type, 72 | closure: @escaping @Sendable (ModelEvent, Model, any Database) throws -> EventLoopFuture 73 | ) -> any AnyModelResponder where Model: FluentKit.Model { 74 | var responder: any AnyModelResponder = BasicModelResponder(handle: closure) 75 | for middleware in reversed() { 76 | responder = middleware.makeResponder(chainingTo: responder) 77 | } 78 | return responder 79 | } 80 | } 81 | 82 | private struct ModelMiddlewareResponder: AnyModelResponder { 83 | var middleware: any AnyModelMiddleware 84 | var responder: any AnyModelResponder 85 | 86 | func handle(_ event: ModelEvent, _ model: any AnyModel, on db: any Database) -> EventLoopFuture { 87 | self.middleware.handle(event, model, on: db, chainingTo: responder) 88 | } 89 | } 90 | 91 | public enum ModelEvent: Sendable { 92 | case create 93 | case update 94 | case delete(Bool) 95 | case restore 96 | case softDelete 97 | } 98 | -------------------------------------------------------------------------------- /Sources/FluentKit/Middleware/ModelResponder.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public protocol AnyModelResponder: Sendable { 4 | func handle( 5 | _ event: ModelEvent, 6 | _ model: any AnyModel, 7 | on db: any Database 8 | ) -> EventLoopFuture 9 | } 10 | 11 | extension AnyModelResponder { 12 | public func create(_ model: any AnyModel, on db: any Database) -> EventLoopFuture { 13 | self.handle(.create, model, on: db) 14 | } 15 | 16 | public func update(_ model: any AnyModel, on db: any Database) -> EventLoopFuture { 17 | self.handle(.update, model, on: db) 18 | } 19 | 20 | public func restore(_ model: any AnyModel, on db: any Database) -> EventLoopFuture { 21 | self.handle(.restore, model, on: db) 22 | } 23 | 24 | public func softDelete(_ model: any AnyModel, on db: any Database) -> EventLoopFuture { 25 | self.handle(.softDelete, model, on: db) 26 | } 27 | 28 | public func delete(_ model: any AnyModel, force: Bool, on db: any Database) -> EventLoopFuture { 29 | self.handle(.delete(force), model, on: db) 30 | } 31 | } 32 | 33 | internal struct BasicModelResponder: AnyModelResponder where Model: FluentKit.Model { 34 | private let _handle: @Sendable (ModelEvent, Model, any Database) throws -> EventLoopFuture 35 | 36 | internal func handle(_ event: ModelEvent, _ model: any AnyModel, on db: any Database) -> EventLoopFuture { 37 | guard let modelType = model as? Model else { 38 | fatalError("Could not convert type AnyModel to \(Model.self)") 39 | } 40 | 41 | do { 42 | return try self._handle(event, modelType, db) 43 | } catch { 44 | return db.eventLoop.makeFailedFuture(error) 45 | } 46 | } 47 | 48 | init(handle: @escaping @Sendable (ModelEvent, Model, any Database) throws -> EventLoopFuture) { 49 | self._handle = handle 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Sources/FluentKit/Migration/Migration.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | /// Fluent's `Migration` can handle database migrations, which can include 4 | /// adding new table, changing existing tables or adding 5 | /// seed data. These actions are executed only once. 6 | public protocol Migration: Sendable { 7 | 8 | /// The name of the migration which Fluent uses to track the state of. 9 | var name: String { get } 10 | 11 | /// Called when a migration is executed. 12 | /// - Parameters: 13 | /// - database: `Database` to run the migration on, 14 | /// - returns: An asynchronous `Void`. 15 | 16 | func prepare(on database: any Database) -> EventLoopFuture 17 | 18 | /// Called when the changes from a migration are reverted. 19 | /// - Parameters: 20 | /// - database: `Database` to revert the migration on. 21 | /// - returns: An asynchronous `Void`. 22 | func revert(on database: any Database) -> EventLoopFuture 23 | } 24 | 25 | extension Migration { 26 | public var name: String { 27 | self.defaultName 28 | } 29 | 30 | internal var defaultName: String { 31 | #if compiler(<6.2) 32 | /// `String.init(reflecting:)` creates a `Mirror` unconditionally, but 33 | /// when the parameter is a metatype (such as is the case here), that 34 | /// mirror is never actually used for anything. Unfortunately, just 35 | /// creating it slows this accessor down by at least 30% at best, and 36 | /// as much as 50% in some cases. Given that it's already incorrect to 37 | /// be depending on the stability of `String(reflecting:)`'s output 38 | /// anyway, it seems to make more sense to just call the underlying 39 | /// runtime function directly instead of taking the huge speed hit just 40 | /// because the leading underscore makes it harder to ignore the 41 | /// fragility of the usage. 42 | Swift._typeName(Self.self, qualified: true) 43 | #else 44 | String(reflecting: Self.self) 45 | #endif 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/FluentKit/Migration/MigrationLog.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import Foundation 3 | 4 | /// Stores information about `Migration`s that have been run. 5 | public final class MigrationLog: Model, @unchecked Sendable { 6 | public static let schema = "_fluent_migrations" 7 | 8 | public static var migration: any Migration { 9 | MigrationLogMigration() 10 | } 11 | 12 | @ID(key: .id) 13 | public var id: UUID? 14 | 15 | @Field(key: "name") 16 | public var name: String 17 | 18 | @Field(key: "batch") 19 | public var batch: Int 20 | 21 | @Timestamp(key: "created_at", on: .create) 22 | public var createdAt: Date? 23 | 24 | @Timestamp(key: "updated_at", on: .update) 25 | public var updatedAt: Date? 26 | 27 | public init() {} 28 | 29 | public init(id: IDValue? = nil, name: String, batch: Int) { 30 | self.id = id 31 | self.name = name 32 | self.batch = batch 33 | self.createdAt = nil 34 | self.updatedAt = nil 35 | } 36 | } 37 | 38 | private struct MigrationLogMigration: Migration { 39 | func prepare(on database: any Database) -> EventLoopFuture { 40 | database.schema(MigrationLog.schema) 41 | .field(.id, .uuid, .identifier(auto: false)) 42 | .field("name", .string, .required) 43 | .field("batch", .int, .required) 44 | .field("created_at", .datetime) 45 | .field("updated_at", .datetime) 46 | .unique(on: "name") 47 | .ignoreExisting() 48 | .create() 49 | } 50 | 51 | func revert(on database: any Database) -> EventLoopFuture { 52 | database.schema(MigrationLog.schema).delete() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/FluentKit/Migration/Migrations.swift: -------------------------------------------------------------------------------- 1 | import NIOConcurrencyHelpers 2 | 3 | public final class Migrations: Sendable { 4 | let storage: NIOLockedValueBox<[DatabaseID?: [any Migration]]> 5 | 6 | public init() { 7 | self.storage = .init([:]) 8 | } 9 | 10 | public func add(_ migration: any Migration, to id: DatabaseID? = nil) { 11 | self.storage.withLockedValue { $0[id, default: []].append(migration) } 12 | } 13 | 14 | @inlinable 15 | public func add(_ migrations: any Migration..., to id: DatabaseID? = nil) { 16 | self.add(migrations, to: id) 17 | } 18 | 19 | public func add(_ migrations: [any Migration], to id: DatabaseID? = nil) { 20 | self.storage.withLockedValue { $0[id, default: []].append(contentsOf: migrations) } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/FluentKit/Model/AnyModel.swift: -------------------------------------------------------------------------------- 1 | public protocol AnyModel: Schema, CustomStringConvertible { } 2 | 3 | extension AnyModel { 4 | public static var alias: String? { nil } 5 | } 6 | 7 | extension AnyModel { 8 | public var description: String { 9 | let input = self.collectInput() 10 | let info = [ 11 | "input": !input.isEmpty ? input.description : nil, 12 | "output": self.anyID.cachedOutput?.description 13 | ].compactMapValues({ $0 }) 14 | 15 | return "\(Self.self)(\(info.isEmpty ? ":" : info.map { "\($0): \($1)" }.joined(separator: ", ")))" 16 | } 17 | 18 | // MARK: Joined 19 | 20 | public func joined(_ model: Joined.Type) throws -> Joined 21 | where Joined: Schema 22 | { 23 | guard let output = self.anyID.cachedOutput else { 24 | fatalError("Can only access joined models using models fetched from database (from \(Self.self) to \(Joined.self)).") 25 | } 26 | let joined = Joined() 27 | try joined.output(from: output.qualifiedSchema(space: Joined.spaceIfNotAliased, Joined.schemaOrAlias)) 28 | return joined 29 | } 30 | 31 | var anyID: any AnyID { 32 | for (nameC, child) in _FastChildSequence(subject: self) { 33 | /// Match a property named `_id` which conforms to `AnyID`. `as?` is expensive, so check that last. 34 | if nameC?[0] == 0x5f/* '_' */, 35 | nameC?[1] == 0x69/* 'i' */, 36 | nameC?[2] == 0x64/* 'd' */, 37 | nameC?[3] == 0x00/* '\0' */, 38 | let idChild = child as? any AnyID 39 | { 40 | return idChild 41 | } 42 | } 43 | fatalError("id property for model \(Self.self) must be declared using @ID or @CompositeID") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/FluentKit/Model/EagerLoad.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public protocol EagerLoader: AnyEagerLoader { 4 | associatedtype Model: FluentKit.Model 5 | func run(models: [Model], on database: any Database) -> EventLoopFuture 6 | } 7 | 8 | extension EagerLoader { 9 | func anyRun(models: [any AnyModel], on database: any Database) -> EventLoopFuture { 10 | self.run(models: models.map { $0 as! Model }, on: database) 11 | } 12 | } 13 | 14 | public protocol AnyEagerLoader: Sendable { 15 | func anyRun(models: [any AnyModel], on database: any Database) -> EventLoopFuture 16 | } 17 | 18 | public protocol EagerLoadable { 19 | associatedtype From: Model 20 | associatedtype To: Model 21 | 22 | static func eagerLoad( 23 | _ relationKey: KeyPath, 24 | to builder: Builder 25 | ) where Builder: EagerLoadBuilder, Builder.Model == From 26 | 27 | static func eagerLoad( 28 | _ relationKey: KeyPath, 29 | withDeleted: Bool, 30 | to builder: Builder 31 | ) where Builder: EagerLoadBuilder, Builder.Model == From 32 | 33 | static func eagerLoad( 34 | _ loader: Loader, 35 | through: KeyPath, 36 | to builder: Builder 37 | ) where Loader: EagerLoader, 38 | Builder: EagerLoadBuilder, 39 | Loader.Model == To, 40 | Builder.Model == From 41 | } 42 | 43 | extension EagerLoadable { 44 | public static func eagerLoad( 45 | _ relationKey: KeyPath, 46 | withDeleted: Bool, 47 | to builder: Builder 48 | ) where Builder: EagerLoadBuilder, Builder.Model == From { 49 | Self.eagerLoad(relationKey, to: builder) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/FluentKit/Model/Fields+Codable.swift: -------------------------------------------------------------------------------- 1 | import struct SQLKit.SomeCodingKey 2 | 3 | extension Fields { 4 | public init(from decoder: any Decoder) throws { 5 | self.init() 6 | 7 | let container = try decoder.container(keyedBy: SomeCodingKey.self) 8 | 9 | for (key, property) in self.codableProperties { 10 | let propDecoder = try container.superDecoder(forKey: key) 11 | do { 12 | try property.decode(from: propDecoder) 13 | } catch { 14 | throw DecodingError.typeMismatch(type(of: property).anyValueType, .init( 15 | codingPath: container.codingPath + [key], 16 | debugDescription: "Could not decode property", 17 | underlyingError: error 18 | )) 19 | } 20 | } 21 | } 22 | 23 | public func encode(to encoder: any Encoder) throws { 24 | var container = encoder.container(keyedBy: SomeCodingKey.self) 25 | 26 | for (key, property) in self.codableProperties where !property.skipPropertyEncoding { 27 | do { 28 | try property.encode(to: container.superEncoder(forKey: key)) 29 | } catch let error where error is EncodingError { // trapping all errors breaks value handling logic in database driver layers 30 | throw EncodingError.invalidValue(property.anyValue ?? "null", .init( 31 | codingPath: container.codingPath + [key], 32 | debugDescription: "Could not encode property", 33 | underlyingError: error 34 | )) 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/FluentKit/Model/Model.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | 3 | public protocol Model: AnyModel { 4 | associatedtype IDValue: Codable, Hashable, Sendable 5 | var id: IDValue? { get set } 6 | } 7 | 8 | extension Model { 9 | public static func query(on database: any Database) -> QueryBuilder { 10 | .init(database: database) 11 | } 12 | 13 | public static func find( 14 | _ id: Self.IDValue?, 15 | on database: any Database 16 | ) -> EventLoopFuture { 17 | guard let id = id else { 18 | return database.eventLoop.makeSucceededFuture(nil) 19 | } 20 | return Self.query(on: database) 21 | .filter(id: id) 22 | .first() 23 | } 24 | 25 | public func requireID() throws -> IDValue { 26 | guard let id = self.id else { 27 | throw FluentError.idRequired 28 | } 29 | return id 30 | } 31 | 32 | /// Replaces the existing common usage of `model._$id.exists`, which indicates whether any 33 | /// particular generic model has a non-`nil` ID that was loaded from a database query (or 34 | /// was overridden to allow Fluent to assume as such without having to check first). This 35 | /// version works for models which use `@CompositeID()`. It would not be necessary if 36 | /// support existed for property wrappers in protocols. 37 | /// 38 | /// > Note: Adding this property to ``Model`` rather than making the ``AnyID`` protocol 39 | /// > and ``anyID`` property public was chosen because implementing a new conformance for 40 | /// > ``AnyID`` can not be done correctly from outside FluentKit; it would be mostly useless 41 | /// > and potentially confusing public API surface. 42 | public var _$idExists: Bool { 43 | get { self.anyID.exists } 44 | set { self.anyID.exists = newValue } 45 | } 46 | 47 | public var _$id: ID { 48 | self.anyID as! ID 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/FluentKit/Model/Schema.swift: -------------------------------------------------------------------------------- 1 | public protocol Schema: Fields { 2 | static var space: String? { get } 3 | static var schema: String { get } 4 | static var alias: String? { get } 5 | } 6 | 7 | extension Schema { 8 | public static var space: String? { nil } 9 | 10 | public static var schemaOrAlias: String { 11 | self.alias ?? self.schema 12 | } 13 | 14 | public static var spaceIfNotAliased: String? { 15 | self.alias == nil ? self.space : nil 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/FluentKit/Operators/Operators.swift: -------------------------------------------------------------------------------- 1 | infix operator ~~ 2 | infix operator !~ 3 | infix operator =~ 4 | infix operator !~= 5 | infix operator !=~ 6 | -------------------------------------------------------------------------------- /Sources/FluentKit/Operators/ValueOperators+Array.swift: -------------------------------------------------------------------------------- 1 | // MARK: Field.Value 2 | 3 | public func ~~ (lhs: KeyPath, rhs: Values) -> ModelValueFilter 4 | where Model: FluentKit.Schema, 5 | Field: QueryableProperty, 6 | Values: Collection, 7 | Values.Element == Field.Value 8 | { 9 | lhs ~~ .array(rhs.map { Field.queryValue($0) }) 10 | } 11 | 12 | public func ~~ (lhs: KeyPath, rhs: Values) -> ModelValueFilter 13 | where Model: FluentKit.Schema, 14 | Field: QueryableProperty, 15 | Field.Value: OptionalType, 16 | Field.Value.Wrapped: Codable, 17 | Values: Collection, 18 | Values.Element == Field.Value.Wrapped 19 | { 20 | lhs ~~ .array(rhs.map { Field.queryValue(.init($0)) }) 21 | } 22 | 23 | public func !~ (lhs: KeyPath, rhs: Values) -> ModelValueFilter 24 | where Model: FluentKit.Schema, 25 | Field: QueryableProperty, 26 | Values: Collection, 27 | Values.Element == Field.Value 28 | { 29 | lhs !~ .array(rhs.map { Field.queryValue($0) }) 30 | } 31 | 32 | public func !~ (lhs: KeyPath, rhs: Values) -> ModelValueFilter 33 | where Model: FluentKit.Schema, 34 | Field: QueryableProperty, 35 | Field.Value: OptionalType, 36 | Field.Value.Wrapped: Codable, 37 | Values: Collection, 38 | Values.Element == Field.Value.Wrapped 39 | { 40 | lhs !~ .array(rhs.map { Field.queryValue(.init($0)) }) 41 | } 42 | 43 | // MARK: DatabaseQuery.Value 44 | 45 | public func ~~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter 46 | where Model: FluentKit.Schema, Field: QueryableProperty 47 | { 48 | .init(lhs, .subset(inverse: false), rhs) 49 | } 50 | 51 | public func !~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter 52 | where Model: FluentKit.Schema, Field: QueryableProperty 53 | { 54 | .init(lhs, .subset(inverse: true), rhs) 55 | } 56 | -------------------------------------------------------------------------------- /Sources/FluentKit/Properties/CompositeID.swift: -------------------------------------------------------------------------------- 1 | import NIOConcurrencyHelpers 2 | 3 | extension Model { 4 | public typealias CompositeID = CompositeIDProperty 5 | where Value: Fields 6 | } 7 | 8 | // MARK: Type 9 | 10 | @propertyWrapper @dynamicMemberLookup 11 | public final class CompositeIDProperty: @unchecked Sendable 12 | where Model: FluentKit.Model, Value: FluentKit.Fields 13 | { 14 | public var value: Value? = .init(.init()) 15 | public var exists: Bool = false 16 | var cachedOutput: (any DatabaseOutput)? 17 | 18 | public var projectedValue: CompositeIDProperty { self } 19 | 20 | public var wrappedValue: Value? { 21 | get { self.value } 22 | set { self.value = newValue } 23 | } 24 | 25 | public init() {} 26 | 27 | public subscript( 28 | dynamicMember keyPath: KeyPath 29 | ) -> Nested 30 | where Nested: Property 31 | { 32 | self.value![keyPath: keyPath] 33 | } 34 | } 35 | 36 | extension CompositeIDProperty: CustomStringConvertible { 37 | public var description: String { 38 | "@\(Model.self).CompositeID<\(Value.self))>()" 39 | } 40 | } 41 | 42 | // MARK: Property 43 | 44 | extension CompositeIDProperty: AnyProperty, Property {} 45 | 46 | // MARK: Database 47 | 48 | extension CompositeIDProperty: AnyDatabaseProperty { 49 | public var keys: [FieldKey] { 50 | Value.keys 51 | } 52 | 53 | public func input(to input: any DatabaseInput) { 54 | if let value = self.value { 55 | value.input(to: input) 56 | } 57 | } 58 | 59 | public func output(from output: any DatabaseOutput) throws { 60 | self.exists = true 61 | self.cachedOutput = output 62 | try self.value!.output(from: output) 63 | } 64 | } 65 | 66 | // MARK: Codable 67 | 68 | extension CompositeIDProperty: AnyCodableProperty { 69 | public func encode(to encoder: any Encoder) throws { 70 | var container = encoder.singleValueContainer() 71 | try container.encode(self.value) 72 | } 73 | 74 | public func decode(from decoder: any Decoder) throws { 75 | let container = try decoder.singleValueContainer() 76 | self.value = try container.decode(Value?.self) 77 | } 78 | } 79 | 80 | // MARK: AnyID 81 | 82 | extension CompositeIDProperty: AnyID { 83 | func generate() {} 84 | } 85 | -------------------------------------------------------------------------------- /Sources/FluentKit/Properties/FieldKey.swift: -------------------------------------------------------------------------------- 1 | public indirect enum FieldKey: Sendable { 2 | case id 3 | case string(String) 4 | case aggregate 5 | case prefix(FieldKey, FieldKey) 6 | } 7 | 8 | extension FieldKey: ExpressibleByStringLiteral { 9 | public init(stringLiteral value: String) { 10 | switch value { 11 | case "id", "_id": 12 | self = .id 13 | default: 14 | self = .string(value) 15 | } 16 | } 17 | } 18 | 19 | extension FieldKey: CustomStringConvertible { 20 | public var description: String { 21 | switch self { 22 | case .id: 23 | "id" 24 | case .string(let name): 25 | name 26 | case .aggregate: 27 | "aggregate" 28 | case .prefix(let prefix, let key): 29 | prefix.description + key.description 30 | } 31 | } 32 | } 33 | 34 | extension FieldKey: Equatable { } 35 | 36 | extension FieldKey: Hashable { } 37 | -------------------------------------------------------------------------------- /Sources/FluentKit/Properties/OptionalField.swift: -------------------------------------------------------------------------------- 1 | import NIOConcurrencyHelpers 2 | 3 | extension Fields { 4 | public typealias OptionalField = OptionalFieldProperty 5 | where Value: Codable & Sendable 6 | } 7 | 8 | // MARK: Type 9 | 10 | @propertyWrapper 11 | public final class OptionalFieldProperty: @unchecked Sendable 12 | where Model: FluentKit.Fields, WrappedValue: Codable & Sendable 13 | { 14 | public let key: FieldKey 15 | var outputValue: WrappedValue?? 16 | var inputValue: DatabaseQuery.Value? 17 | 18 | public var projectedValue: OptionalFieldProperty { 19 | self 20 | } 21 | 22 | public var wrappedValue: WrappedValue? { 23 | get { self.value ?? nil } 24 | set { self.value = .some(newValue) } 25 | } 26 | 27 | public init(key: FieldKey) { 28 | self.key = key 29 | } 30 | } 31 | 32 | // MARK: Property 33 | 34 | extension OptionalFieldProperty: AnyProperty { } 35 | 36 | extension OptionalFieldProperty: Property { 37 | public var value: WrappedValue?? { 38 | get { 39 | if let value = self.inputValue { 40 | switch value { 41 | case .bind(let bind): 42 | .some(bind as? WrappedValue) 43 | case .enumCase(let string): 44 | .some(string as? WrappedValue) 45 | case .default: 46 | fatalError("Cannot access default field for '\(Model.self).\(key)' before it is initialized or fetched") 47 | case .null: 48 | .some(.none) 49 | default: 50 | fatalError("Unexpected input value type for '\(Model.self).\(key)': \(value)") 51 | } 52 | } else if let value = self.outputValue { 53 | .some(value) 54 | } else { 55 | .none 56 | } 57 | } 58 | set { 59 | if let value = newValue { 60 | self.inputValue = value 61 | .flatMap { .bind($0) } 62 | ?? .null 63 | } else { 64 | self.inputValue = nil 65 | } 66 | } 67 | } 68 | } 69 | 70 | // MARK: Queryable 71 | 72 | extension OptionalFieldProperty: AnyQueryableProperty { 73 | public var path: [FieldKey] { 74 | [self.key] 75 | } 76 | } 77 | 78 | extension OptionalFieldProperty: QueryableProperty { } 79 | 80 | // MARK: Query-addressable 81 | 82 | extension OptionalFieldProperty: AnyQueryAddressableProperty { 83 | public var anyQueryableProperty: any AnyQueryableProperty { self } 84 | public var queryablePath: [FieldKey] { self.path } 85 | } 86 | 87 | extension OptionalFieldProperty: QueryAddressableProperty { 88 | public var queryableProperty: OptionalFieldProperty { self } 89 | } 90 | 91 | // MARK: Database 92 | 93 | extension OptionalFieldProperty: AnyDatabaseProperty { 94 | public var keys: [FieldKey] { 95 | [self.key] 96 | } 97 | 98 | public func input(to input: any DatabaseInput) { 99 | if input.wantsUnmodifiedKeys { 100 | input.set(self.inputValue ?? self.outputValue.map { $0.map { .bind($0) } ?? .null } ?? .default, at: self.key) 101 | } else if let inputValue = self.inputValue { 102 | input.set(inputValue, at: self.key) 103 | } 104 | } 105 | 106 | public func output(from output: any DatabaseOutput) throws { 107 | if output.contains(self.key) { 108 | self.inputValue = nil 109 | do { 110 | if try output.decodeNil(self.key) { 111 | self.outputValue = .some(nil) 112 | } else { 113 | self.outputValue = try .some(output.decode(self.key, as: Value.self)) 114 | } 115 | } catch { 116 | throw FluentError.invalidField( 117 | name: self.key.description, 118 | valueType: Value.self, 119 | error: error 120 | ) 121 | } 122 | } 123 | } 124 | } 125 | 126 | // MARK: Codable 127 | 128 | extension OptionalFieldProperty: AnyCodableProperty { 129 | public func encode(to encoder: any Encoder) throws { 130 | var container = encoder.singleValueContainer() 131 | try container.encode(self.wrappedValue) 132 | } 133 | 134 | public func decode(from decoder: any Decoder) throws { 135 | let container = try decoder.singleValueContainer() 136 | if container.decodeNil() { 137 | self.value = nil 138 | } else { 139 | self.value = try container.decode(Value.self) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/FluentKit/Properties/TimestampFormat.swift: -------------------------------------------------------------------------------- 1 | import NIOConcurrencyHelpers 2 | import class NIOPosix.ThreadSpecificVariable 3 | import Foundation 4 | 5 | // MARK: Format 6 | 7 | public protocol TimestampFormat: Sendable { 8 | associatedtype Value: Codable & Sendable 9 | 10 | func parse(_ value: Value) -> Date? 11 | func serialize(_ date: Date) -> Value? 12 | } 13 | 14 | public struct TimestampFormatFactory { 15 | public let makeFormat: () -> Format 16 | 17 | public init(_ makeFormat: @escaping () -> Format) { 18 | self.makeFormat = makeFormat 19 | } 20 | } 21 | 22 | // MARK: Default 23 | 24 | extension TimestampFormatFactory { 25 | public static var `default`: TimestampFormatFactory { 26 | .init { 27 | DefaultTimestampFormat() 28 | } 29 | } 30 | } 31 | 32 | public struct DefaultTimestampFormat: TimestampFormat { 33 | public typealias Value = Date 34 | 35 | public func parse(_ value: Date) -> Date? { 36 | value 37 | } 38 | 39 | public func serialize(_ date: Date) -> Date? { 40 | date 41 | } 42 | } 43 | 44 | 45 | // MARK: ISO8601 46 | 47 | extension TimestampFormatFactory { 48 | public static var iso8601: TimestampFormatFactory { 49 | .iso8601(withMilliseconds: false) 50 | } 51 | 52 | public static func iso8601( 53 | withMilliseconds: Bool 54 | ) -> TimestampFormatFactory { 55 | .init { 56 | ISO8601TimestampFormat(formatter: (withMilliseconds ? 57 | ISO8601DateFormatter.sharedWithMs : 58 | ISO8601DateFormatter.sharedWithoutMs 59 | ).value) 60 | } 61 | } 62 | } 63 | 64 | extension ISO8601DateFormatter { 65 | // We use this to suppress warnings about ISO8601DateFormatter not being Sendable. It's safe to do so because we 66 | // know that in reality, the formatter is safe to use simultaneously from multiple threads as long as the options 67 | // are not changed, and we never change the options after the formatter is first created. 68 | fileprivate struct FakeSendable: @unchecked Sendable { let value: T } 69 | 70 | fileprivate static let sharedWithoutMs: FakeSendable = .init(value: .init()) 71 | fileprivate static let sharedWithMs: FakeSendable = { 72 | let formatter = ISO8601DateFormatter() 73 | formatter.formatOptions.insert(.withFractionalSeconds) 74 | return .init(value: formatter) 75 | }() 76 | } 77 | 78 | public struct ISO8601TimestampFormat: TimestampFormat, @unchecked Sendable { 79 | public typealias Value = String 80 | 81 | let formatter: ISO8601DateFormatter 82 | 83 | public func parse(_ value: String) -> Date? { 84 | self.formatter.date(from: value) 85 | } 86 | 87 | public func serialize(_ date: Date) -> String? { 88 | self.formatter.string(from: date) 89 | } 90 | } 91 | 92 | // MARK: Unix 93 | 94 | 95 | extension TimestampFormatFactory { 96 | public static var unix: TimestampFormatFactory { 97 | .init { 98 | UnixTimestampFormat() 99 | } 100 | } 101 | } 102 | 103 | public struct UnixTimestampFormat: TimestampFormat { 104 | public typealias Value = Double 105 | 106 | public func parse(_ value: Double) -> Date? { 107 | Date(timeIntervalSince1970: value) 108 | } 109 | 110 | public func serialize(_ date: Date) -> Double? { 111 | date.timeIntervalSince1970 112 | } 113 | } 114 | 115 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Builder/QueryBuilder+EagerLoad.swift: -------------------------------------------------------------------------------- 1 | extension QueryBuilder: EagerLoadBuilder { 2 | public func add(loader: Loader) 3 | where Loader: EagerLoader, Loader.Model == Model 4 | { 5 | self.eagerLoaders.append(loader) 6 | } 7 | } 8 | 9 | public protocol EagerLoadBuilder { 10 | associatedtype Model: FluentKit.Model 11 | func add(loader: Loader) 12 | where Loader: EagerLoader, Loader.Model == Model 13 | } 14 | 15 | 16 | extension EagerLoadBuilder { 17 | // MARK: Eager Load 18 | 19 | @discardableResult 20 | public func with(_ relationKey: KeyPath) -> Self 21 | where Relation: EagerLoadable, Relation.From == Model 22 | { 23 | Relation.eagerLoad(relationKey, to: self) 24 | return self 25 | } 26 | 27 | @discardableResult 28 | public func with( 29 | _ throughKey: KeyPath, 30 | _ nested: (NestedEagerLoadBuilder) throws -> () 31 | ) rethrows -> Self 32 | where Relation: EagerLoadable, Relation.From == Model 33 | { 34 | Relation.eagerLoad(throughKey, to: self) 35 | let builder = NestedEagerLoadBuilder(builder: self, throughKey) 36 | try nested(builder) 37 | return self 38 | } 39 | 40 | @discardableResult 41 | public func with( 42 | _ relationKey: KeyPath, 43 | withDeleted: Bool 44 | ) -> Self 45 | where Relation: EagerLoadable, Relation.From == Model 46 | { 47 | Relation.eagerLoad(relationKey, withDeleted: withDeleted, to: self) 48 | return self 49 | } 50 | 51 | @discardableResult 52 | public func with( 53 | _ throughKey: KeyPath, 54 | withDeleted: Bool, 55 | _ nested: (NestedEagerLoadBuilder) throws -> () 56 | ) rethrows -> Self 57 | where Relation: EagerLoadable, Relation.From == Model 58 | { 59 | Relation.eagerLoad(throughKey, withDeleted: withDeleted, to: self) 60 | let builder = NestedEagerLoadBuilder(builder: self, throughKey) 61 | try nested(builder) 62 | return self 63 | } 64 | } 65 | 66 | public struct NestedEagerLoadBuilder: EagerLoadBuilder 67 | where Builder: EagerLoadBuilder, 68 | Relation: EagerLoadable, 69 | Builder.Model == Relation.From 70 | { 71 | public typealias Model = Relation.To 72 | let builder: Builder 73 | let relationKey: KeyPath 74 | 75 | init(builder: Builder, _ relationKey: KeyPath) { 76 | self.builder = builder 77 | self.relationKey = relationKey 78 | } 79 | 80 | public func add(loader: Loader) 81 | where Loader: EagerLoader, Loader.Model == Relation.To 82 | { 83 | Relation.eagerLoad(loader, through: self.relationKey, to: self.builder) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Builder/QueryBuilder+Filter.swift: -------------------------------------------------------------------------------- 1 | extension QueryBuilder { 2 | // MARK: Filter 3 | 4 | @discardableResult 5 | func filter(id: Model.IDValue) -> Self { 6 | if let fields = id as? any Fields { 7 | self.group(.and) { fields.input(to: QueryFilterInput(builder: $0)) } 8 | } else { 9 | self.filter(\Model._$id == id) 10 | } 11 | } 12 | 13 | @discardableResult 14 | func filter(ids: [Model.IDValue]) -> Self { 15 | guard let firstId = ids.first else { return self.limit(0) } 16 | return if firstId is any Fields { 17 | self.group(.or) { q in ids.forEach { id in q.group(.and) { (id as! any Fields).input(to: QueryFilterInput(builder: $0)) } } } 18 | } else { 19 | self.filter(\Model._$id ~~ ids) 20 | } 21 | } 22 | 23 | @discardableResult 24 | public func filter( 25 | _ field: KeyPath, 26 | _ method: DatabaseQuery.Filter.Method, 27 | _ value: Field.Value 28 | ) -> Self 29 | where Field: QueryableProperty, Field.Model == Model 30 | { 31 | self.filter(.extendedPath( 32 | Model.path(for: field), 33 | schema: Model.schemaOrAlias, 34 | space: Model.spaceIfNotAliased 35 | ), method, Field.queryValue(value)) 36 | } 37 | 38 | @discardableResult 39 | public func filter( 40 | _ joined: Joined.Type, 41 | _ field: KeyPath, 42 | _ method: DatabaseQuery.Filter.Method, 43 | _ value: Field.Value 44 | ) -> Self 45 | where Joined: Schema, Field: QueryableProperty, Field.Model == Joined 46 | { 47 | self.filter(.extendedPath( 48 | Joined.path(for: field), 49 | schema: Joined.schemaOrAlias, 50 | space: Joined.spaceIfNotAliased 51 | ), method, Field.queryValue(value)) 52 | } 53 | 54 | @discardableResult 55 | public func filter( 56 | _ lhsField: KeyPath, 57 | _ method: DatabaseQuery.Filter.Method, 58 | _ rhsField: KeyPath 59 | ) -> Self 60 | where Left: QueryableProperty, 61 | Left.Model == Model, 62 | Right: QueryableProperty, 63 | Right.Model == Model 64 | { 65 | self.filter(Model.path(for: lhsField), method, Model.path(for: rhsField)) 66 | } 67 | 68 | @discardableResult 69 | public func filter( 70 | _ fieldName: FieldKey, 71 | _ method: DatabaseQuery.Filter.Method, 72 | _ value: Value 73 | ) -> Self 74 | where Value: Codable & Sendable 75 | { 76 | self.filter([fieldName], method, value) 77 | } 78 | 79 | @discardableResult 80 | public func filter( 81 | _ fieldPath: [FieldKey], 82 | _ method: DatabaseQuery.Filter.Method, 83 | _ value: Value 84 | ) -> Self 85 | where Value: Codable & Sendable 86 | { 87 | self.filter( 88 | .extendedPath(fieldPath, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), 89 | method, 90 | .bind(value) 91 | ) 92 | } 93 | 94 | @discardableResult 95 | public func filter( 96 | _ leftName: FieldKey, 97 | _ method: DatabaseQuery.Filter.Method, 98 | _ rightName: FieldKey 99 | ) -> Self { 100 | self.filter([leftName], method, [rightName]) 101 | } 102 | 103 | @discardableResult 104 | public func filter( 105 | _ leftPath: [FieldKey], 106 | _ method: DatabaseQuery.Filter.Method, 107 | _ rightPath: [FieldKey] 108 | ) -> Self { 109 | self.filter( 110 | .extendedPath(leftPath, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), 111 | method, 112 | .extendedPath(rightPath, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased) 113 | ) 114 | } 115 | 116 | @discardableResult 117 | public func filter( 118 | _ field: DatabaseQuery.Field, 119 | _ method: DatabaseQuery.Filter.Method, 120 | _ value: DatabaseQuery.Value 121 | ) -> Self { 122 | self.filter(.value(field, method, value)) 123 | } 124 | 125 | @discardableResult 126 | public func filter( 127 | _ lhsField: DatabaseQuery.Field, 128 | _ method: DatabaseQuery.Filter.Method, 129 | _ rhsField: DatabaseQuery.Field 130 | ) -> Self { 131 | self.filter(.field(lhsField, method, rhsField)) 132 | } 133 | 134 | @discardableResult 135 | public func filter(_ filter: DatabaseQuery.Filter) -> Self { 136 | self.query.filters.append(filter) 137 | return self 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Builder/QueryBuilder+Group.swift: -------------------------------------------------------------------------------- 1 | extension QueryBuilder { 2 | @discardableResult 3 | public func group( 4 | _ relation: DatabaseQuery.Filter.Relation = .and, 5 | _ closure: (QueryBuilder) throws -> () 6 | ) rethrows -> Self { 7 | let group = QueryBuilder(database: self.database) 8 | try closure(group) 9 | if !group.query.filters.isEmpty { 10 | self.query.filters.append(.group(group.query.filters, relation)) 11 | } 12 | return self 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Builder/QueryBuilder+Range.swift: -------------------------------------------------------------------------------- 1 | extension QueryBuilder { 2 | // MARK: Range 3 | 4 | /// Limits the results of this query to the specified range. 5 | /// 6 | /// query.range(2..<5) // returns at most 3 results, offset by 2 7 | /// 8 | /// - returns: Query builder for chaining. 9 | @discardableResult 10 | public func range(_ range: Range) -> Self { 11 | self.range(lower: range.lowerBound, upper: range.upperBound - 1) 12 | } 13 | 14 | /// Limits the results of this query to the specified range. 15 | /// 16 | /// query.range(...5) // returns at most 6 results 17 | /// 18 | /// - returns: Query builder for chaining. 19 | @discardableResult 20 | public func range(_ range: PartialRangeThrough) -> Self { 21 | self.range(upper: range.upperBound) 22 | } 23 | 24 | /// Limits the results of this query to the specified range. 25 | /// 26 | /// query.range(..<5) // returns at most 5 results 27 | /// 28 | /// - returns: Query builder for chaining. 29 | @discardableResult 30 | public func range(_ range: PartialRangeUpTo) -> Self { 31 | self.range(upper: range.upperBound - 1) 32 | } 33 | 34 | /// Limits the results of this query to the specified range. 35 | /// 36 | /// query.range(5...) // offsets the result by 5 37 | /// 38 | /// - returns: Query builder for chaining. 39 | @discardableResult 40 | public func range(_ range: PartialRangeFrom) -> Self { 41 | self.range(lower: range.lowerBound) 42 | } 43 | 44 | /// Limits the results of this query to the specified range. 45 | /// 46 | /// query.range(2..<5) // returns at most 3 results, offset by 2 47 | /// 48 | /// - returns: Query builder for chaining. 49 | @discardableResult 50 | public func range(_ range: ClosedRange) -> Self { 51 | self.range(lower: range.lowerBound, upper: range.upperBound) 52 | } 53 | 54 | /// Limits the results of this query to the specified range. 55 | /// 56 | /// - parameters: 57 | /// - lower: Amount to offset the query by. 58 | /// - upper: `upper` - `lower` = maximum results. 59 | /// - returns: Query builder for chaining. 60 | @discardableResult 61 | public func range(lower: Int = 0, upper: Int? = nil) -> Self { 62 | self.query.offsets.append(.count(lower)) 63 | upper.flatMap { upper in 64 | self.query.limits.append(.count((upper - lower) + 1)) 65 | } 66 | return self 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Builder/QueryBuilder+Set.swift: -------------------------------------------------------------------------------- 1 | extension QueryBuilder { 2 | // MARK: Set 3 | 4 | @discardableResult 5 | public func set(_ data: [FieldKey: DatabaseQuery.Value]) -> Self { 6 | self.set([data]) 7 | } 8 | 9 | @discardableResult 10 | public func set(_ data: [[FieldKey: DatabaseQuery.Value]]) -> Self { 11 | self.query.input = data.map { 12 | .dictionary($0) 13 | } 14 | return self 15 | } 16 | 17 | // MARK: Set 18 | 19 | @discardableResult 20 | public func set( 21 | _ field: KeyPath, 22 | to value: Field.Value 23 | ) -> Self 24 | where Field: QueryableProperty, Field.Model == Model.IDValue 25 | { 26 | if self.query.input.isEmpty { 27 | self.query.input = [.dictionary([:])] 28 | } 29 | 30 | switch self.query.input[0] { 31 | case .dictionary(var existing): 32 | let path = Model.path(for: field) 33 | assert(path.count == 1, "Set on nested properties is not yet supported.") 34 | existing[path[0]] = Field.queryValue(value) 35 | self.query.input[0] = .dictionary(existing) 36 | default: 37 | fatalError() 38 | } 39 | 40 | return self 41 | } 42 | 43 | @discardableResult 44 | public func set( 45 | _ field: KeyPath, 46 | to value: Field.Value 47 | ) -> Self 48 | where Field: QueryableProperty, Field.Model == Model 49 | { 50 | if self.query.input.isEmpty { 51 | self.query.input = [.dictionary([:])] 52 | } 53 | 54 | switch self.query.input[0] { 55 | case .dictionary(var existing): 56 | let path = Model.path(for: field) 57 | assert(path.count == 1, "Set on nested properties is not yet supported.") 58 | existing[path[0]] = Field.queryValue(value) 59 | self.query.input[0] = .dictionary(existing) 60 | default: 61 | fatalError() 62 | } 63 | 64 | return self 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Builder/QueryBuilder+Sort.swift: -------------------------------------------------------------------------------- 1 | extension QueryBuilder { 2 | // MARK: Sort 3 | 4 | @discardableResult 5 | public func sort( 6 | _ field: KeyPath, 7 | _ direction: DatabaseQuery.Sort.Direction = .ascending 8 | ) -> Self 9 | where 10 | Field: QueryableProperty, 11 | Field.Model == Model 12 | { 13 | self.sort(Model.path(for: field), direction) 14 | } 15 | 16 | @discardableResult 17 | public func sort( 18 | _ field: KeyPath>, 19 | _ direction: DatabaseQuery.Sort.Direction = .ascending 20 | ) -> Self 21 | where Field: QueryableProperty 22 | { 23 | self.sort(Model.path(for: field), direction) 24 | } 25 | 26 | @discardableResult 27 | public func sort( 28 | _ path: FieldKey, 29 | _ direction: DatabaseQuery.Sort.Direction = .ascending 30 | ) -> Self { 31 | self.sort([path], direction) 32 | } 33 | 34 | @discardableResult 35 | public func sort( 36 | _ path: [FieldKey], 37 | _ direction: DatabaseQuery.Sort.Direction = .ascending 38 | ) -> Self { 39 | self.sort(.extendedPath(path, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), direction) 40 | } 41 | 42 | @discardableResult 43 | public func sort( 44 | _ joined: Joined.Type, 45 | _ field: KeyPath, 46 | _ direction: DatabaseQuery.Sort.Direction = .ascending, 47 | alias: String? = nil 48 | ) -> Self 49 | where 50 | Field: QueryableProperty, 51 | Field.Model == Joined, 52 | Joined: Schema 53 | { 54 | self.sort(Joined.self, Joined.path(for: field), direction, alias: alias) 55 | } 56 | 57 | @discardableResult 58 | public func sort( 59 | _ model: Joined.Type, 60 | _ path: FieldKey, 61 | _ direction: DatabaseQuery.Sort.Direction = .ascending, 62 | alias: String? = nil 63 | ) -> Self 64 | where Joined: Schema 65 | { 66 | self.sort(Joined.self, [path], direction) 67 | } 68 | 69 | @discardableResult 70 | public func sort( 71 | _ model: Joined.Type, 72 | _ path: [FieldKey], 73 | _ direction: DatabaseQuery.Sort.Direction = .ascending, 74 | alias: String? = nil 75 | ) -> Self 76 | where Joined: Schema 77 | { 78 | self.sort(.extendedPath(path, schema: Joined.schemaOrAlias, space: Joined.spaceIfNotAliased), direction) 79 | } 80 | 81 | @discardableResult 82 | public func sort( 83 | _ field: DatabaseQuery.Field, 84 | _ direction: DatabaseQuery.Sort.Direction 85 | ) -> Self { 86 | self.sort(.sort(field, direction)) 87 | } 88 | 89 | @discardableResult 90 | public func sort(_ sort: DatabaseQuery.Sort) -> Self { 91 | self.query.sorts.append(sort) 92 | return self 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Database/DatabaseQuery+Action.swift: -------------------------------------------------------------------------------- 1 | extension DatabaseQuery { 2 | public enum Action: Sendable { 3 | case create 4 | case read 5 | case update 6 | case delete 7 | case aggregate(Aggregate) 8 | case custom(any Sendable) 9 | } 10 | } 11 | 12 | extension DatabaseQuery.Action: CustomStringConvertible { 13 | public var description: String { 14 | switch self { 15 | case .create: 16 | "create" 17 | case .read: 18 | "read" 19 | case .update: 20 | "update" 21 | case .delete: 22 | "delete" 23 | case .aggregate(let aggregate): 24 | "aggregate(\(aggregate))" 25 | case .custom(let custom): 26 | "custom(\(custom))" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Database/DatabaseQuery+Aggregate.swift: -------------------------------------------------------------------------------- 1 | extension DatabaseQuery { 2 | public enum Aggregate: Sendable { 3 | public enum Method: Sendable { 4 | case count 5 | case sum 6 | case average 7 | case minimum 8 | case maximum 9 | case custom(any Sendable) 10 | } 11 | case field(Field, Method) 12 | case custom(any Sendable) 13 | } 14 | } 15 | 16 | extension DatabaseQuery.Aggregate: CustomStringConvertible { 17 | public var description: String { 18 | switch self { 19 | case .field(let field, let method): 20 | "\(method)(\(field))" 21 | case .custom(let custom): 22 | "custom(\(custom))" 23 | } 24 | } 25 | } 26 | 27 | extension DatabaseQuery.Aggregate.Method: CustomStringConvertible { 28 | public var description: String { 29 | switch self { 30 | case .count: 31 | "count" 32 | case .sum: 33 | "sum" 34 | case .average: 35 | "average" 36 | case .minimum: 37 | "minimum" 38 | case .maximum: 39 | "maximum" 40 | case .custom(let custom): 41 | "custom(\(custom))" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Database/DatabaseQuery+Field.swift: -------------------------------------------------------------------------------- 1 | extension DatabaseQuery { 2 | public enum Field: Sendable { 3 | case path([FieldKey], schema: String) 4 | case extendedPath([FieldKey], schema: String, space: String?) 5 | case custom(any Sendable) 6 | } 7 | } 8 | 9 | extension DatabaseQuery.Field: CustomStringConvertible { 10 | public var description: String { 11 | switch self { 12 | case .path(let path, let schema): 13 | "\(schema)\(path)" 14 | case .extendedPath(let path, let schema, let space): 15 | "\(space.map { "\($0)." } ?? "")\(schema)\(path)" 16 | case .custom(let custom): 17 | "custom(\(custom))" 18 | } 19 | } 20 | 21 | var describedByLoggingMetadata: Logger.MetadataValue { 22 | switch self { 23 | case .path(let array, let schema): 24 | "\(schema).\(array.map(\.description).joined(separator: "->"))" 25 | case .extendedPath(let array, let schema, let space): 26 | "\(space.map { "\($0)." } ?? "")\(schema).\(array.map(\.description).joined(separator: "->"))" 27 | case .custom: 28 | .stringConvertible(self) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Database/DatabaseQuery+Filter.swift: -------------------------------------------------------------------------------- 1 | extension DatabaseQuery { 2 | public enum Filter: Sendable { 3 | public enum Method: Sendable { 4 | public static var equal: Method { 5 | .equality(inverse: false) 6 | } 7 | 8 | public static var notEqual: Method { 9 | .equality(inverse: true) 10 | } 11 | 12 | public static var greaterThan: Method { 13 | .order(inverse: false, equality: false) 14 | } 15 | 16 | public static var greaterThanOrEqual: Method { 17 | .order(inverse: false, equality: true) 18 | } 19 | 20 | public static var lessThan: Method { 21 | .order(inverse: true, equality: false) 22 | } 23 | 24 | public static var lessThanOrEqual: Method { 25 | .order(inverse: true, equality: true) 26 | } 27 | 28 | /// LHS is equal/not equal to RHS 29 | case equality(inverse: Bool) 30 | 31 | /// LHS is greater/less than [or equal to] RHS 32 | case order(inverse: Bool, equality: Bool) 33 | 34 | /// LHS exists in/doesn't exist in RHS 35 | case subset(inverse: Bool) 36 | 37 | public enum Contains: Sendable { 38 | case prefix 39 | case suffix 40 | case anywhere 41 | } 42 | 43 | /// RHS is [anchored] substring/isn't [anchored] substring of LHS 44 | case contains(inverse: Bool, Contains) 45 | 46 | /// Custom method 47 | case custom(any Sendable) 48 | } 49 | 50 | public enum Relation: Sendable { 51 | case and 52 | case or 53 | case custom(any Sendable) 54 | } 55 | 56 | case value(Field, Method, Value) 57 | case field(Field, Method, Field) 58 | case group([Filter], Relation) 59 | case custom(any Sendable) 60 | } 61 | } 62 | 63 | extension DatabaseQuery.Filter: CustomStringConvertible { 64 | public var description: String { 65 | switch self { 66 | case .value(let field, let method, let value): 67 | "\(field) \(method) \(value)" 68 | case .field(let fieldA, let method, let fieldB): 69 | "\(fieldA) \(method) \(fieldB)" 70 | case .group(let filters, let relation): 71 | filters.map { "(\($0))" }.joined(separator: " \(relation) ") 72 | case .custom(let any): 73 | "custom(\(any))" 74 | } 75 | } 76 | 77 | var describedByLoggingMetadata: Logger.MetadataValue { 78 | switch self { 79 | case .value(let field, let method, let value): 80 | ["field": field.describedByLoggingMetadata, "method": "\(method)", "value": value.describedByLoggingMetadata] 81 | case .field(let field, let method, let field2): 82 | ["field1": field.describedByLoggingMetadata, "method": "\(method)", "field2": field2.describedByLoggingMetadata] 83 | case .group(let array, let relation): 84 | ["group": .array(array.map(\.describedByLoggingMetadata)), "relation": "\(relation)"] 85 | case .custom: 86 | .stringConvertible(self) 87 | } 88 | } 89 | } 90 | 91 | extension DatabaseQuery.Filter.Method: CustomStringConvertible { 92 | public var description: String { 93 | switch self { 94 | case .equality(let inverse): 95 | inverse ? "!=" : "=" 96 | case .order(let inverse, let equality): 97 | if equality { 98 | inverse ? "<=" : ">=" 99 | } else { 100 | inverse ? "<" : ">" 101 | } 102 | case .subset(let inverse): 103 | inverse ? "!~~" : "~~" 104 | case .contains(let inverse, let contains): 105 | inverse ? "!\(contains)" : "\(contains)" 106 | case .custom(let any): 107 | "custom(\(any))" 108 | } 109 | } 110 | } 111 | 112 | extension DatabaseQuery.Filter.Method.Contains: CustomStringConvertible { 113 | public var description: String { 114 | switch self { 115 | case .prefix: 116 | "startswith" 117 | case .suffix: 118 | "endswith" 119 | case .anywhere: 120 | "contains" 121 | } 122 | } 123 | } 124 | 125 | extension DatabaseQuery.Filter.Relation: CustomStringConvertible { 126 | public var description: String { 127 | switch self { 128 | case .and: 129 | "and" 130 | case .or: 131 | "or" 132 | case .custom(let custom): 133 | "custom(\(custom))" 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Database/DatabaseQuery+Join.swift: -------------------------------------------------------------------------------- 1 | extension DatabaseQuery { 2 | public enum Join: Sendable { 3 | public enum Method: Sendable { 4 | case inner 5 | case left 6 | case custom(any Sendable) 7 | } 8 | 9 | case join( 10 | schema: String, 11 | alias: String?, 12 | Method, 13 | foreign: Field, 14 | local: Field 15 | ) 16 | 17 | case extendedJoin( 18 | schema: String, 19 | space: String?, 20 | alias: String?, 21 | Method, 22 | foreign: Field, 23 | local: Field 24 | ) 25 | 26 | case advancedJoin( 27 | schema: String, 28 | space: String?, 29 | alias: String?, 30 | Method, 31 | filters: [Filter] 32 | ) 33 | 34 | case custom(any Sendable) 35 | } 36 | } 37 | 38 | extension DatabaseQuery.Join: CustomStringConvertible { 39 | public var description: String { 40 | switch self { 41 | case .join(let schema, let alias, let method, let foreign, let local): 42 | "\(self.schemaDescription(schema: schema, alias: alias)) \(method) on \(foreign) == \(local)" 43 | 44 | case .extendedJoin(let schema, let space, let alias, let method, let foreign, let local): 45 | "\(self.schemaDescription(space: space, schema: schema, alias: alias)) \(method) on \(foreign) == \(local)" 46 | 47 | case .advancedJoin(let schema, let space, let alias, let method, let filters): 48 | "\(self.schemaDescription(space: space, schema: schema, alias: alias)) \(method) on \(filters)" 49 | 50 | case .custom(let custom): 51 | "custom(\(custom))" 52 | } 53 | } 54 | 55 | var describedByLoggingMetadata: Logger.MetadataValue { 56 | switch self { 57 | case .join(let schema, let alias, let method, let foreign, let local): 58 | .dictionary([ 59 | "schema": "\(schema)", "alias": alias.map { "\($0)" }, "method": "\(method)", 60 | "foreign": foreign.describedByLoggingMetadata, "local": local.describedByLoggingMetadata 61 | ].compactMapValues { $0 }) 62 | case .extendedJoin(let schema, let space, let alias, let method, let foreign, let local): 63 | .dictionary([ 64 | "schema": "\(schema)", "space": space.map { "\($0)" }, "alias": alias.map { "\($0)" }, "method": "\(method)", 65 | "foreign": foreign.describedByLoggingMetadata, "local": local.describedByLoggingMetadata 66 | ].compactMapValues { $0 }) 67 | case .advancedJoin(let schema, let space, let alias, let method, let filters): 68 | .dictionary([ 69 | "schema": "\(schema)", "space": space.map { "\($0)" }, "alias": alias.map { "\($0)" }, "method": "\(method)", 70 | "filters": .array(filters.map(\.describedByLoggingMetadata)) 71 | ].compactMapValues { $0 }) 72 | case .custom: 73 | .stringConvertible(self) 74 | } 75 | } 76 | 77 | private func schemaDescription(space: String? = nil, schema: String, alias: String?) -> String { 78 | [space, schema].compactMap({ $0 }).joined(separator: ".") + (alias.map { " as \($0)" } ?? "") 79 | } 80 | } 81 | 82 | extension DatabaseQuery.Join.Method: CustomStringConvertible { 83 | public var description: String { 84 | switch self { 85 | case .inner: 86 | "inner" 87 | case .left: 88 | "left" 89 | case .custom(let custom): 90 | "custom(\(custom))" 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Database/DatabaseQuery+Range.swift: -------------------------------------------------------------------------------- 1 | extension DatabaseQuery { 2 | public enum Limit: Sendable { 3 | case count(Int) 4 | 5 | /// Due to design limitations in SQLKit, it is not possible to provide a custom limit expression; 6 | /// as such, it is in turn a design flaw of FluentKit that this enum exists at all. Any attempt to 7 | /// use ``custom(_:)`` in a query unconditionally triggers a fatal error at runtime. 8 | @available(*, deprecated, message: "`DatabaseQuery.Limit.custom(_:)` does not work.") 9 | case custom(any Sendable) 10 | } 11 | 12 | public enum Offset: Sendable { 13 | case count(Int) 14 | 15 | /// Due to design limitations in SQLKit, it is not possible to provide a custom offset expression; 16 | /// as such, it is in turn a design flaw of FluentKit that this enum exists at all. Any attempt to 17 | /// use ``custom(_:)`` in a query unconditionally triggers a fatal error at runtime. 18 | @available(*, deprecated, message: "`DatabaseQuery.Offset.custom(_:)` triggers a fatal error when used.") 19 | case custom(any Sendable) 20 | } 21 | } 22 | 23 | extension DatabaseQuery.Limit: CustomStringConvertible { 24 | public var description: String { 25 | switch self { 26 | case .count(let count): 27 | "count(\(count))" 28 | case .custom(let custom): 29 | "custom(\(custom))" 30 | } 31 | } 32 | } 33 | 34 | extension DatabaseQuery.Limit { 35 | var describedByLoggingMetadata: Logger.MetadataValue { 36 | switch self { 37 | case .count(let count): .stringConvertible(count) 38 | default: "custom" 39 | } 40 | } 41 | } 42 | 43 | extension DatabaseQuery.Offset: CustomStringConvertible { 44 | public var description: String { 45 | switch self { 46 | case .count(let count): 47 | "count(\(count))" 48 | case .custom(let custom): 49 | "custom(\(custom))" 50 | } 51 | } 52 | } 53 | 54 | extension DatabaseQuery.Offset { 55 | var describedByLoggingMetadata: Logger.MetadataValue { 56 | switch self { 57 | case .count(let count): .stringConvertible(count) 58 | default: "custom" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Database/DatabaseQuery+Sort.swift: -------------------------------------------------------------------------------- 1 | extension DatabaseQuery { 2 | public enum Sort: Sendable { 3 | public enum Direction: Sendable { 4 | case ascending 5 | case descending 6 | case custom(any Sendable) 7 | } 8 | case sort(Field, Direction) 9 | case custom(any Sendable) 10 | } 11 | } 12 | 13 | extension DatabaseQuery.Sort: CustomStringConvertible { 14 | public var description: String { 15 | switch self { 16 | case .sort(let field, let direction): 17 | "\(field) \(direction)" 18 | case .custom(let custom): 19 | "custom(\(custom))" 20 | } 21 | } 22 | 23 | var describedByLoggingMetadata: Logger.MetadataValue { 24 | switch self { 25 | case .sort(let field, let direction): 26 | ["field": field.describedByLoggingMetadata, "direction": "\(direction)"] 27 | case .custom: 28 | .stringConvertible(self) 29 | } 30 | } 31 | } 32 | 33 | extension DatabaseQuery.Sort.Direction: CustomStringConvertible { 34 | public var description: String { 35 | switch self { 36 | case .ascending: 37 | "ascending" 38 | case .descending: 39 | "descending" 40 | case .custom(let custom): 41 | "custom(\(custom))" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Database/DatabaseQuery+Value.swift: -------------------------------------------------------------------------------- 1 | extension DatabaseQuery { 2 | public enum Value: Sendable { 3 | case bind(any Encodable & Sendable) 4 | case dictionary([FieldKey: Value]) 5 | case array([Value]) 6 | case null 7 | case enumCase(String) 8 | case `default` 9 | case custom(any Sendable) 10 | } 11 | } 12 | 13 | extension DatabaseQuery.Value: CustomStringConvertible { 14 | public var description: String { 15 | switch self { 16 | case .bind(let encodable): 17 | if let convertible = encodable as? any CustomDebugStringConvertible { 18 | String(reflecting: convertible) 19 | } else { 20 | String(describing: encodable) 21 | } 22 | case .dictionary(let dictionary): 23 | String(describing: dictionary) 24 | case .array(let array): 25 | String(describing: array) 26 | case .enumCase(let string): 27 | string 28 | case .null: 29 | "nil" 30 | case .default: 31 | "default" 32 | case .custom(let custom): 33 | "custom(\(custom))" 34 | } 35 | } 36 | 37 | var describedByLoggingMetadata: Logger.MetadataValue { 38 | switch self { 39 | case .bind(let encodable): 40 | // N.B.: `is` is used instead of `as?` here because the latter irreversibly loses the `Sendable`-ness of the value. 41 | if encodable is any CustomStringConvertible { 42 | .stringConvertible(encodable as! any CustomStringConvertible & Sendable) 43 | } else { 44 | .string(String(describing: encodable)) 45 | } 46 | case .dictionary(let d): 47 | .dictionary(.init(uniqueKeysWithValues: d.map { ($0.description, $1.describedByLoggingMetadata) })) 48 | case .array(let a): 49 | .array(a.map(\.describedByLoggingMetadata)) 50 | case .enumCase(let string): 51 | .string(string) 52 | case .null: 53 | "nil" 54 | case .default: 55 | "" 56 | default: 57 | .stringConvertible(self) 58 | } 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/Database/DatabaseQuery.swift: -------------------------------------------------------------------------------- 1 | public struct DatabaseQuery: Sendable { 2 | public var schema: String 3 | public var space: String? 4 | public var customIDKey: FieldKey? 5 | public var isUnique: Bool 6 | public var fields: [Field] 7 | public var action: Action 8 | public var filters: [Filter] 9 | public var input: [Value] 10 | public var joins: [Join] 11 | public var sorts: [Sort] 12 | public var limits: [Limit] 13 | public var offsets: [Offset] 14 | 15 | init(schema: String, space: String? = nil) { 16 | self.schema = schema 17 | self.space = space 18 | self.isUnique = false 19 | self.fields = [] 20 | self.action = .read 21 | self.filters = [] 22 | self.input = [] 23 | self.joins = [] 24 | self.sorts = [] 25 | self.limits = [] 26 | self.offsets = [] 27 | } 28 | } 29 | 30 | extension DatabaseQuery: CustomStringConvertible { 31 | public var description: String { 32 | var parts = [ 33 | "query", 34 | "\(self.action)", 35 | "\(self.space.map { "\($0)." } ?? "")\(self.schema)", 36 | ] 37 | if self.isUnique { 38 | parts.append("unique") 39 | } 40 | if !self.fields.isEmpty { 41 | parts.append("fields=\(self.fields)") 42 | } 43 | if !self.joins.isEmpty { 44 | parts.append("joins=\(self.joins)") 45 | } 46 | if !self.filters.isEmpty { 47 | parts.append("filters=\(self.filters)") 48 | } 49 | if !self.input.isEmpty { 50 | parts.append("input=\(self.input)") 51 | } 52 | if !self.sorts.isEmpty { 53 | parts.append("sorts=\(self.sorts)") 54 | } 55 | if !self.limits.isEmpty { 56 | parts.append("limits=\(self.limits)") 57 | } 58 | if !self.offsets.isEmpty { 59 | parts.append("offsets=\(self.offsets)") 60 | } 61 | return parts.joined(separator: " ") 62 | } 63 | 64 | var describedByLoggingMetadata: Logger.Metadata { 65 | var result: Logger.Metadata = [ 66 | "action": "\(self.action)", 67 | "schema": "\(self.space.map { "\($0)." } ?? "")\(self.schema)", 68 | ] 69 | switch self.action { 70 | case .create, .update, .custom: result["input"] = .array(self.input.map(\.describedByLoggingMetadata)) 71 | default: break 72 | } 73 | switch self.action { 74 | case .read, .aggregate, .custom: 75 | result["unique"] = .stringConvertible(self.isUnique) 76 | result["fields"] = .array(self.fields.map(\.describedByLoggingMetadata)) 77 | result["joins"] = .array(self.joins.map(\.describedByLoggingMetadata)) 78 | fallthrough 79 | case .update, .delete: 80 | result["filters"] = .array(self.filters.map(\.describedByLoggingMetadata)) 81 | result["sorts"] = .array(self.sorts.map(\.describedByLoggingMetadata)) 82 | result["limits"] = .array(self.limits.map(\.describedByLoggingMetadata)) 83 | result["offsets"] = .array(self.offsets.map(\.describedByLoggingMetadata)) 84 | default: break 85 | } 86 | return result 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/FluentKit/Query/QueryHistory.swift: -------------------------------------------------------------------------------- 1 | import struct NIOConcurrencyHelpers.NIOLockedValueBox 2 | 3 | /// Holds the history of queries for a database. 4 | public final class QueryHistory: @unchecked Sendable { 5 | /// The underlying (locked) storage. 6 | private let _queries: NIOLockedValueBox<[DatabaseQuery]> = .init([]) 7 | 8 | /// The queries that have been executed. 9 | /// 10 | /// > Warning: This array can be modified aribtrarily by any code with access to the ``QueryHistory`` 11 | /// > object; there is no guarantee that it represents a consistent and accurate history. This is an 12 | /// > accidental design flaw that can't be changed now without breaking the API. 13 | public var queries: [DatabaseQuery] { 14 | get { self._queries.withLockedValue { $0 } } 15 | set { self._queries.withLockedValue { $0 = newValue } } 16 | } 17 | 18 | /// Create a new ``QueryHistory`` with no existing history. 19 | public init() {} 20 | 21 | /// Add a query to the history. 22 | func add(_ query: DatabaseQuery) { 23 | self._queries.withLockedValue { $0.append(query) } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/FluentKit/Utilities/OptionalType.swift: -------------------------------------------------------------------------------- 1 | public protocol AnyOptionalType { 2 | static var wrappedType: Any.Type { get } 3 | static var `nil`: Any { get } 4 | var wrappedValue: Any? { get } 5 | } 6 | 7 | public protocol OptionalType: AnyOptionalType { 8 | associatedtype Wrapped 9 | init(_ wrapped: Wrapped) 10 | } 11 | 12 | extension OptionalType { 13 | public static var wrappedType: Any.Type { 14 | Wrapped.self 15 | } 16 | } 17 | 18 | extension Optional: OptionalType { 19 | public static var `nil`: Any { 20 | Self.none as Any 21 | } 22 | 23 | public var wrappedValue: Any? { 24 | self 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/FluentKit/Utilities/RandomGeneratable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol RandomGeneratable { 4 | static func generateRandom() -> Self 5 | } 6 | 7 | extension UUID: RandomGeneratable { 8 | public static func generateRandom() -> UUID { 9 | .init() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/FluentKit/Utilities/SomeCodingKey.swift: -------------------------------------------------------------------------------- 1 | import SQLKit 2 | 3 | extension SQLKit.SomeCodingKey { 4 | public var description: String { 5 | "SomeCodingKey(\"\(self.stringValue)\"\(self.intValue.map { ", int: \($0)" } ?? ""))" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/FluentKit/Utilities/UnsafeMutableTransferBox.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | final class UnsafeMutableTransferBox: @unchecked Sendable { 3 | @usableFromInline 4 | var wrappedValue: Wrapped 5 | 6 | @inlinable 7 | init(_ wrappedValue: Wrapped) { 8 | self.wrappedValue = wrappedValue 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/FluentSQL/ConverterUtilities.swift: -------------------------------------------------------------------------------- 1 | import SQLKit 2 | 3 | func custom(_ any: Any) -> any SQLExpression { 4 | if let sql = any as? any SQLExpression { 5 | sql 6 | } else if let string = any as? String { 7 | SQLRaw(string) 8 | } else if let stringConvertible = any as? any CustomStringConvertible { 9 | SQLRaw(stringConvertible.description) 10 | } else { 11 | fatalError("Could not convert \(any) to a SQL-compatible type.") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/FluentSQL/Docs.docc/Resources/vapor-fluentkit-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Sources/FluentSQL/Docs.docc/index.md: -------------------------------------------------------------------------------- 1 | # ``FluentSQL`` 2 | 3 | FluentSQL is a module containing the logic which maps FluentKit queries, schemata, models, and values to and from [SQLKit] types. 4 | 5 | [SQLKit]: https://api.vapor.codes/sqlkit/documentation/sqlkit 6 | -------------------------------------------------------------------------------- /Sources/FluentSQL/Docs.docc/theme-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": { 3 | "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, 4 | "border-radius": "0", 5 | "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, 6 | "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, 7 | "color": { 8 | "fluentkit": { "dark": "hsl(200, 75%, 85%)", "light": "hsl(200, 75%, 75%)" }, 9 | "documentation-intro-fill": "radial-gradient(circle at top, var(--color-fluentkit) 30%, #000 100%)", 10 | "documentation-intro-accent": "var(--color-fluentkit)", 11 | "documentation-intro-eyebrow": "white", 12 | "documentation-intro-figure": "white", 13 | "documentation-intro-title": "white", 14 | "logo-base": { "dark": "#fff", "light": "#000" }, 15 | "logo-shape": { "dark": "#000", "light": "#fff" }, 16 | "fill": { "dark": "#000", "light": "#fff" } 17 | }, 18 | "icons": { "technology": "/fluentsql/images/FluentSQL/vapor-fluentkit-logo.svg" } 19 | }, 20 | "features": { 21 | "quickNavigation": { "enable": true }, 22 | "i18n": { "enable": true } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/FluentSQL/Exports.swift: -------------------------------------------------------------------------------- 1 | @_documentation(visibility: internal) @_exported import FluentKit 2 | @_documentation(visibility: internal) @_exported import SQLKit 3 | -------------------------------------------------------------------------------- /Sources/FluentSQL/SQLDatabase+Model+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import SQLKit 3 | import FluentKit 4 | 5 | extension SQLQueryFetcher { 6 | public func first(decodingFluent model: Model.Type) async throws -> Model? { 7 | try await self.first(decodingFluent: Model.self).get() 8 | } 9 | 10 | @available(*, deprecated, renamed: "first(decodingFluent:)", message: "Renamed to first(decodingFluent:)") 11 | public func first(decoding: Model.Type) async throws -> Model? { 12 | try await self.first(decodingFluent: Model.self) 13 | } 14 | 15 | public func all(decodingFluent: Model.Type) async throws -> [Model] { 16 | try await self.all(decodingFluent: Model.self).get() 17 | } 18 | 19 | @available(*, deprecated, renamed: "all(decodingFluent:)", message: "Renamed to all(decodingFluent:)") 20 | public func all(decoding: Model.Type) async throws -> [Model] { 21 | try await self.all(decodingFluent: Model.self) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/FluentSQL/SQLJSONColumnPath+Deprecated.swift: -------------------------------------------------------------------------------- 1 | import SQLKit 2 | import FluentKit 3 | 4 | /// A thin deprecated wrapper around `SQLNestedSubpathExpression`. 5 | @available(*, deprecated, message: "Replaced by `SQLNestedSubpathExpression` in SQLKit") 6 | public struct SQLJSONColumnPath: SQLExpression { 7 | private var realExpression: SQLNestedSubpathExpression 8 | 9 | public var column: String { 10 | get { (self.realExpression.column as? SQLIdentifier)?.string ?? "" } 11 | set { self.realExpression.column = SQLIdentifier(newValue) } 12 | } 13 | 14 | public var path: [String] { 15 | get { self.realExpression.path } 16 | set { self.realExpression.path = newValue } 17 | } 18 | 19 | public init(column: String, path: [String]) { 20 | self.realExpression = .init(column: column, path: path) 21 | } 22 | 23 | public func serialize(to serializer: inout SQLSerializer) { 24 | self.realExpression.serialize(to: &serializer) 25 | } 26 | } 27 | 28 | extension DatabaseQuery.Field { 29 | public static func sql(json column: String, _ path: String...) -> Self { 30 | .sql(json: column, path) 31 | } 32 | 33 | public static func sql(json column: String, _ path: [String]) -> Self { 34 | .sql(SQLNestedSubpathExpression(column: column, path: path)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/FluentSQL/SQLList+Deprecated.swift: -------------------------------------------------------------------------------- 1 | import SQLKit 2 | 3 | /// This file provides a few extensions to SQLKit's `SQLList` which have the effect of mimicking 4 | /// the public API which was previously provided by a nearly-identical type of the same name in 5 | /// this module. The slightly differing behavior of the Fluent version had a tendency to cause 6 | /// confusion when both `FluentSQL` and `SQLKit` were imported in the same context; as such, the 7 | /// Fluent version was removed. To avoid breaking API that has been publicly available for a long 8 | /// time (no matter how incorrectly so), these deprecated extensions make the semantics of the removed 9 | /// Fluent implementation available. Whether the original or alternate serialization behavior is used 10 | /// is based on which initializer is used. The original SQLKit initializer, ``init(_:separator:)`` (or 11 | /// ``init(_:)``, taking the default value for the separator), gives the original and intended behavior 12 | /// (see `SQLList` for further details). The convenience intializer, ``init(items:separator:)``, 13 | /// enables the deprecated alternate behavior, which adds a space character before and after the separator. 14 | /// 15 | /// Examples: 16 | /// 17 | /// Expressions: [1, 2, 3, 4, 5] 18 | /// Separator: "AND" 19 | /// Original serialization: 1AND2AND3AND4AND5 20 | /// Alternate serialization: 1 AND 2 AND 3 AND 4 AND 5 21 | /// 22 | /// Expressions: [1, 2, 3, 4, 5] 23 | /// Separator: ", " 24 | /// Original serialization: 1, 2, 3, 4, 5 25 | /// Alternate serialization: 1 , 2 , 3 , 4 , 5 26 | /// 27 | /// > Warning: These extensions are not recommended, as it was never intended for this behavior to be 28 | /// > public. Convert code using these extensions to invoke the original `SQLList` directly. 29 | extension SQLKit.SQLList { 30 | @available(*, deprecated, message: "Use `expressions` instead.") 31 | public var items: [any SQLExpression] { 32 | get { self.expressions } 33 | set { self.expressions = newValue } 34 | } 35 | 36 | @available(*, deprecated, message: "Use `init(_:separator:)` and include whitespace in the separator as needed instead.") 37 | public init(items: [any SQLExpression], separator: any SQLExpression) { 38 | self.init(items, separator: " \(separator) " as SQLQueryString) 39 | } 40 | } 41 | 42 | @available(*, deprecated, message: "Import `SQLList` from the SQLKit module instead.") 43 | public typealias SQLList = SQLKit.SQLList 44 | -------------------------------------------------------------------------------- /Sources/FluentSQL/SQLQualifiedTable+Deprecated.swift: -------------------------------------------------------------------------------- 1 | import SQLKit 2 | 3 | /// Formerly a complete implementation of a qualified table SQL expression, now an alias for the equivalent 4 | /// type available in SQLKit. 5 | @available(*, deprecated, message: "This type is now provided by SQLKit; use that module's version.") 6 | public typealias SQLQualifiedTable = SQLKit.SQLQualifiedTable 7 | -------------------------------------------------------------------------------- /Sources/XCTFluent/Docs.docc/Resources/vapor-fluentkit-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Sources/XCTFluent/Docs.docc/index.md: -------------------------------------------------------------------------------- 1 | # ``XCTFluent`` 2 | 3 | XCTFluent provides XCTest extensions to make it easy to write tests using FluentKit. 4 | -------------------------------------------------------------------------------- /Sources/XCTFluent/Docs.docc/theme-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": { 3 | "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, 4 | "border-radius": "0", 5 | "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, 6 | "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, 7 | "color": { 8 | "fluentkit": { "dark": "hsl(200, 75%, 85%)", "light": "hsl(200, 75%, 75%)" }, 9 | "documentation-intro-fill": "radial-gradient(circle at top, var(--color-fluentkit) 30%, #000 100%)", 10 | "documentation-intro-accent": "var(--color-fluentkit)", 11 | "documentation-intro-eyebrow": "white", 12 | "documentation-intro-figure": "white", 13 | "documentation-intro-title": "white", 14 | "logo-base": { "dark": "#fff", "light": "#000" }, 15 | "logo-shape": { "dark": "#000", "light": "#fff" }, 16 | "fill": { "dark": "#000", "light": "#fff" } 17 | }, 18 | "icons": { "technology": "/xctfluent/images/XCTFluent/vapor-fluentkit-logo.svg" } 19 | }, 20 | "features": { 21 | "quickNavigation": { "enable": true }, 22 | "i18n": { "enable": true } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/FluentKitTests/OptionalFieldQueryTests.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import FluentSQL 3 | import Foundation 4 | import XCTest 5 | 6 | final class OptionalFieldQueryTests: DbQueryTestCase { 7 | func testInsertNonNull() throws { 8 | db.fakedRows.append([.init(["id": UUID()])]) 9 | _ = try Thing(id: 1, name: "Jared").create(on: db).wait() 10 | assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2)"#) 11 | } 12 | 13 | func testInsertNull() throws { 14 | db.fakedRows.append([.init(["id": UUID()])]) 15 | _ = try Thing(id: 1, name: nil).create(on: db).wait() 16 | assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, NULL)"#) 17 | } 18 | 19 | func testInsertAfterMutatingNullableField() throws { 20 | let thing = Thing(id: 1, name: nil) 21 | thing.name = "Jared" 22 | db.fakedRows.append([.init(["id": UUID()])]) 23 | _ = try thing.create(on: db).wait() 24 | assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2)"#) 25 | 26 | db.reset() 27 | 28 | let thing2 = Thing(id: 1, name: "Jared") 29 | thing2.name = nil 30 | db.fakedRows.append([.init(["id": UUID()])]) 31 | _ = try thing2.create(on: db).wait() 32 | assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, NULL)"#) 33 | } 34 | 35 | func testSaveReplacingNonNull() throws { 36 | let thing = Thing(id: 1, name: "Jared") 37 | db.fakedRows.append([.init(["id": UUID()])]) 38 | _ = try thing.create(on: db).wait() 39 | thing.name = "Bob" 40 | _ = try thing.save(on: db).wait() 41 | assertLastQuery(db, #"UPDATE "things" SET "name" = $1 WHERE "things"."id" = $2"#) 42 | } 43 | 44 | func testSaveReplacingNull() throws { 45 | let thing = Thing(id: 1, name: nil) 46 | db.fakedRows.append([.init(["id": UUID()])]) 47 | _ = try thing.create(on: db).wait() 48 | thing.name = "Bob" 49 | _ = try thing.save(on: db).wait() 50 | assertLastQuery(db, #"UPDATE "things" SET "name" = $1 WHERE "things"."id" = $2"#) 51 | } 52 | 53 | func testSaveNullReplacingNonNull() throws { 54 | let thing = Thing(id: 1, name: "Jared") 55 | db.fakedRows.append([.init(["id": UUID()])]) 56 | _ = try thing.create(on: db).wait() 57 | thing.name = nil 58 | _ = try thing.save(on: db).wait() 59 | assertLastQuery(db, #"UPDATE "things" SET "name" = NULL WHERE "things"."id" = $1"#) 60 | } 61 | 62 | func testBulkInsertWithoutNulls() throws { 63 | let things = [Thing(id: 1, name: "Jared"), Thing(id: 2, name: "Bob")] 64 | db.fakedRows.append([.init(["id": UUID()])]) 65 | _ = try things.create(on: db).wait() 66 | assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2), ($3, $4)"#) 67 | } 68 | 69 | func testBulkInsertWithOnlyNulls() throws { 70 | let things = [Thing(id: 1, name: nil), Thing(id: 2, name: nil)] 71 | db.fakedRows.append([.init(["id": UUID()])]) 72 | _ = try things.create(on: db).wait() 73 | assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, NULL), ($2, NULL)"#) 74 | } 75 | 76 | func testBulkInsertWithMixedNulls() throws { 77 | let things = [Thing(id: 1, name: "Jared"), Thing(id: 2, name: nil)] 78 | db.fakedRows.append([.init(["id": UUID()])]) 79 | _ = try things.create(on: db).wait() 80 | assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2), ($3, NULL)"#) 81 | } 82 | } 83 | 84 | private final class Thing: Model, @unchecked Sendable { 85 | static let schema = "things" 86 | 87 | @ID(custom: "id", generatedBy: .user) 88 | var id: Int? 89 | 90 | @OptionalField(key: "name") 91 | var name: String? 92 | 93 | init() {} 94 | 95 | init(id: Int, name: String? = nil) { 96 | self.id = id 97 | self.name = name 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Tests/FluentKitTests/TestUtilities.swift: -------------------------------------------------------------------------------- 1 | import Logging 2 | import SQLKit 3 | import XCTest 4 | 5 | class DbQueryTestCase: XCTestCase { 6 | var db = DummyDatabaseForTestSQLSerializer() 7 | 8 | override class func setUp() { 9 | super.setUp() 10 | XCTAssertTrue(isLoggingConfigured) 11 | } 12 | 13 | override func setUp() { 14 | self.db = DummyDatabaseForTestSQLSerializer() 15 | } 16 | 17 | override func tearDown() { 18 | self.db.reset() 19 | } 20 | } 21 | 22 | func assertQuery( 23 | _ db: DummyDatabaseForTestSQLSerializer, 24 | _ query: String, 25 | file: StaticString = #filePath, 26 | line: UInt = #line 27 | ) { 28 | XCTAssertEqual(db.sqlSerializers.count, 1, file: file, line: line) 29 | XCTAssertEqual(db.sqlSerializers.first?.sql, query, file: file, line: line) 30 | } 31 | 32 | func assertLastQuery( 33 | _ db: DummyDatabaseForTestSQLSerializer, 34 | _ query: String, 35 | file: StaticString = #filePath, 36 | line: UInt = #line 37 | ) { 38 | XCTAssertEqual(db.sqlSerializers.last?.sql, query, file: file, line: line) 39 | } 40 | 41 | func env(_ name: String) -> String? { 42 | ProcessInfo.processInfo.environment[name] 43 | } 44 | 45 | let isLoggingConfigured: Bool = { 46 | LoggingSystem.bootstrap { 47 | var handler = StreamLogHandler.standardOutput(label: $0) 48 | handler.logLevel = env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? .info 49 | return handler 50 | } 51 | return true 52 | }() 53 | --------------------------------------------------------------------------------