diff --git a/.api-breakage/allowlist-branch-make-pivot-rhs-available-during-array-attach.txt b/.api-breakage/allowlist-branch-make-pivot-rhs-available-during-array-attach.txt new file mode 100644 index 00000000..bbbd2921 --- /dev/null +++ b/.api-breakage/allowlist-branch-make-pivot-rhs-available-during-array-attach.txt @@ -0,0 +1 @@ +API breakage: constructor PlanetTag.init(id:planetID:tagID:) has been removed diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..6ff9614b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +* @gwynne +/.github/CONTRIBUTING.md @gwynne @0xTim +/.github/workflows/*.yml @gwynne @0xTim +/.github/workflows/test.yml @gwynne +/.spi.yml @gwynne @0xTim +/.gitignore @gwynne @0xTim +/LICENSE @gwynne @0xTim +/README.md @gwynne @0xTim diff --git a/.github/contributing.md b/.github/CONTRIBUTING.md similarity index 100% rename from .github/contributing.md rename to .github/CONTRIBUTING.md diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index d521498e..1b402724 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -1,18 +1,14 @@ name: deploy-api-docs on: - push: - branches: - - main + push: + branches: + - main jobs: - deploy: - name: api.vapor.codes - runs-on: ubuntu-latest - steps: - - name: Deploy api-docs - uses: appleboy/ssh-action@master - with: - host: vapor.codes - username: vapor - key: ${{ secrets.VAPOR_CODES_SSH_KEY }} - script: ./github-actions/deploy-api-docs.sh + build-and-deploy: + uses: vapor/api-docs/.github/workflows/build-and-deploy-docs-workflow.yml@main + secrets: inherit + with: + package_name: fluent-kit + modules: FluentKit + pathsToInvalidate: /fluentkit/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa9aba23..202f17e7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,137 +1,101 @@ name: test +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true on: - pull_request: - push: - branches: - - main + pull_request: { types: [opened, reopened, synchronize, ready_for_review] } + push: { branches: [ main ] } + env: LOG_LEVEL: info SWIFT_DETERMINISTIC_HASHING: 1 + POSTGRES_HOSTNAME: 'psql-a' + POSTGRES_HOSTNAME_A: 'psql-a' + POSTGRES_HOSTNAME_B: 'psql-b' + POSTGRES_DB: 'test_database' + POSTGRES_DB_A: 'test_database' + POSTGRES_DB_B: 'test_database' + POSTGRES_USER: 'test_username' + POSTGRES_USER_A: 'test_username' + POSTGRES_USER_B: 'test_username' + POSTGRES_PASSWORD: 'test_password' + POSTGRES_PASSWORD_A: 'test_password' + POSTGRES_PASSWORD_B: 'test_password' + MYSQL_HOSTNAME: 'mysql-a' + MYSQL_HOSTNAME_A: 'mysql-a' + MYSQL_HOSTNAME_B: 'mysql-b' + MYSQL_DATABASE: 'test_database' + MYSQL_DATABASE_A: 'test_database' + MYSQL_DATABASE_B: 'test_database' + MYSQL_USERNAME: 'test_username' + MYSQL_USERNAME_A: 'test_username' + MYSQL_USERNAME_B: 'test_username' + MYSQL_PASSWORD: 'test_password' + MYSQL_PASSWORD_A: 'test_password' + MYSQL_PASSWORD_B: 'test_password' + MONGO_HOSTNAME: 'mongo-a' + MONGO_HOSTNAME_A: 'mongo-a' + MONGO_HOSTNAME_B: 'mongo-b' jobs: linux-integration: + if: ${{ !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest + container: swift:5.8-jammy services: mysql-a: - image: mysql:latest - env: - MYSQL_ALLOW_EMPTY_PASSWORD: "true" - MYSQL_USER: vapor_username - MYSQL_PASSWORD: vapor_password - MYSQL_DATABASE: vapor_database + image: mysql:8.0 + env: { MYSQL_ALLOW_EMPTY_PASSWORD: true, MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database } mysql-b: - image: mysql:latest - env: - MYSQL_ALLOW_EMPTY_PASSWORD: "true" - MYSQL_USER: vapor_username - MYSQL_PASSWORD: vapor_password - MYSQL_DATABASE: vapor_database - postgres-a: - image: postgres:latest - env: - POSTGRES_USER: vapor_username - POSTGRES_PASSWORD: vapor_password - POSTGRES_DB: vapor_database - postgres-b: - image: postgres:latest - env: - POSTGRES_USER: vapor_username - POSTGRES_PASSWORD: vapor_password - POSTGRES_DB: vapor_database + image: mysql:8.0 + env: { MYSQL_ALLOW_EMPTY_PASSWORD: true, MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database } + psql-a: + image: postgres:15 + env: { + POSTGRES_USER: test_username, POSTGRES_PASSWORD: test_password, POSTGRES_DB: test_database, + POSTGRES_HOST_AUTH_METHOD: scram-sha-256, POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 + } + psql-b: + image: postgres:15 + env: { + POSTGRES_USER: test_username, POSTGRES_PASSWORD: test_password, POSTGRES_DB: test_database, + POSTGRES_HOST_AUTH_METHOD: scram-sha-256, POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 + } mongo-a: - image: mongo:latest + image: mongo:6 mongo-b: - image: mongo:latest + image: mongo:6 strategy: fail-fast: false matrix: - swiftver: - - swift:5.2 - - swift:5.5 - swiftos: - - focal - dependent: - - fluent-sqlite-driver - - fluent-postgres-driver - - fluent-mysql-driver - - fluent-mongo-driver - container: ${{ format('{0}-{1}', matrix.swiftver, matrix.swiftos) }} + include: + - { dependent: 'fluent-sqlite-driver', ref: 'main' } + - { dependent: 'fluent-postgres-driver', ref: 'main' } + - { dependent: 'fluent-mysql-driver', ref: 'main' } + - { dependent: 'fluent-mongo-driver', ref: 'main' } steps: - - name: Install SQLite dependencies - run: apt-get -q update && apt-get -q install -y libsqlite3-dev - if: ${{ contains(matrix.dependent, 'sqlite') }} - name: Check out package - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: - path: package + path: fluent-kit - name: Check out dependent - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: vapor/${{ matrix.dependent }} - path: dependent - - name: Use local package - run: swift package edit fluent-kit --path ../package - working-directory: dependent - - name: Run tests with Thread Sanitizer - run: swift test --enable-test-discovery --sanitize=thread - working-directory: dependent + path: ${{ matrix.dependent }} + ref: ${{ matrix.ref }} + - name: Use local package and run tests env: - POSTGRES_HOSTNAME_A: postgres-a - POSTGRES_USERNAME_A: vapor_username - POSTGRES_PASSWORD_A: vapor_password - POSTGRES_DATABASE_A: vapor_database - POSTGRES_HOSTNAME_B: postgres-b - POSTGRES_USERNAME_B: vapor_username - POSTGRES_PASSWORD_B: vapor_password - POSTGRES_DATABASE_B: vapor_database - MYSQL_HOSTNAME_A: mysql-a - MYSQL_USERNAME_A: vapor_username - MYSQL_PASSWORD_A: vapor_password - MYSQL_DATABASE_A: vapor_database - MYSQL_HOSTNAME_B: mysql-b - MYSQL_USERNAME_B: vapor_username - MYSQL_PASSWORD_B: vapor_password - MYSQL_DATABASE_B: vapor_database - MONGO_HOSTNAME_A: mongo-a - MONGO_HOSTNAME_B: mongo-b - - linux-unit: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - swiftver: - - swift:5.2 - - swift:5.3 - - swift:5.4 - - swift:5.5 - - swiftlang/swift:nightly-main - swiftos: - - focal - container: ${{ format('{0}-{1}', matrix.swiftver, matrix.swiftos) }} - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Run tests with Thread Sanitizer - run: swift test --enable-test-discovery --sanitize=thread - - macos-unit: - strategy: - fail-fast: false - matrix: - xcode: - - latest - - latest-stable - runs-on: macos-11 - steps: - - name: Select latest available Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.xcode }} - - name: Check out package - uses: actions/checkout@v2 - - name: Run tests with Thread Sanitizer + DEPENDENT: ${{ matrix.dependent }} run: | - swift test --sanitize=thread -Xlinker -rpath \ - -Xlinker $(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.5/macosx + swift package --package-path ${DEPENDENT} edit fluent-kit --path fluent-kit + swift test --package-path ${DEPENDENT} + + # also serves as code coverage baseline update + unit-tests: + uses: vapor/ci/.github/workflows/run-unit-tests.yml@main + with: + with_coverage: true + with_tsan: true + coverage_ignores: '/Tests/|/Sources/FluentBenchmark/' diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 00000000..d5552914 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +metadata: + authors: "Maintained by the Vapor Core Team with hundreds of contributions from the Vapor Community." +external_links: + documentation: "https://api.vapor.codes/fluentkit/documentation/fluentkit/" diff --git a/Package.swift b/Package.swift index 9a6fcd50..a3958412 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,13 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.6 import PackageDescription let package = Package( name: "fluent-kit", platforms: [ .macOS(.v10_15), - .iOS(.v13) + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), ], products: [ .library(name: "FluentKit", targets: ["FluentKit"]), @@ -14,16 +16,17 @@ let package = Package( .library(name: "XCTFluent", targets: ["XCTFluent"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.33.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/vapor/sql-kit.git", from: "3.1.0"), - .package(url: "https://github.com/vapor/async-kit.git", from: "1.4.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.55.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.2"), + .package(url: "https://github.com/vapor/sql-kit.git", from: "3.28.0"), + .package(url: "https://github.com/vapor/async-kit.git", from: "1.17.0"), ], targets: [ .target(name: "FluentKit", dependencies: [ .product(name: "NIOCore", package: "swift-nio"), .product(name: "Logging", package: "swift-log"), .product(name: "AsyncKit", package: "async-kit"), + .product(name: "SQLKit", package: "sql-kit"), ]), .target(name: "FluentBenchmark", dependencies: [ .target(name: "FluentKit"), diff --git a/README.md b/README.md index 59f26395..cc263ffd 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,13 @@ MIT License - - Continuous Integration + + Continuous Integration + + + Test Coverage - Swift 5.2 + Swift 5.6

diff --git a/Sources/FluentBenchmark/Exports.swift b/Sources/FluentBenchmark/Exports.swift index a65c8165..83531ee0 100644 --- a/Sources/FluentBenchmark/Exports.swift +++ b/Sources/FluentBenchmark/Exports.swift @@ -1,2 +1,11 @@ +#if swift(>=5.8) + +@_documentation(visibility: internal) @_exported import FluentKit +@_documentation(visibility: internal) @_exported import XCTest + +#else + @_exported import FluentKit @_exported import XCTest + +#endif diff --git a/Sources/FluentBenchmark/FluentBenchmarker.swift b/Sources/FluentBenchmark/FluentBenchmarker.swift index 2757c987..603995c7 100644 --- a/Sources/FluentBenchmark/FluentBenchmarker.swift +++ b/Sources/FluentBenchmark/FluentBenchmarker.swift @@ -4,17 +4,15 @@ import XCTest public final class FluentBenchmarker { public let databases: Databases + public var database: any Database - public var database: Database { - self.databases.database( - logger: .init(label: "codes.fluent.benchmarker"), - on: self.databases.eventLoopGroup.next() - )! - } - public init(databases: Databases) { precondition(databases.ids().count >= 2, "FluentBenchmarker Databases instance must have 2 or more registered databases") self.databases = databases + self.database = self.databases.database( + logger: .init(label: "codes.vapor.fluent.benchmarker"), + on: self.databases.eventLoopGroup.any() + )! } public func testAll() throws { @@ -25,6 +23,7 @@ public final class FluentBenchmarker { try self.testChildren() try self.testCodable() try self.testChunk() + try self.testCompositeID() try self.testCRUD() try self.testEagerLoad() try self.testEnum() @@ -56,35 +55,60 @@ public final class FluentBenchmarker { internal func runTest( _ name: String, _ migrations: [Migration], - on database: Database? = nil, _ test: () throws -> () ) throws { - self.log("Running \(name)...") - let database = database ?? self.database + try self.runTest(name, migrations, { _ in try test() }) + } + + internal func runTest( + _ name: String, + _ migrations: [Migration], + _ test: (any Database) throws -> () + ) throws { + // This re-initialization is required to make the middleware tests work thanks to ridiculous design flaws + self.database = self.databases.database( + logger: .init(label: "codes.vapor.fluent.benchmarker"), + on: self.databases.eventLoopGroup.any() + )! + try self.runTest(name, migrations, on: self.database, test) + } + + internal func runTest( + _ name: String, + _ migrations: [Migration], + on database: any Database, + _ test: (any Database) throws -> () + ) throws { + database.logger.notice("Running \(name)...") // Prepare migrations. - for migration in migrations { - try migration.prepare(on: database).wait() - } - - var e: Error? do { - try test() + for migration in migrations { + try migration.prepare(on: database).wait() + } } catch { - e = error + database.logger.error("\(name): Error: \(String(reflecting: error))") + throw error } + + let result = Result { try test(database) } // Revert migrations - for migration in migrations.reversed() { - try migration.revert(on: database).wait() + do { + for migration in migrations.reversed() { + try migration.revert(on: database).wait() + } + } catch { + // ignore revert errors if the test itself failed + guard case .failure(_) = result else { + database.logger.error("\(name): Error: \(String(reflecting: error))") + throw error + } } - - if let error = e { + + if case .failure(let error) = result { + database.logger.error("\(name): Error: \(String(reflecting: error))") throw error } } - - private func log(_ message: String) { - self.database.logger.notice("[FluentBenchmark] \(message)") - } } diff --git a/Sources/FluentBenchmark/SolarSystem/GalacticJurisdiction.swift b/Sources/FluentBenchmark/SolarSystem/GalacticJurisdiction.swift new file mode 100644 index 00000000..3665a669 --- /dev/null +++ b/Sources/FluentBenchmark/SolarSystem/GalacticJurisdiction.swift @@ -0,0 +1,100 @@ +import FluentKit +import Foundation +import NIOCore + +public final class GalacticJurisdiction: Model { + public static let schema = "galaxy_jurisdictions" + + public final class IDValue: Fields, Hashable { + @Parent(key: "galaxy_id") + public var galaxy: Galaxy + + @Parent(key: "jurisdiction_id") + public var jurisdiction: Jurisdiction + + @Field(key: "rank") + public var rank: Int + + public init() {} + + public convenience init(galaxy: Galaxy, jurisdiction: Jurisdiction, rank: Int) throws { + try self.init(galaxyId: galaxy.requireID(), jurisdictionId: jurisdiction.requireID(), rank: rank) + } + + public init(galaxyId: Galaxy.IDValue, jurisdictionId: Jurisdiction.IDValue, rank: Int) { + self.$galaxy.id = galaxyId + self.$jurisdiction.id = jurisdictionId + self.rank = rank + } + + public static func == (lhs: IDValue, rhs: IDValue) -> Bool { + lhs.$galaxy.id == rhs.$galaxy.id && lhs.$jurisdiction.id == rhs.$jurisdiction.id && lhs.rank == rhs.rank + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.$galaxy.id) + hasher.combine(self.$jurisdiction.id) + hasher.combine(self.rank) + } + } + + @CompositeID() + public var id: IDValue? + + public init() {} + + public init(id: IDValue) { + self.id = id + } +} + +public struct GalacticJurisdictionMigration: Migration { + public init() {} + + public func prepare(on database: Database) -> EventLoopFuture { + database.schema(GalacticJurisdiction.schema) + .field("galaxy_id", .uuid, .required, .references(Galaxy.schema, .id, onDelete: .cascade, onUpdate: .cascade)) + .field("jurisdiction_id", .uuid, .required, .references(Jurisdiction.schema, .id, onDelete: .cascade, onUpdate: .cascade)) + .field("rank", .int, .required) + .compositeIdentifier(over: "galaxy_id", "jurisdiction_id", "rank") + .create() + } + + public func revert(on database: Database) -> EventLoopFuture { + database.schema(GalacticJurisdiction.schema) + .delete() + } +} + +public struct GalacticJurisdictionSeed: Migration { + public init() {} + + public func prepare(on database: Database) -> EventLoopFuture { + database.eventLoop.flatSubmit { + Galaxy.query(on: database).all().and( + Jurisdiction.query(on: database).all()) + }.flatMap { galaxies, jurisdictions in + [ + ("Milky Way", "Old", 0), + ("Milky Way", "Corporate", 1), + ("Andromeda", "Military", 0), + ("Andromeda", "Corporate", 1), + ("Andromeda", "None", 2), + ("Pinwheel Galaxy", "Q", 0), + ("Messier 82", "None", 0), + ("Messier 82", "Military", 1), + ] + .sequencedFlatMapEach(on: database.eventLoop) { galaxyName, jurisdictionName, rank in + GalacticJurisdiction.init(id: try! .init( + galaxy: galaxies.first(where: { $0.name == galaxyName })!, + jurisdiction: jurisdictions.first(where: { $0.title == jurisdictionName })!, + rank: rank + )).create(on: database) + } + } + } + + public func revert(on database: Database) -> EventLoopFuture { + GalacticJurisdiction.query(on: database).delete() + } +} diff --git a/Sources/FluentBenchmark/SolarSystem/Galaxy.swift b/Sources/FluentBenchmark/SolarSystem/Galaxy.swift index 36462e0a..f37e25b1 100644 --- a/Sources/FluentBenchmark/SolarSystem/Galaxy.swift +++ b/Sources/FluentBenchmark/SolarSystem/Galaxy.swift @@ -1,4 +1,7 @@ import FluentKit +import Foundation +import NIOCore +import XCTest public final class Galaxy: Model { public static let schema = "galaxies" @@ -11,6 +14,9 @@ public final class Galaxy: Model { @Children(for: \.$galaxy) public var stars: [Star] + + @Siblings(through: GalacticJurisdiction.self, from: \.$id.$galaxy, to: \.$id.$jurisdiction) + public var jurisdictions: [Jurisdiction] public init() { } diff --git a/Sources/FluentBenchmark/SolarSystem/Governor.swift b/Sources/FluentBenchmark/SolarSystem/Governor.swift index 883f8af4..8d314f35 100644 --- a/Sources/FluentBenchmark/SolarSystem/Governor.swift +++ b/Sources/FluentBenchmark/SolarSystem/Governor.swift @@ -1,4 +1,7 @@ import FluentKit +import Foundation +import NIOCore +import XCTest public final class Governor: Model { public static let schema = "governors" diff --git a/Sources/FluentBenchmark/SolarSystem/Jurisdiction.swift b/Sources/FluentBenchmark/SolarSystem/Jurisdiction.swift new file mode 100644 index 00000000..d51a881e --- /dev/null +++ b/Sources/FluentBenchmark/SolarSystem/Jurisdiction.swift @@ -0,0 +1,62 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + +public final class Jurisdiction: Model { + public static let schema = "jurisdictions" + + @ID(key: .id) + public var id: UUID? + + @Field(key: "title") + public var title: String + + @Siblings(through: GalacticJurisdiction.self, from: \.$id.$jurisdiction, to: \.$id.$galaxy) + public var galaxies: [Galaxy] + + public init() {} + + public init(id: IDValue? = nil, title: String) { + self.id = id + self.title = title + } +} + +public struct JurisdictionMigration: Migration { + public init() {} + + public func prepare(on database: Database) -> EventLoopFuture { + database.schema(Jurisdiction.schema) + .field(.id, .uuid, .identifier(auto: false), .required) + .field("title", .string, .required) + .unique(on: "title") + .create() + } + + public func revert(on database: Database) -> EventLoopFuture { + database.schema(Jurisdiction.schema) + .delete() + } +} + +public struct JurisdictionSeed: Migration { + public init() {} + + public func prepare(on database: Database) -> EventLoopFuture { + [ + "Old", + "Corporate", + "Military", + "None", + "Q", + ] + .map { Jurisdiction(title: $0) } + .create(on: database) + } + + public func revert(on database: Database) -> EventLoopFuture { + Jurisdiction.query(on: database) + .delete() + } +} diff --git a/Sources/FluentBenchmark/SolarSystem/Moon.swift b/Sources/FluentBenchmark/SolarSystem/Moon.swift index 27c44d33..20c53cf4 100644 --- a/Sources/FluentBenchmark/SolarSystem/Moon.swift +++ b/Sources/FluentBenchmark/SolarSystem/Moon.swift @@ -1,4 +1,6 @@ import FluentKit +import Foundation +import NIOCore public final class Moon: Model { public static let schema = "moons" diff --git a/Sources/FluentBenchmark/SolarSystem/Planet.swift b/Sources/FluentBenchmark/SolarSystem/Planet.swift index 173977ce..a3755413 100644 --- a/Sources/FluentBenchmark/SolarSystem/Planet.swift +++ b/Sources/FluentBenchmark/SolarSystem/Planet.swift @@ -1,4 +1,7 @@ import FluentKit +import Foundation +import NIOCore +import XCTest public final class Planet: Model { public static let schema = "planets" @@ -23,6 +26,9 @@ public final class Planet: Model { @Siblings(through: PlanetTag.self, from: \.$planet, to: \.$tag) public var tags: [Tag] + + @Timestamp(key: "deleted_at", on: .delete) + var deletedAt: Date? public init() { } @@ -45,6 +51,7 @@ public struct PlanetMigration: Migration { .field("name", .string, .required) .field("star_id", .uuid, .required, .references("stars", "id")) .field("possible_star_id", .uuid, .references("stars", "id")) + .field("deleted_at", .datetime) .create() } @@ -85,6 +92,6 @@ public struct PlanetSeed: Migration { } public func revert(on database: Database) -> EventLoopFuture { - Planet.query(on: database).delete() + Planet.query(on: database).delete(force: true) } } diff --git a/Sources/FluentBenchmark/SolarSystem/PlanetTag.swift b/Sources/FluentBenchmark/SolarSystem/PlanetTag.swift index bd2d45dc..425fbe0f 100644 --- a/Sources/FluentBenchmark/SolarSystem/PlanetTag.swift +++ b/Sources/FluentBenchmark/SolarSystem/PlanetTag.swift @@ -1,4 +1,7 @@ import FluentKit +import Foundation +import NIOCore +import XCTest public final class PlanetTag: Model { public static let schema = "planet+tag" @@ -11,13 +14,17 @@ public final class PlanetTag: Model { @Parent(key: "tag_id") public var tag: Tag + + @OptionalField(key: "comments") + public var comments: String? public init() { } - public init(id: IDValue? = nil, planetID: Planet.IDValue, tagID: Tag.IDValue) { + public init(id: IDValue? = nil, planetID: Planet.IDValue, tagID: Tag.IDValue, comments: String? = nil) { self.id = id self.$planet.id = planetID self.$tag.id = tagID + self.comments = comments } } @@ -25,15 +32,18 @@ public struct PlanetTagMigration: Migration { public init() { } public func prepare(on database: Database) -> EventLoopFuture { - database.schema("planet+tag") - .field("id", .uuid, .identifier(auto: false)) - .field("planet_id", .uuid, .required, .references("planets", "id")) - .field("tag_id", .uuid, .required, .references("tags", "id")) + database.schema(PlanetTag.schema) + .id() + .field("planet_id", .uuid, .required) + .field("tag_id", .uuid, .required) + .field("comments", .string) + .foreignKey("planet_id", references: Planet.schema, .id) + .foreignKey("tag_id", references: Tag.schema, .id) .create() } public func revert(on database: Database) -> EventLoopFuture { - database.schema("planet+tag").delete() + database.schema(PlanetTag.schema).delete() } } diff --git a/Sources/FluentBenchmark/SolarSystem/SolarSystem.swift b/Sources/FluentBenchmark/SolarSystem/SolarSystem.swift index 4e7d9899..5b0a6468 100644 --- a/Sources/FluentBenchmark/SolarSystem/SolarSystem.swift +++ b/Sources/FluentBenchmark/SolarSystem/SolarSystem.swift @@ -1,4 +1,6 @@ import AsyncKit +import FluentKit +import NIOCore private let migrations: [Migration] = [ GalaxyMigration(), diff --git a/Sources/FluentBenchmark/SolarSystem/Star.swift b/Sources/FluentBenchmark/SolarSystem/Star.swift index be5f7858..fbb8ebae 100644 --- a/Sources/FluentBenchmark/SolarSystem/Star.swift +++ b/Sources/FluentBenchmark/SolarSystem/Star.swift @@ -1,4 +1,7 @@ import FluentKit +import Foundation +import NIOCore +import XCTest public final class Star: Model { public static let schema = "stars" @@ -14,6 +17,9 @@ public final class Star: Model { @Children(for: \.$star) public var planets: [Planet] + + @Timestamp(key: "deleted_at", on: .delete) + var deletedAt: Date? public init() { } @@ -29,6 +35,7 @@ public struct StarMigration: Migration { .field("id", .uuid, .identifier(auto: false)) .field("name", .string, .required) .field("galaxy_id", .uuid, .required, .references("galaxies", "id")) + .field("deleted_at", .datetime) .create() } @@ -58,6 +65,6 @@ public final class StarSeed: Migration { } public func revert(on database: Database) -> EventLoopFuture { - Star.query(on: database).delete() + Star.query(on: database).delete(force: true) } } diff --git a/Sources/FluentBenchmark/SolarSystem/Tag.swift b/Sources/FluentBenchmark/SolarSystem/Tag.swift index 27dc469b..218f93f9 100644 --- a/Sources/FluentBenchmark/SolarSystem/Tag.swift +++ b/Sources/FluentBenchmark/SolarSystem/Tag.swift @@ -1,4 +1,7 @@ import FluentKit +import Foundation +import NIOCore +import XCTest public final class Tag: Model { public static let schema = "tags" diff --git a/Sources/FluentBenchmark/Tests/AggregateTests.swift b/Sources/FluentBenchmark/Tests/AggregateTests.swift index ca8dd822..4103635c 100644 --- a/Sources/FluentBenchmark/Tests/AggregateTests.swift +++ b/Sources/FluentBenchmark/Tests/AggregateTests.swift @@ -1,3 +1,6 @@ +import FluentKit +import XCTest + extension FluentBenchmarker { public func testAggregate(max: Bool = true) throws { try self.testAggregate_all(max: max) diff --git a/Sources/FluentBenchmark/Tests/ArrayTests.swift b/Sources/FluentBenchmark/Tests/ArrayTests.swift index 095d7e21..f4b5d68b 100644 --- a/Sources/FluentBenchmark/Tests/ArrayTests.swift +++ b/Sources/FluentBenchmark/Tests/ArrayTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testArray() throws { try self.testArray_basic() @@ -142,7 +147,7 @@ private struct UserMigration: Migration { private final class FooSet: Model { static let schema = "foos" - @ID(key: "id") + @ID(key: .id) var id: UUID? @Field(key: "bar") diff --git a/Sources/FluentBenchmark/Tests/BatchTests.swift b/Sources/FluentBenchmark/Tests/BatchTests.swift index 87691669..3e8abc77 100644 --- a/Sources/FluentBenchmark/Tests/BatchTests.swift +++ b/Sources/FluentBenchmark/Tests/BatchTests.swift @@ -1,3 +1,6 @@ +import NIOCore +import XCTest + extension FluentBenchmarker { public func testBatch() throws { try self.testBatch_create() diff --git a/Sources/FluentBenchmark/Tests/CRUDTests.swift b/Sources/FluentBenchmark/Tests/CRUDTests.swift index 1af76ee1..ccc9258a 100644 --- a/Sources/FluentBenchmark/Tests/CRUDTests.swift +++ b/Sources/FluentBenchmark/Tests/CRUDTests.swift @@ -1,3 +1,7 @@ +import FluentKit +import NIOCore +import XCTest + extension FluentBenchmarker { public func testCRUD() throws { try self.testCRUD_create() diff --git a/Sources/FluentBenchmark/Tests/ChildTests.swift b/Sources/FluentBenchmark/Tests/ChildTests.swift index 6b9774c5..ee429ae2 100644 --- a/Sources/FluentBenchmark/Tests/ChildTests.swift +++ b/Sources/FluentBenchmark/Tests/ChildTests.swift @@ -1,4 +1,9 @@ +import FluentKit +import Foundation +import NIOCore import FluentSQL +import SQLKit +import XCTest extension FluentBenchmarker { public func testChild() throws { diff --git a/Sources/FluentBenchmark/Tests/ChildrenTests.swift b/Sources/FluentBenchmark/Tests/ChildrenTests.swift index 8aa50a34..b9e177a1 100644 --- a/Sources/FluentBenchmark/Tests/ChildrenTests.swift +++ b/Sources/FluentBenchmark/Tests/ChildrenTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testChildren() throws { try self.testChildren_with() diff --git a/Sources/FluentBenchmark/Tests/ChunkTests.swift b/Sources/FluentBenchmark/Tests/ChunkTests.swift index 7fd0214e..ab4f2ec4 100644 --- a/Sources/FluentBenchmark/Tests/ChunkTests.swift +++ b/Sources/FluentBenchmark/Tests/ChunkTests.swift @@ -1,3 +1,6 @@ +import NIOCore +import XCTest + extension FluentBenchmarker { public func testChunk() throws { try self.testChunk_fetch() diff --git a/Sources/FluentBenchmark/Tests/CodableTests.swift b/Sources/FluentBenchmark/Tests/CodableTests.swift index ccbd19d7..68c2429c 100644 --- a/Sources/FluentBenchmark/Tests/CodableTests.swift +++ b/Sources/FluentBenchmark/Tests/CodableTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testCodable() throws { try self.testCodable_decodeError() diff --git a/Sources/FluentBenchmark/Tests/CompositeIDTests.swift b/Sources/FluentBenchmark/Tests/CompositeIDTests.swift new file mode 100644 index 00000000..353e8d59 --- /dev/null +++ b/Sources/FluentBenchmark/Tests/CompositeIDTests.swift @@ -0,0 +1,256 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest +import SQLKit + +extension FluentBenchmarker { + public func testCompositeID() throws { + try self.testCompositeID_create() + try self.testCompositeID_lookup() + try self.testCompositeID_update() + try self.testCompositeID_asPivot() + try self.testCompositeID_eagerLoaders() + try self.testCompositeID_arrayCreateAndDelete() + try self.testCompositeID_count() + + // Embed this here instead of having to update all the Fluent drivers + if self.database is SQLDatabase { + try self.testCompositeRelations() + } + } + + private func testCompositeID_create() throws { + try self.runTest(#function, [ + CompositeIDModelMigration(), + ]) { + let newModel = CompositeIDModel(name: "A", dimensions: 1, additionalInfo: nil) + try newModel.create(on: self.database).wait() + + let count = try CompositeIDModel.query(on: self.database).count(\.$id.$name).wait() + XCTAssertEqual(count, 1) + + let anotherNewModel = CompositeIDModel(name: "A", dimensions: 1, additionalInfo: nil) + XCTAssertThrowsError(try anotherNewModel.create(on: self.database).wait()) + + let differentNewModel = CompositeIDModel(name: "B", dimensions: 1, additionalInfo: nil) + try differentNewModel.create(on: self.database).wait() + + let anotherDifferentNewModel = CompositeIDModel(name: "A", dimensions: 2, additionalInfo: nil) + try anotherDifferentNewModel.create(on: self.database).wait() + + let countAgain = try CompositeIDModel.query(on: self.database).count(\.$id.$name).wait() + XCTAssertEqual(countAgain, 3) + } + } + + private func testCompositeID_lookup() throws { + try self.runTest(#function, [ + CompositeIDModelMigration(), + CompositeIDModelSeed(), + ]) { + let found = try CompositeIDModel.find(.init(name: "A", dimensions: 1), on: self.database).wait() + XCTAssertNotNil(found) + + let foundByPartial = try CompositeIDModel.query(on: self.database).filter(\.$id.$name == "B").all().wait() + XCTAssertEqual(foundByPartial.count, 1) + XCTAssertEqual(foundByPartial.first?.id?.dimensions, 1) + + let foundByOtherPartial = try CompositeIDModel.query(on: self.database).filter(\.$id.$dimensions == 2).all().wait() + XCTAssertEqual(foundByOtherPartial.count, 1) + XCTAssertEqual(foundByOtherPartial.first?.id?.name, "A") + } + } + + private func testCompositeID_update() throws { + try self.runTest(#function, [ + CompositeIDModelMigration(), + CompositeIDModelSeed(), + ]) { + let existing = try XCTUnwrap(CompositeIDModel.find(.init(name: "A", dimensions: 1), on: self.database).wait()) + + existing.additionalInfo = "additional" + try existing.update(on: self.database).wait() + + XCTAssertEqual(try CompositeIDModel.query(on: self.database).filter(\.$additionalInfo == "additional").count(\.$id.$name).wait(), 1) + + try CompositeIDModel.query(on: self.database).filter(\.$id.$name == "A").filter(\.$id.$dimensions == 1).set(\.$id.$dimensions, to: 3).update().wait() + XCTAssertNotNil(try CompositeIDModel.find(.init(name: "A", dimensions: 3), on: self.database).wait()) + } + } + + private func testCompositeID_asPivot() throws { + try self.runTest(#function, [ + GalaxyMigration(), + JurisdictionMigration(), + GalacticJurisdictionMigration(), + GalaxySeed(), + JurisdictionSeed(), + ]) { + let milkyWayGalaxy = try XCTUnwrap(Galaxy.query(on: self.database).filter(\.$name == "Milky Way").first().wait()) + let andromedaGalaxy = try XCTUnwrap(Galaxy.query(on: self.database).filter(\.$name == "Andromeda").first().wait()) + let oldJurisdiction = try XCTUnwrap(Jurisdiction.query(on: self.database).filter(\.$title == "Old").first().wait()) + let noneJurisdiction = try XCTUnwrap(Jurisdiction.query(on: self.database).filter(\.$title == "None").first().wait()) + + try milkyWayGalaxy.$jurisdictions.attach(oldJurisdiction, method: .always, on: self.database, { $0.$id.$rank.value = 1 }).wait() + try noneJurisdiction.$galaxies.attach(andromedaGalaxy, method: .always, on: self.database, { $0.$id.$rank.value = 0 }).wait() + try noneJurisdiction.$galaxies.attach(milkyWayGalaxy, method: .always, on: self.database, { $0.$id.$rank.value = 2 }).wait() + + let pivots = try GalacticJurisdiction.query(on: self.database).all().wait() + + XCTAssertEqual(pivots.count, 3) + XCTAssertTrue(pivots.contains(where: { $0.id!.$galaxy.id == milkyWayGalaxy.id! && $0.id!.$jurisdiction.id == oldJurisdiction.id! && $0.id!.rank == 1 })) + XCTAssertTrue(pivots.contains(where: { $0.id!.$galaxy.id == milkyWayGalaxy.id! && $0.id!.$jurisdiction.id == noneJurisdiction.id! && $0.id!.rank == 2 })) + XCTAssertTrue(pivots.contains(where: { $0.id!.$galaxy.id == andromedaGalaxy.id! && $0.id!.$jurisdiction.id == noneJurisdiction.id! && $0.id!.rank == 0 })) + } + } + + private func testCompositeID_eagerLoaders() throws { + try self.runTest(#function, [ + GalaxyMigration(), + StarMigration(), + JurisdictionMigration(), + GalacticJurisdictionMigration(), + GalaxySeed(), + StarSeed(), + JurisdictionSeed(), + GalacticJurisdictionSeed(), + ]) { + let milkyWayGalaxy = try XCTUnwrap(Galaxy.query(on: self.database).filter(\.$name == "Milky Way").with(\.$jurisdictions).first().wait()) + XCTAssertEqual(milkyWayGalaxy.jurisdictions.count, 2) + + let militaryJurisdiction = try XCTUnwrap(Jurisdiction.query(on: self.database).filter(\.$title == "Military").with(\.$galaxies).with(\.$galaxies.$pivots).first().wait()) + XCTAssertEqual(militaryJurisdiction.galaxies.count, 2) + XCTAssertEqual(militaryJurisdiction.$galaxies.pivots.count, 2) + + let corporateMilkyWayPivot = try XCTUnwrap(GalacticJurisdiction.query(on: self.database) + .join(parent: \.$id.$galaxy).filter(Galaxy.self, \.$name == "Milky Way") + .join(parent: \.$id.$jurisdiction).filter(Jurisdiction.self, \.$title == "Corporate") + .with(\.$id.$galaxy) { $0.with(\.$stars) }.with(\.$id.$jurisdiction) + .first().wait()) + XCTAssertNotNil(corporateMilkyWayPivot.$id.$galaxy.value) + XCTAssertNotNil(corporateMilkyWayPivot.$id.$jurisdiction.value) + XCTAssertEqual(corporateMilkyWayPivot.id!.galaxy.stars.count, 2) + } + } + + private func testCompositeID_arrayCreateAndDelete() throws { + try self.runTest(#function, [ + GalaxyMigration(), + JurisdictionMigration(), + GalacticJurisdictionMigration(), + GalaxySeed(), + JurisdictionSeed(), + ]) { + let milkyWayGalaxy = try XCTUnwrap(Galaxy.query(on: self.database).filter(\.$name == "Milky Way").first().wait()) + let allJurisdictions = try Jurisdiction.query(on: self.database).all().wait() + + assert(!allJurisdictions.isEmpty, "Test expects there to be at least one jurisdiction defined") + + try milkyWayGalaxy.$jurisdictions.attach(allJurisdictions, on: self.database) { $0.id!.rank = 1 }.wait() // `Siblings.attach(_:on:)` uses array create. + + let milkyWayGalaxyReloaded = try XCTUnwrap(Galaxy.query(on: self.database).filter(\.$name == "Milky Way").with(\.$jurisdictions).with(\.$jurisdictions.$pivots).first().wait()) + XCTAssertEqual(milkyWayGalaxyReloaded.jurisdictions.count, allJurisdictions.count) + + try milkyWayGalaxyReloaded.$jurisdictions.pivots.delete(on: self.database).wait() // `Silbings.detach(_:on:)` does *not* use array delete, though, so do it ourselves. + + let milkyWayGalaxyRevolutions = try XCTUnwrap(Galaxy.query(on: self.database).filter(\.$name == "Milky Way").with(\.$jurisdictions).first().wait()) + XCTAssertEqual(milkyWayGalaxyRevolutions.jurisdictions.count, 0) + } + } + + private func testCompositeID_count() throws { + try self.runTest(#function, [ + GalaxyMigration(), + JurisdictionMigration(), + GalacticJurisdictionMigration(), + GalaxySeed(), + JurisdictionSeed(), + GalacticJurisdictionSeed(), + ]) { + let pivotCount = try GalacticJurisdiction.query(on: self.database).count().wait() + + XCTAssertGreaterThan(pivotCount, 0) + } + } +} + +public final class CompositeIDModel: Model { + public static let schema = "composite_id_models" + + public final class IDValue: Fields, Hashable { + @Field(key: "name") + public var name: String + + @Field(key: "dimensions") + public var dimensions: Int + + public init() {} + + public init(name: String, dimensions: Int) { + self.name = name + self.dimensions = dimensions + } + + public static func == (lhs: IDValue, rhs: IDValue) -> Bool { + lhs.name == rhs.name && lhs.dimensions == rhs.dimensions + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.name) + hasher.combine(self.dimensions) + } + } + + @CompositeID() + public var id: IDValue? + + @OptionalField(key: "additional_info") + public var additionalInfo: String? + + public init() {} + + public init(id: IDValue, additionalInfo: String? = nil) { + self.id = id + self.additionalInfo = additionalInfo + } + + public convenience init(name: String, dimensions: Int, additionalInfo: String? = nil) { + self.init(id: .init(name: name, dimensions: dimensions), additionalInfo: additionalInfo) + } +} + +public struct CompositeIDModelMigration: Migration { + public init() {} + + public func prepare(on database: Database) -> EventLoopFuture { + database.schema(CompositeIDModel.schema) + .field("name", .string, .required) + .field("dimensions", .int, .required) + .field("additional_info", .string) + .compositeIdentifier(over: "name", "dimensions") + .create() + } + + public func revert(on database: Database) -> EventLoopFuture { + database.schema(CompositeIDModel.schema) + .delete() + } +} + +public struct CompositeIDModelSeed: Migration { + public init() {} + + public func prepare(on database: Database) -> EventLoopFuture { + [ + CompositeIDModel(name: "A", dimensions: 1, additionalInfo: nil), + CompositeIDModel(name: "A", dimensions: 2, additionalInfo: nil), + CompositeIDModel(name: "B", dimensions: 1, additionalInfo: nil), + ].map { $0.create(on: database) }.flatten(on: database.eventLoop) + } + + public func revert(on database: Database) -> EventLoopFuture { + CompositeIDModel.query(on: database).delete() + } +} + diff --git a/Sources/FluentBenchmark/Tests/CompositeRelationTests.swift b/Sources/FluentBenchmark/Tests/CompositeRelationTests.swift new file mode 100644 index 00000000..5b811ead --- /dev/null +++ b/Sources/FluentBenchmark/Tests/CompositeRelationTests.swift @@ -0,0 +1,437 @@ +import XCTest +import SQLKit +import FluentKit + +extension FluentBenchmarker { + public func testCompositeRelations() throws { + try testCompositeParent_loading() + try testCompositeChildren_loading() + try testCompositeParent_nestedInCompositeID() + } + + private func testCompositeParent_loading() throws { + try self.runTest(#function, [ + CompositeIDParentModel.ModelMigration(), + CompositeIDParentModel.ModelSeed(), + CompositeIDChildModel.ModelMigration(), + CompositeIDChildModel.ModelSeed(), + ]) { + let child1 = try XCTUnwrap(CompositeIDChildModel.find(1, on: self.database).wait()) + let child2 = try XCTUnwrap(CompositeIDChildModel.find(2, on: self.database).wait()) + + XCTAssertEqual(child1.$compositeIdParentModel.id, .init(name: "A")) + XCTAssertNil(child1.$additionalCompositeIdParentModel.id) + XCTAssertEqual(child1.$linkedCompositeIdParentModel.id, .init(name: "A")) + XCTAssertNil(child1.$additionalLinkedCompositeIdParentModel.id) + + XCTAssertEqual(child2.$compositeIdParentModel.id, .init(name: "A")) + XCTAssertEqual(child2.$additionalCompositeIdParentModel.id, .init(name: "B")) + XCTAssertEqual(child2.$linkedCompositeIdParentModel.id, .init(name: "B")) + XCTAssertEqual(child2.$additionalLinkedCompositeIdParentModel.id, .init(name: "A")) + + XCTAssertEqual(try child1.$compositeIdParentModel.get(on: self.database).wait().id, child1.$compositeIdParentModel.id) + XCTAssertNil(try child1.$additionalCompositeIdParentModel.get(on: self.database).wait()) + XCTAssertEqual(try child1.$linkedCompositeIdParentModel.get(on: self.database).wait().id, child1.$linkedCompositeIdParentModel.id) + XCTAssertNil(try child1.$additionalLinkedCompositeIdParentModel.get(on: self.database).wait()) + + XCTAssertEqual(try child2.$compositeIdParentModel.get(on: self.database).wait().id, child2.$compositeIdParentModel.id) + XCTAssertEqual(try child2.$additionalCompositeIdParentModel.get(on: self.database).wait()?.id, child2.$additionalCompositeIdParentModel.id) + XCTAssertEqual(try child2.$linkedCompositeIdParentModel.get(on: self.database).wait().id, child2.$linkedCompositeIdParentModel.id) + XCTAssertEqual(try child2.$additionalLinkedCompositeIdParentModel.get(on: self.database).wait()?.id, child2.$additionalLinkedCompositeIdParentModel.id) + + let child3 = try XCTUnwrap(CompositeIDChildModel.query(on: self.database).filter(\.$id == 1).with(\.$compositeIdParentModel).with(\.$additionalCompositeIdParentModel).with(\.$linkedCompositeIdParentModel).with(\.$additionalLinkedCompositeIdParentModel).first().wait()) + let child4 = try XCTUnwrap(CompositeIDChildModel.query(on: self.database).filter(\.$id == 2).with(\.$compositeIdParentModel).with(\.$additionalCompositeIdParentModel).with(\.$linkedCompositeIdParentModel).with(\.$additionalLinkedCompositeIdParentModel).first().wait()) + + XCTAssertEqual(child3.$compositeIdParentModel.value?.id, child3.$compositeIdParentModel.id) + XCTAssertNil(child3.$additionalCompositeIdParentModel.value??.id) + XCTAssertEqual(child3.$linkedCompositeIdParentModel.value?.id, child3.$linkedCompositeIdParentModel.id) + XCTAssertNil(child3.$additionalLinkedCompositeIdParentModel.value??.id) + + XCTAssertEqual(child4.$compositeIdParentModel.value?.id, child4.$compositeIdParentModel.id) + XCTAssertEqual(child4.$additionalCompositeIdParentModel.value??.id, child4.$additionalCompositeIdParentModel.id) + XCTAssertEqual(child4.$linkedCompositeIdParentModel.value?.id, child4.$linkedCompositeIdParentModel.id) + XCTAssertEqual(child4.$additionalLinkedCompositeIdParentModel.value??.id, child4.$additionalLinkedCompositeIdParentModel.id) + } + } + + private func testCompositeChildren_loading() throws { + try self.runTest(#function, [ + CompositeIDParentModel.ModelMigration(), + CompositeIDParentModel.ModelSeed(), + CompositeIDChildModel.ModelMigration(), + CompositeIDChildModel.ModelSeed(), + ]) { + let parent1 = try XCTUnwrap(CompositeIDParentModel.query(on: self.database).filter(\.$id.$name == "A").first().wait()) + let parent2 = try XCTUnwrap(CompositeIDParentModel.query(on: self.database).filter(\.$id.$name == "B").first().wait()) + let parent3 = try XCTUnwrap(CompositeIDParentModel.query(on: self.database).filter(\.$id.$name == "C").first().wait()) + + let children1_1 = try parent1.$compositeIdChildModels.get(on: self.database).wait() + let children1_2 = try parent1.$additionalCompositeIdChildModels.get(on: self.database).wait() + let children1_3 = try parent1.$linkedCompositeIdChildModel.get(on: self.database).wait() + let children1_4 = try parent1.$additionalLinkedCompositeIdChildModel.get(on: self.database).wait() + + let children2_1 = try parent2.$compositeIdChildModels.get(on: self.database).wait() + let children2_2 = try parent2.$additionalCompositeIdChildModels.get(on: self.database).wait() + let children2_3 = try parent2.$linkedCompositeIdChildModel.get(on: self.database).wait() + let children2_4 = try parent2.$additionalLinkedCompositeIdChildModel.get(on: self.database).wait() + + let children3_1 = try parent3.$compositeIdChildModels.get(on: self.database).wait() + let children3_2 = try parent3.$additionalCompositeIdChildModels.get(on: self.database).wait() + let children3_3 = try parent3.$linkedCompositeIdChildModel.get(on: self.database).wait() + let children3_4 = try parent3.$additionalLinkedCompositeIdChildModel.get(on: self.database).wait() + + XCTAssertEqual(children1_1.compactMap(\.id).sorted(), [1, 2, 3]) + XCTAssertTrue(children1_2.isEmpty) + XCTAssertEqual(children1_3?.id, 1) + XCTAssertEqual(children1_4?.id, 2) + + XCTAssertTrue(children2_1.isEmpty) + XCTAssertEqual(children2_2.compactMap(\.id).sorted(), [2, 3]) + XCTAssertEqual(children2_3?.id, 2) + XCTAssertEqual(children2_4?.id, 3) + + XCTAssertTrue(children3_1.isEmpty) + XCTAssertTrue(children3_2.isEmpty) + XCTAssertEqual(children3_3?.id, 3) + XCTAssertNil(children3_4) + + let parent4 = try XCTUnwrap(CompositeIDParentModel.query(on: self.database).filter(\.$id.$name == "A").with(\.$compositeIdChildModels).with(\.$additionalCompositeIdChildModels).with(\.$linkedCompositeIdChildModel).with(\.$additionalLinkedCompositeIdChildModel).first().wait()) + let parent5 = try XCTUnwrap(CompositeIDParentModel.query(on: self.database).filter(\.$id.$name == "B").with(\.$compositeIdChildModels).with(\.$additionalCompositeIdChildModels).with(\.$linkedCompositeIdChildModel).with(\.$additionalLinkedCompositeIdChildModel).first().wait()) + let parent6 = try XCTUnwrap(CompositeIDParentModel.query(on: self.database).filter(\.$id.$name == "C").with(\.$compositeIdChildModels).with(\.$additionalCompositeIdChildModels).with(\.$linkedCompositeIdChildModel).with(\.$additionalLinkedCompositeIdChildModel).first().wait()) + + XCTAssertEqual(parent4.$compositeIdChildModels.value?.compactMap(\.id).sorted(), [1, 2, 3]) + XCTAssertTrue(parent4.$additionalCompositeIdChildModels.value?.isEmpty ?? false) + XCTAssertEqual(parent4.$linkedCompositeIdChildModel.value??.id, 1) + XCTAssertEqual(parent4.$additionalLinkedCompositeIdChildModel.value??.id, 2) + + XCTAssertTrue(parent5.$compositeIdChildModels.value?.isEmpty ?? false) + XCTAssertEqual(parent5.$additionalCompositeIdChildModels.value?.compactMap(\.id).sorted(), [2, 3]) + XCTAssertEqual(parent5.$linkedCompositeIdChildModel.value??.id, 2) + XCTAssertEqual(parent5.$additionalLinkedCompositeIdChildModel.value??.id, 3) + + XCTAssertTrue(parent6.$compositeIdChildModels.value?.isEmpty ?? false) + XCTAssertTrue(parent6.$additionalCompositeIdChildModels.value?.isEmpty ?? false) + XCTAssertEqual(parent6.$linkedCompositeIdChildModel.value??.id, 3) + XCTAssertNil(parent6.$additionalLinkedCompositeIdChildModel.value??.id) + } + } + + private func testCompositeParent_nestedInCompositeID() throws { + try self.runTest(#function, [ + GalaxyMigration(), + GalaxySeed(), + CompositeParentTheFirst.ModelMigration(), + CompositeParentTheSecond.ModelMigration(), + ]) { + let anyGalaxy = try XCTUnwrap(Galaxy.query(on: self.database).first().wait()) + + let parentFirst = CompositeParentTheFirst(parentId: try anyGalaxy.requireID()) + try parentFirst.create(on: self.database).wait() + + let parentSecond = CompositeParentTheSecond(parentId: try parentFirst.requireID()) + try parentSecond.create(on: self.database).wait() + + XCTAssertEqual(try CompositeParentTheFirst.query(on: self.database).filter(\.$id == parentFirst.requireID()).count().wait(), 1) + + let parentFirstAgain = try XCTUnwrap(CompositeParentTheFirst.query(on: self.database).filter(\.$id.$parent.$id == anyGalaxy.requireID()).with(\.$id.$parent).with(\.$children).first().wait()) + + XCTAssertEqual(parentFirstAgain.id?.$parent.value?.id, anyGalaxy.id) + XCTAssertEqual(parentFirstAgain.$children.value?.first?.id?.$parent.id, parentFirstAgain.id) + + try Galaxy.query(on: self.database).filter(\.$id == anyGalaxy.requireID()).delete(force: true).wait() + + XCTAssertEqual(try CompositeParentTheFirst.query(on: self.database).count().wait(), 0) + XCTAssertEqual(try CompositeParentTheSecond.query(on: self.database).count().wait(), 0) + } + } +} + +final class CompositeIDParentModel: Model { + static let schema = "composite_id_parent_models" + + final class IDValue: Fields, Hashable { + @Field(key: "name") + var name: String + + @Field(key: "dimensions") + var dimensions: Int + + init() {} + + init(name: String, dimensions: Int = 1) { + self.name = name + self.dimensions = dimensions + } + + static func == (lhs: IDValue, rhs: IDValue) -> Bool { + lhs.name == rhs.name && lhs.dimensions == rhs.dimensions + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.name) + hasher.combine(self.dimensions) + } + } + + @CompositeID + var id: IDValue? + + @CompositeChildren(for: \.$compositeIdParentModel) // Children referencing required composite parent + var compositeIdChildModels: [CompositeIDChildModel] + + @CompositeChildren(for: \.$additionalCompositeIdParentModel) // Children referencing optional composite parent + var additionalCompositeIdChildModels: [CompositeIDChildModel] + + @CompositeOptionalChild(for: \.$linkedCompositeIdParentModel) // Optional child referencing required composite parent + var linkedCompositeIdChildModel: CompositeIDChildModel? + + @CompositeOptionalChild(for: \.$additionalLinkedCompositeIdParentModel) // Optional child referencing optional composite parent + var additionalLinkedCompositeIdChildModel: CompositeIDChildModel? + + init() {} + + init(id: IDValue) { + self.id = id + } + + convenience init(name: String, dimensions: Int) { + self.init(id: .init(name: name, dimensions: dimensions)) + } + + struct ModelMigration: Migration { + func prepare(on database: Database) -> EventLoopFuture { + database.schema(CompositeIDParentModel.schema) + .field("name", .string, .required) + .field("dimensions", .int, .required) + .compositeIdentifier(over: "name", "dimensions") + .create() + } + + func revert(on database: Database) -> EventLoopFuture { + database.schema(CompositeIDParentModel.schema) + .delete() + } + } + + struct ModelSeed: Migration { + func prepare(on database: Database) -> EventLoopFuture { + [ + CompositeIDParentModel(name: "A", dimensions: 1), + CompositeIDParentModel(name: "B", dimensions: 1), + CompositeIDParentModel(name: "C", dimensions: 1), + ].map { $0.create(on: database) }.flatten(on: database.eventLoop) + } + + func revert(on database: Database) -> EventLoopFuture { + CompositeIDParentModel.query(on: database).delete() + } + } +} + +final class CompositeIDChildModel: Model { + static let schema = "composite_id_child_models" + + @ID(custom: .id) + var id: Int? + + @CompositeParent(prefix: "comp_parent_model", strategy: .snakeCase) // required composite parent referencing multiple children + var compositeIdParentModel: CompositeIDParentModel + + @CompositeOptionalParent(prefix: "addl_comp_parent_model", strategy: .snakeCase) // optional composite parent referencing multiple children + var additionalCompositeIdParentModel: CompositeIDParentModel? + + @CompositeParent(prefix: "comp_linked_model", strategy: .snakeCase) // required composite parent referencing one optional child + var linkedCompositeIdParentModel: CompositeIDParentModel + + @CompositeOptionalParent(prefix: "addl_comp_linked_model", strategy: .snakeCase) // optional composite parent referencing one optional child + var additionalLinkedCompositeIdParentModel: CompositeIDParentModel? + + init() {} + + init( + id: Int? = nil, + parentId: CompositeIDParentModel.IDValue, + additionalParentId: CompositeIDParentModel.IDValue?, + linkedId: CompositeIDParentModel.IDValue, + additionalLinkedId: CompositeIDParentModel.IDValue? + ) { + self.id = id + self.$compositeIdParentModel.id = parentId + self.$additionalCompositeIdParentModel.id = additionalParentId + self.$linkedCompositeIdParentModel.id = linkedId + self.$additionalLinkedCompositeIdParentModel.id = additionalLinkedId + } + + struct ModelMigration: Migration { + func prepare(on database: Database) -> EventLoopFuture { + database.schema(CompositeIDChildModel.schema) + .field(.id, .int, .required, .identifier(auto: (database as? SQLDatabase)?.dialect.name != "sqlite")) + + .field("comp_parent_model_name", .string, .required) + .field("comp_parent_model_dimensions", .int, .required) + .foreignKey(["comp_parent_model_name", "comp_parent_model_dimensions"], + references: CompositeIDParentModel.schema, ["name", "dimensions"] + ) + + .field("addl_comp_parent_model_name", .string) + .field("addl_comp_parent_model_dimensions", .int) + .constraint(.optionalCompositeReferenceCheck("addl_comp_parent_model_name", "addl_comp_parent_model_dimensions")) + .foreignKey(["addl_comp_parent_model_name", "addl_comp_parent_model_dimensions"], + references: CompositeIDParentModel.schema, ["name", "dimensions"] + ) + + .field("comp_linked_model_name", .string, .required) + .field("comp_linked_model_dimensions", .int, .required) + .unique(on: "comp_linked_model_name", "comp_linked_model_dimensions") + .foreignKey(["comp_linked_model_name", "comp_linked_model_dimensions"], + references: CompositeIDParentModel.schema, ["name", "dimensions"] + ) + + .field("addl_comp_linked_model_name", .string) + .field("addl_comp_linked_model_dimensions", .int) + .unique(on: "addl_comp_linked_model_name", "addl_comp_linked_model_dimensions") + .constraint(.optionalCompositeReferenceCheck("addl_comp_linked_model_name", "addl_comp_linked_model_dimensions")) + .foreignKey(["addl_comp_linked_model_name", "addl_comp_linked_model_dimensions"], + references: CompositeIDParentModel.schema, ["name", "dimensions"] + ) + .create() + } + + func revert(on database: Database) -> EventLoopFuture { + database.schema(CompositeIDChildModel.schema).delete() + } + } + + struct ModelSeed: Migration { + func prepare(on database: Database) -> EventLoopFuture { + [ + CompositeIDChildModel(id: 1, parentId: .init(name: "A"), additionalParentId: nil, linkedId: .init(name: "A"), additionalLinkedId: nil), + CompositeIDChildModel(id: 2, parentId: .init(name: "A"), additionalParentId: .init(name: "B"), linkedId: .init(name: "B"), additionalLinkedId: .init(name: "A")), + CompositeIDChildModel(id: 3, parentId: .init(name: "A"), additionalParentId: .init(name: "B"), linkedId: .init(name: "C"), additionalLinkedId: .init(name: "B")), + ].create(on: database) + } + + func revert(on database: Database) -> EventLoopFuture { + CompositeIDChildModel.query(on: database).delete() + } + } +} + +extension DatabaseSchema.Constraint { + /// Convenience overload of `optionalCompositeReferenceCheck(_:)`. + static func optionalCompositeReferenceCheck(_ field1: FieldKey, _ field2: FieldKey, _ moreFields: FieldKey...) -> DatabaseSchema.Constraint { + return self.optionalCompositeReferenceCheck([field1, field2] + moreFields) + } + + /// Returns a `CHECK` constraint whose condition requires that none of the provided fields be NULL + /// unless all of them are. This is useful for guaranteeing that the columns making up a NULLable + /// multi-column foreign key never specify only a partial key (even if such a key is permitted by + /// the database itself, Fluent's relations make no attempt to support it). + static func optionalCompositeReferenceCheck(_ fields: C) -> DatabaseSchema.Constraint where C: Collection, C.Element == FieldKey { + guard fields.count > 1 else { fatalError("A composite reference check must cover at least two fields.") } + let fields = fields.map { SQLIdentifier($0.description) } + + return .sql(.check(SQLGroupExpression(SQLBinaryExpression( + SQLGroupExpression(SQLBinaryExpression(fields.first!, .is, SQLLiteral.null)), + .equal, + SQLGroupExpression(SQLBinaryExpression(SQLFunction.coalesce([SQLLiteral.null] + fields.dropFirst().map{$0}), .is, SQLLiteral.null)) + )))) + } +} + +final class CompositeParentTheFirst: Model { + static let schema = "composite_parent_the_first" + + final class IDValue: Fields, Hashable { + @Parent(key: "parent_id") + var parent: Galaxy + + init() {} + + init(parentId: Galaxy.IDValue) { + self.$parent.id = parentId + } + + static func == (lhs: IDValue, rhs: IDValue) -> Bool { + lhs.$parent.id == rhs.$parent.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.$parent.id) + } + } + + @CompositeID + var id: IDValue? + + @CompositeChildren(for: \.$id.$parent) + var children: [CompositeParentTheSecond] + + init() {} + + init(parentId: Galaxy.IDValue) { + self.id = .init(parentId: parentId) + } + + struct ModelMigration: Migration { + func prepare(on database: Database) -> EventLoopFuture { + database.schema(CompositeParentTheFirst.schema) + .field("parent_id", .uuid, .required) + .foreignKey("parent_id", references: Galaxy.schema, .id, onDelete: .cascade, onUpdate: .cascade) + .compositeIdentifier(over: "parent_id") + .create() + } + + func revert(on database: Database) -> EventLoopFuture { + database.schema(CompositeParentTheFirst.schema) + .delete() + } + } +} + +final class CompositeParentTheSecond: Model { + static let schema = "composite_parent_the_second" + + final class IDValue: Fields, Hashable { + @CompositeParent(prefix: "ref", strategy: .snakeCase) + var parent: CompositeParentTheFirst + + init() {} + + init(parentId: CompositeParentTheFirst.IDValue) { + self.$parent.id.$parent.id = parentId.$parent.id + } + + static func == (lhs: IDValue, rhs: IDValue) -> Bool { + lhs.$parent.id == rhs.$parent.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.$parent.id) + } + } + + @CompositeID + var id: IDValue? + + init() {} + + init(parentId: CompositeParentTheFirst.IDValue) { + self.id = .init(parentId: parentId) + } + + struct ModelMigration: Migration { + func prepare(on database: Database) -> EventLoopFuture { + database.schema(CompositeParentTheSecond.schema) + .field("ref_parent_id", .uuid, .required) + .foreignKey("ref_parent_id", references: CompositeParentTheFirst.schema, "parent_id", onDelete: .cascade, onUpdate: .cascade) + .compositeIdentifier(over: "ref_parent_id") + .create() + } + + func revert(on database: Database) -> EventLoopFuture { + database.schema(CompositeParentTheSecond.schema) + .delete() + } + } +} diff --git a/Sources/FluentBenchmark/Tests/EagerLoadTests.swift b/Sources/FluentBenchmark/Tests/EagerLoadTests.swift index f0d5a4e4..edc4cf6a 100644 --- a/Sources/FluentBenchmark/Tests/EagerLoadTests.swift +++ b/Sources/FluentBenchmark/Tests/EagerLoadTests.swift @@ -1,9 +1,18 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest +import Logging + extension FluentBenchmarker { public func testEagerLoad() throws { try self.testEagerLoad_nesting() try self.testEagerLoad_children() + try self.testEagerLoad_childrenDeleted() try self.testEagerLoad_parent() + try self.testEagerLoad_parentDeleted() try self.testEagerLoad_siblings() + try self.testEagerLoad_siblingsDeleted() try self.testEagerLoad_parentJSON() try self.testEagerLoad_childrenJSON() try self.testEagerLoad_emptyChildren() @@ -51,6 +60,30 @@ extension FluentBenchmarker { } } } + + private func testEagerLoad_childrenDeleted() throws { + try self.runTest(#function, [ + SolarSystem() + ]) { + try Planet.query(on: self.database).filter(\.$name == "Jupiter").delete().wait() + + let sun1 = try XCTUnwrap(Star.query(on: self.database) + .filter(\.$name == "Sun") + .with(\.$planets, withDeleted: true) + .first().wait() + ) + XCTAssertTrue(sun1.planets.contains { $0.name == "Earth" }) + XCTAssertTrue(sun1.planets.contains { $0.name == "Jupiter" }) + + let sun2 = try XCTUnwrap(Star.query(on: self.database) + .filter(\.$name == "Sun") + .with(\.$planets) + .first().wait() + ) + XCTAssertTrue(sun2.planets.contains { $0.name == "Earth" }) + XCTAssertFalse(sun2.planets.contains { $0.name == "Jupiter" }) + } + } private func testEagerLoad_parent() throws { try self.runTest(#function, [ @@ -71,6 +104,34 @@ extension FluentBenchmarker { } } } + + private func testEagerLoad_parentDeleted() throws { + try self.runTest(#function, [ + SolarSystem() + ]) { + try Star.query(on: self.database).filter(\.$name == "Sun").delete().wait() + + let planet = try XCTUnwrap(Planet.query(on: self.database) + .filter(\.$name == "Earth") + .with(\.$star, withDeleted: true) + .first().wait() + ) + XCTAssertEqual(planet.star.name, "Sun") + + XCTAssertThrowsError( + try Planet.query(on: self.database) + .with(\.$star) + .all().wait() + ) { error in + guard case let .missingParent(from, to, key, _) = error as? FluentError else { + return XCTFail("Unexpected error \(error) thrown") + } + XCTAssertEqual(from, "Planet") + XCTAssertEqual(to, "Star") + XCTAssertEqual(key, "star_id") + } + } + } private func testEagerLoad_siblings() throws { try self.runTest(#function, [ @@ -97,6 +158,28 @@ extension FluentBenchmarker { } } } + + private func testEagerLoad_siblingsDeleted() throws { + try self.runTest(#function, [ + SolarSystem() + ]) { + try Planet.query(on: self.database).filter(\.$name == "Earth").delete().wait() + + let tag1 = try XCTUnwrap(Tag.query(on: self.database) + .filter(\.$name == "Inhabited") + .with(\.$planets, withDeleted: true) + .first().wait() + ) + XCTAssertEqual(Set(tag1.planets.map(\.name)), ["Earth"]) + + let tag2 = try XCTUnwrap(Tag.query(on: self.database) + .filter(\.$name == "Inhabited") + .with(\.$planets) + .first().wait() + ) + XCTAssertEqual(Set(tag2.planets.map(\.name)), []) + } + } private func testEagerLoad_parentJSON() throws { try self.runTest(#function, [ diff --git a/Sources/FluentBenchmark/Tests/EnumTests.swift b/Sources/FluentBenchmark/Tests/EnumTests.swift index ddceb439..29c58260 100644 --- a/Sources/FluentBenchmark/Tests/EnumTests.swift +++ b/Sources/FluentBenchmark/Tests/EnumTests.swift @@ -1,11 +1,20 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testEnum() throws { try self.testEnum_basic() - try self.testEnum_addCase() + try self.testEnum_addCases() try self.testEnum_raw() try self.testEnum_queryFound() try self.testEnum_queryMissing() try self.testEnum_decode() + + // Note: These should really be in their own top-level test case, but then I'd have to open + // PRs against all the drivers again. + try self.testBooleanProperties() } private func testEnum_basic() throws { @@ -13,25 +22,27 @@ extension FluentBenchmarker { FooMigration() ]) { let foo = Foo(bar: .baz, baz: .qux) + XCTAssertTrue(foo.hasChanges) try foo.save(on: self.database).wait() let fetched = try Foo.find(foo.id, on: self.database).wait() XCTAssertEqual(fetched?.bar, .baz) XCTAssertEqual(fetched?.baz, .qux) + XCTAssertEqual(fetched?.hasChanges, false) } } - private func testEnum_addCase() throws { + private func testEnum_addCases() throws { try self.runTest(#function, [ FooMigration(), - BarAddQuuzMigration() + BarAddQuzAndQuzzMigration() ]) { - let foo = Foo(bar: .baz, baz: .qux) + let foo = Foo(bar: .quz, baz: .quzz) try foo.save(on: self.database).wait() let fetched = try Foo.find(foo.id, on: self.database).wait() - XCTAssertEqual(fetched?.bar, .baz) - XCTAssertEqual(fetched?.baz, .qux) + XCTAssertEqual(fetched?.bar, .quz) + XCTAssertEqual(fetched?.baz, .quzz) } } @@ -87,6 +98,14 @@ extension FluentBenchmarker { XCTAssertEqual(fetched3?.bar, .baz) XCTAssertEqual(fetched3?.baz, .qux) + let fetched3Opt = try Foo + .query(on: self.database) + .filter(\.$baz ~~ [.baz, .qux]) + .first() + .wait() + XCTAssertEqual(fetched3Opt?.bar, .baz) + XCTAssertEqual(fetched3Opt?.baz, .qux) + // not in let foo4 = Foo(bar: .baz, baz: .qux) try foo4.save(on: self.database).wait() @@ -99,6 +118,14 @@ extension FluentBenchmarker { XCTAssertEqual(fetched4?.bar, .baz) XCTAssertEqual(fetched4?.baz, .qux) + let fetched4Opt = try Foo + .query(on: self.database) + .filter(\.$baz !~ [.baz]) + .first() + .wait() + XCTAssertEqual(fetched4Opt?.bar, .baz) + XCTAssertEqual(fetched4Opt?.baz, .qux) + // is null let foo5 = Foo(bar: .baz, baz: nil) try foo5.save(on: self.database).wait() @@ -213,10 +240,74 @@ extension FluentBenchmarker { XCTAssertEqual(fetched?.baz, .qux) } } + + public func testBooleanProperties() throws { + try runTest(#function, [ + FlagsMigration() + ]) { + let flags1 = Flags(inquired: true, required: true, desired: true, expired: true, inspired: true, retired: true), + flags2 = Flags(inquired: false, required: false, desired: false, expired: false, inspired: false, retired: false), + flags3 = Flags(inquired: true, required: true, desired: true, expired: nil, inspired: nil, retired: nil) + + try flags1.create(on: self.database).wait() + try flags2.create(on: self.database).wait() + try flags3.create(on: self.database).wait() + + let rawFlags1 = try XCTUnwrap(RawFlags.find(flags1.id!, on: self.database).wait()), + rawFlags2 = try XCTUnwrap(RawFlags.find(flags2.id!, on: self.database).wait()), + rawFlags3 = try XCTUnwrap(RawFlags.find(flags3.id!, on: self.database).wait()) + + XCTAssertEqual(rawFlags1.inquired, true) + XCTAssertEqual(rawFlags1.required, 1) + XCTAssertEqual(rawFlags1.desired, "true") + XCTAssertEqual(rawFlags1.expired, true) + XCTAssertEqual(rawFlags1.inspired, 1) + XCTAssertEqual(rawFlags1.retired, "true") + + XCTAssertEqual(rawFlags2.inquired, false) + XCTAssertEqual(rawFlags2.required, 0) + XCTAssertEqual(rawFlags2.desired, "false") + XCTAssertEqual(rawFlags2.expired, false) + XCTAssertEqual(rawFlags2.inspired, 0) + XCTAssertEqual(rawFlags2.retired, "false") + + XCTAssertEqual(rawFlags3.inquired, true) + XCTAssertEqual(rawFlags3.required, 1) + XCTAssertEqual(rawFlags3.desired, "true") + XCTAssertNil(rawFlags3.expired) + XCTAssertNil(rawFlags3.inspired) + XCTAssertNil(rawFlags3.retired) + + let savedFlags1 = try XCTUnwrap(Flags.find(flags1.id!, on: self.database).wait()), + savedFlags2 = try XCTUnwrap(Flags.find(flags2.id!, on: self.database).wait()), + savedFlags3 = try XCTUnwrap(Flags.find(flags3.id!, on: self.database).wait()) + + XCTAssertEqual(savedFlags1.inquired, flags1.inquired) + XCTAssertEqual(savedFlags1.required, flags1.required) + XCTAssertEqual(savedFlags1.desired, flags1.desired) + XCTAssertEqual(savedFlags1.expired, flags1.expired) + XCTAssertEqual(savedFlags1.inspired, flags1.inspired) + XCTAssertEqual(savedFlags1.retired, flags1.retired) + + XCTAssertEqual(savedFlags2.inquired, flags2.inquired) + XCTAssertEqual(savedFlags2.required, flags2.required) + XCTAssertEqual(savedFlags2.desired, flags2.desired) + XCTAssertEqual(savedFlags2.expired, flags2.expired) + XCTAssertEqual(savedFlags2.inspired, flags2.inspired) + XCTAssertEqual(savedFlags2.retired, flags2.retired) + + XCTAssertEqual(savedFlags3.inquired, flags3.inquired) + XCTAssertEqual(savedFlags3.required, flags3.required) + XCTAssertEqual(savedFlags3.desired, flags3.desired) + XCTAssertEqual(savedFlags3.expired, flags3.expired) + XCTAssertEqual(savedFlags3.inspired, flags3.inspired) + XCTAssertEqual(savedFlags3.retired, flags3.retired) + } + } } private enum Bar: String, Codable { - case baz, qux, quuz + case baz, qux, quz, quzz } private final class Foo: Model { @@ -264,10 +355,11 @@ private struct FooMigration: Migration { } } -private struct BarAddQuuzMigration: Migration { +private struct BarAddQuzAndQuzzMigration: Migration { func prepare(on database: Database) -> EventLoopFuture { database.enum("bar") - .case("quuz") + .case("quz") + .case("quzz") .update() .flatMap { bar in @@ -327,3 +419,73 @@ private struct PetMigration: Migration { database.schema("pets").delete() } } + +private final class Flags: Model { + static let schema = "flags" + + @ID(key: .id) + var id: UUID? + + @Boolean(key: "inquired") + var inquired: Bool + + @Boolean(key: "required", format: .integer) + var required: Bool + + @Boolean(key: "desired", format: .trueFalse) + var desired: Bool + + @OptionalBoolean(key: "expired") + var expired: Bool? + + @OptionalBoolean(key: "inspired", format: .integer) + var inspired: Bool? + + @OptionalBoolean(key: "retired", format: .trueFalse) + var retired: Bool? + + init() {} + + init(id: IDValue? = nil, inquired: Bool, required: Bool, desired: Bool, expired: Bool? = nil, inspired: Bool? = nil, retired: Bool? = nil) { + self.id = id + self.inquired = inquired + self.required = required + self.desired = desired + self.expired = expired + self.inspired = inspired + self.retired = retired + } +} + +private final class RawFlags: Model { + static let schema = "flags" + + @ID(key: .id) var id: UUID? + @Field(key: "inquired") var inquired: Bool + @Field(key: "required") var required: Int + @Field(key: "desired") var desired: String + @OptionalField(key: "expired") var expired: Bool? + @OptionalField(key: "inspired") var inspired: Int? + @OptionalField(key: "retired") var retired: String? + + init() {} +} + +private struct FlagsMigration: Migration { + func prepare(on database: Database) -> EventLoopFuture { + database.schema(Flags.schema) + .field(.id, .uuid, .identifier(auto: false), .required) + .field("inquired", .bool, .required) + .field("required", .int, .required) + .field("desired", .string, .required) + .field("expired", .bool) + .field("inspired", .int) + .field("retired", .string) + .create() + } + + func revert(on database: Database) -> EventLoopFuture { + database.schema(Flags.schema) + .delete() + } +} diff --git a/Sources/FluentBenchmark/Tests/FilterTests.swift b/Sources/FluentBenchmark/Tests/FilterTests.swift index fff12cae..8492a20f 100644 --- a/Sources/FluentBenchmark/Tests/FilterTests.swift +++ b/Sources/FluentBenchmark/Tests/FilterTests.swift @@ -1,3 +1,7 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest import FluentSQL extension FluentBenchmarker { @@ -202,7 +206,7 @@ extension FluentBenchmarker { try Foo(bar: "baz", type: .baz, ownerID: bazOwner.requireID()).create(on: self.database).wait() let foos = try FooOwner.query(on: self.database) - .join(Foo.self, on: \Foo.$owner.$id == \FooOwner.$id) + .join(Foo.self, on: \FooOwner.$id == \Foo.$owner.$id) .filter(Foo.self, \.$type == .foo) .all() .wait() @@ -232,7 +236,7 @@ extension FluentBenchmarker { try Foo(bar: "baz", type: .baz, ownerID: bazOwner.requireID()).create(on: self.database).wait() let bars = try FooOwner.query(on: self.database) - .join(FooAlias.self, on: \FooAlias.$owner.$id == \FooOwner.$id) + .join(FooAlias.self, on: \FooOwner.$id == \FooAlias.$owner.$id) .filter(FooAlias.self, \.$type == .bar) .all() .wait() diff --git a/Sources/FluentBenchmark/Tests/GroupTests.swift b/Sources/FluentBenchmark/Tests/GroupTests.swift index 6a3b371a..5707c015 100644 --- a/Sources/FluentBenchmark/Tests/GroupTests.swift +++ b/Sources/FluentBenchmark/Tests/GroupTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testGroup() throws { try self.testGroup_flat() diff --git a/Sources/FluentBenchmark/Tests/IDTests.swift b/Sources/FluentBenchmark/Tests/IDTests.swift index 0b9b9ef4..6a06e864 100644 --- a/Sources/FluentBenchmark/Tests/IDTests.swift +++ b/Sources/FluentBenchmark/Tests/IDTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testID( autoincrement: Bool = true, diff --git a/Sources/FluentBenchmark/Tests/JoinTests.swift b/Sources/FluentBenchmark/Tests/JoinTests.swift index 717b0e46..b831b4ec 100644 --- a/Sources/FluentBenchmark/Tests/JoinTests.swift +++ b/Sources/FluentBenchmark/Tests/JoinTests.swift @@ -1,3 +1,9 @@ +import SQLKit +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testJoin() throws { try self.testJoin_basic() @@ -6,6 +12,7 @@ extension FluentBenchmarker { try self.testJoin_fieldOrdering() try self.testJoin_aliasNesting() try self.testJoin_partialSelect() + try self.testJoin_complexCondition() } private func testJoin_basic() throws { @@ -152,7 +159,7 @@ extension FluentBenchmarker { final class ChatParticipant: Model { static let schema = "chat_participants" - @ID(key: "id") + @ID(key: .id) var id: UUID? @Parent(key: "user_id") @@ -162,7 +169,7 @@ extension FluentBenchmarker { final class User: Model { static let schema = "users" - @ID(key: "id") + @ID(key: .id) var id: UUID? } @@ -205,6 +212,26 @@ extension FluentBenchmarker { } } } + + private func testJoin_complexCondition() throws { + try self.runTest(#function, [ + SolarSystem() + ]) { + guard self.database is SQLDatabase else { return } + + let planets = try Planet.query(on: self.database) + .join(Star.self, on: \Planet.$star.$id == \Star.$id && \Star.$name != \Planet.$name) + .all().wait() + + XCTAssertFalse(planets.isEmpty) + + let morePlanets = try Planet.query(on: self.database) + .join(Star.self, on: \Planet.$star.$id == \Star.$id && \Star.$name != "Sun") + .all().wait() + + XCTAssertEqual(morePlanets.count, 1) + } + } } private final class Team: Model { diff --git a/Sources/FluentBenchmark/Tests/MiddlewareTests.swift b/Sources/FluentBenchmark/Tests/MiddlewareTests.swift index 647dc346..9ad2591c 100644 --- a/Sources/FluentBenchmark/Tests/MiddlewareTests.swift +++ b/Sources/FluentBenchmark/Tests/MiddlewareTests.swift @@ -1,21 +1,23 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testMiddleware() throws { try self.testMiddleware_methods() try self.testMiddleware_batchCreationFail() - #if compiler(>=5.5) && canImport(_Concurrency) - if #available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) { - try self.testAsyncMiddleware_methods() - } - #endif + try self.testAsyncMiddleware_methods() } public func testMiddleware_methods() throws { + self.databases.middleware.use(UserMiddleware()) + defer { self.databases.middleware.clear() } + try self.runTest(#function, [ UserMigration(), ]) { - self.databases.middleware.use(UserMiddleware()) - let user = User(name: "A") // create do { @@ -60,14 +62,13 @@ extension FluentBenchmarker { } } -#if compiler(>=5.5) && canImport(_Concurrency) - @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public func testAsyncMiddleware_methods() throws { + self.databases.middleware.use(AsyncUserMiddleware()) + defer { self.databases.middleware.clear() } + try self.runTest(#function, [ UserMigration(), ]) { - self.databases.middleware.use(AsyncUserMiddleware()) - let user = User(name: "A") // create do { @@ -111,15 +112,15 @@ extension FluentBenchmarker { XCTAssertEqual(user.name, "G") } } -#endif public func testMiddleware_batchCreationFail() throws { + self.databases.middleware.clear() + self.databases.middleware.use(UserBatchMiddleware()) + defer { self.databases.middleware.clear() } + try self.runTest(#function, [ UserMigration(), ]) { - self.databases.middleware.clear() - self.databases.middleware.use(UserBatchMiddleware()) - let user = User(name: "A") let user2 = User(name: "B") let user3 = User(name: "C") @@ -173,8 +174,6 @@ private struct UserBatchMiddleware: ModelMiddleware { } } -#if compiler(>=5.5) && canImport(_Concurrency) -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) private struct AsyncUserMiddleware: AsyncModelMiddleware { func create(model: User, on db: Database, next: AnyAsyncModelResponder) async throws { model.name = "B" @@ -211,7 +210,6 @@ private struct AsyncUserMiddleware: AsyncModelMiddleware { throw TestError(string: "didDelete") } } -#endif private struct UserMiddleware: ModelMiddleware { func create(model: User, on db: Database, next: AnyModelResponder) -> EventLoopFuture { diff --git a/Sources/FluentBenchmark/Tests/MigratorTests.swift b/Sources/FluentBenchmark/Tests/MigratorTests.swift index 69fbdde7..1315fcd9 100644 --- a/Sources/FluentBenchmark/Tests/MigratorTests.swift +++ b/Sources/FluentBenchmark/Tests/MigratorTests.swift @@ -1,3 +1,9 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest +import Logging + extension FluentBenchmarker { public func testMigrator() throws { try self.testMigrator_success() diff --git a/Sources/FluentBenchmark/Tests/ModelTests.swift b/Sources/FluentBenchmark/Tests/ModelTests.swift index 52c34bb6..684a2d01 100644 --- a/Sources/FluentBenchmark/Tests/ModelTests.swift +++ b/Sources/FluentBenchmark/Tests/ModelTests.swift @@ -1,4 +1,8 @@ -import FluentSQL +import FluentKit +import Foundation +import NIOCore +import XCTest +import SQLKit extension FluentBenchmarker { public func testModel() throws { @@ -10,6 +14,10 @@ extension FluentBenchmarker { try self.testModel_jsonColumn() try self.testModel_hasChanges() try self.testModel_outputError() + if self.database is SQLDatabase { + // Broken in Mongo at this time + try self.testModel_useOfFieldsWithoutGroup() + } } private func testModel_uuid() throws { @@ -163,11 +171,52 @@ extension FluentBenchmarker { } private func testModel_outputError() throws { - let foo = Foo() - do { - try foo.output(from: BadFooOutput()) - } catch { - XCTAssert("\(error)".contains("id")) + try runTest(#function, []) { + let foo = Foo() + do { + try foo.output(from: BadFooOutput()) + } catch { + XCTAssert("\(error)".contains("id")) + } + } + } + + private func testModel_useOfFieldsWithoutGroup() throws { + try runTest(#function, []) { + final class Contained: Fields { + @Field(key: "something") var something: String + @Field(key: "another") var another: Int + init() {} + } + final class Enclosure: Model { + static let schema = "enclosures" + @ID(custom: .id) var id: Int? + @Field(key: "primary") var primary: Contained + @Field(key: "additional") var additional: [Contained] + init() {} + + struct Migration: FluentKit.Migration { + func prepare(on database: Database) -> EventLoopFuture { + database.schema(Enclosure.schema) + .field(.id, .int, .required, .identifier(auto: true)) + .field("primary", .json, .required) + .field("additional", .array(of: .json), .required) + .create() + } + func revert(on database: Database) -> EventLoopFuture { database.schema(Enclosure.schema).delete() } + } + } + + try Enclosure.Migration().prepare(on: self.database).wait() + + let enclosure = Enclosure() + enclosure.primary = .init() + enclosure.primary.something = "" + enclosure.primary.another = 0 + enclosure.additional = [] + try enclosure.save(on: self.database).wait() + + try! Enclosure.Migration().revert(on: self.database).wait() } } } diff --git a/Sources/FluentBenchmark/Tests/OptionalParentTests.swift b/Sources/FluentBenchmark/Tests/OptionalParentTests.swift index 2cbd6351..1778f98c 100644 --- a/Sources/FluentBenchmark/Tests/OptionalParentTests.swift +++ b/Sources/FluentBenchmark/Tests/OptionalParentTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testOptionalParent() throws { try runTest(#function, [ @@ -60,6 +65,26 @@ extension FluentBenchmarker { .all().wait() XCTAssertEqual(users2.count, 1) XCTAssert(users2.first?.bestFriend == nil) + + // Test deleted OptionalParent + try User.query(on: self.database).filter(\.$name == "Swift").delete().wait() + + let users3 = try User.query(on: self.database) + .with(\.$bestFriend, withDeleted: true) + .all().wait() + XCTAssertEqual(users3.first?.bestFriend?.name, "Swift") + + XCTAssertThrowsError(try User.query(on: self.database) + .with(\.$bestFriend) + .all().wait() + ) { error in + guard case let .missingParent(from, to, key, _) = error as? FluentError else { + return XCTFail("Unexpected error \(error) thrown") + } + XCTAssertEqual(from, "User") + XCTAssertEqual(to, "User") + XCTAssertEqual(key, "bf_id") + } } } } @@ -88,6 +113,9 @@ private final class User: Model { @Children(for: \.$bestFriend) var friends: [User] + + @Timestamp(key: "deleted_at", on: .delete) + var deletedAt: Date? init() { } @@ -106,6 +134,7 @@ private struct UserMigration: Migration { .field("name", .string, .required) .field("pet", .json, .required) .field("bf_id", .uuid) + .field("deleted_at", .datetime) .create() } diff --git a/Sources/FluentBenchmark/Tests/PaginationTests.swift b/Sources/FluentBenchmark/Tests/PaginationTests.swift index bd8be7a7..fda61098 100644 --- a/Sources/FluentBenchmark/Tests/PaginationTests.swift +++ b/Sources/FluentBenchmark/Tests/PaginationTests.swift @@ -1,3 +1,6 @@ +import FluentKit +import XCTest + extension FluentBenchmarker { public func testPagination() throws { try self.runTest(#function, [ @@ -12,6 +15,7 @@ extension FluentBenchmarker { XCTAssertEqual(planetsPage1.metadata.page, 1) XCTAssertEqual(planetsPage1.metadata.per, 2) XCTAssertEqual(planetsPage1.metadata.total, 9) + XCTAssertEqual(planetsPage1.metadata.pageCount, 5) XCTAssertEqual(planetsPage1.items.count, 2) XCTAssertEqual(planetsPage1.items[0].name, "Earth") XCTAssertEqual(planetsPage1.items[1].name, "Jupiter") @@ -25,6 +29,7 @@ extension FluentBenchmarker { XCTAssertEqual(planetsPage2.metadata.page, 2) XCTAssertEqual(planetsPage2.metadata.per, 2) XCTAssertEqual(planetsPage2.metadata.total, 9) + XCTAssertEqual(planetsPage2.metadata.pageCount, 5) XCTAssertEqual(planetsPage2.items.count, 2) XCTAssertEqual(planetsPage2.items[0].name, "Mars") XCTAssertEqual(planetsPage2.items[1].name, "Mercury") diff --git a/Sources/FluentBenchmark/Tests/ParentTests.swift b/Sources/FluentBenchmark/Tests/ParentTests.swift index 56f3cd65..0836b8be 100644 --- a/Sources/FluentBenchmark/Tests/ParentTests.swift +++ b/Sources/FluentBenchmark/Tests/ParentTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testParent() throws { try self.testParent_serialization() diff --git a/Sources/FluentBenchmark/Tests/PerformanceTests+Siblings.swift b/Sources/FluentBenchmark/Tests/PerformanceTests+Siblings.swift index 4e8f9a42..0264cbc4 100644 --- a/Sources/FluentBenchmark/Tests/PerformanceTests+Siblings.swift +++ b/Sources/FluentBenchmark/Tests/PerformanceTests+Siblings.swift @@ -1,5 +1,9 @@ import XCTest import Dispatch +import FluentKit +import Foundation +import NIOCore +import SQLKit extension FluentBenchmarker { internal func testPerformance_siblings() throws { @@ -21,7 +25,7 @@ extension FluentBenchmarker { PersonSeed(), ExpeditionSeed(), ExpeditionPeopleSeed(), - ], on: conn) { + ], on: conn) { conn in let start = Date() let expeditions = try Expedition.query(on: conn) .with(\.$officers) @@ -29,56 +33,112 @@ extension FluentBenchmarker { .with(\.$doctors) .all().wait() let time = Date().timeIntervalSince(start) + // Circa Swift 5.2: // Run took 24.121525049209595 seconds. // Run took 0.33231091499328613 seconds. - self.database.logger.info("Run took \(time) seconds.") + // Circa Swift 5.8: + // Run took 1.6426270008087158 seconds. + // Run took 0.40939199924468994 seconds. + conn.logger.info("Run took \(time) seconds.") XCTAssertEqual(expeditions.count, 300) + if let sqlConn = conn as? any SQLDatabase { + struct DTO1: Codable { let id: UUID; let name: String, area: String, objective: String } + struct DTO2: Codable { let id: UUID, expedition_id: UUID, person_id: UUID } + let start = Date() + let expeditions = try sqlConn.select().columns("id", "name", "area", "objective").from(Expedition.schema).all(decoding: DTO1.self).wait() + let officers = try sqlConn.select().columns("id", "expedition_id", "person_id").from(ExpeditionOfficer.schema).where(SQLIdentifier("expedition_id"), .in, expeditions.map(\.id)).all(decoding: DTO2.self).wait() + let scientists = try sqlConn.select().columns("id", "expedition_id", "person_id").from(ExpeditionScientist.schema).where(SQLIdentifier("expedition_id"), .in, expeditions.map(\.id)).all(decoding: DTO2.self).wait() + let doctors = try sqlConn.select().columns("id", "expedition_id", "person_id").from(ExpeditionDoctor.schema).where(SQLIdentifier("expedition_id"), .in, expeditions.map(\.id)).all(decoding: DTO2.self).wait() + let time = Date().timeIntervalSince(start) + // Run (SQLKit mode) took 0.6164050102233887 seconds. + // Run (SQLKit mode) took 0.050302982330322266 seconds. + conn.logger.info("Run (SQLKit mode) took \(time) seconds.") + XCTAssertEqual(expeditions.count, 300) + XCTAssertEqual(officers.count, 600) + XCTAssertEqual(scientists.count, 1500) + XCTAssertEqual(doctors.count, 900) + } } } } private struct PersonSeed: Migration { - func prepare(on database: Database) -> EventLoopFuture { - .andAllSucceed((1...600).map { i in - Person(firstName: "Foo #\(i)", lastName: "Bar") - .create(on: database) - }, on: database.eventLoop) + func prepare(on database: any Database) -> EventLoopFuture { + if let sqlDatabase = database as? any SQLDatabase { + struct DTO: Codable { let id: UUID; let first_name: String, last_name: String } + return try! sqlDatabase.insert(into: Person.schema) + .models((1...600).map { DTO(id: UUID(), first_name: "Foo #\($0)", last_name: "Bar") }) + .run() + } else { + return .andAllSucceed((1...600).map { i in + Person(firstName: "Foo #\(i)", lastName: "Bar") + .create(on: database) + }, on: database.eventLoop) + } } - func revert(on database: Database) -> EventLoopFuture { + func revert(on database: any Database) -> EventLoopFuture { Person.query(on: database).delete() } } private struct ExpeditionSeed: Migration { - func prepare(on database: Database) -> EventLoopFuture { - .andAllSucceed((1...300).map { i in - Expedition(name: "Baz #\(i)", area: "Qux", objective: "Quuz") - .create(on: database) - }, on: database.eventLoop) + func prepare(on database: any Database) -> EventLoopFuture { + if let sqlDatabase = database as? any SQLDatabase { + struct DTO: Codable { let id: UUID; let name: String, area: String, objective: String } + return try! sqlDatabase.insert(into: Expedition.schema) + .models((1...300).map { DTO(id: UUID(), name: "Baz #\($0)", area: "Qux", objective: "Quuz") }) + .run() + } else { + return .andAllSucceed((1...300).map { i in + Expedition(name: "Baz #\(i)", area: "Qux", objective: "Quuz") + .create(on: database) + }, on: database.eventLoop) + } } - func revert(on database: Database) -> EventLoopFuture { + func revert(on database: any Database) -> EventLoopFuture { Expedition.query(on: database).delete() } } private struct ExpeditionPeopleSeed: Migration { - func prepare(on database: Database) -> EventLoopFuture { - Expedition.query(on: database).all() - .and(Person.query(on: database).all()) - .flatMap - { (expeditions, people) in - .andAllSucceed(expeditions.map { expedition in - expedition.$officers.attach(people.pickRandomly(2), on: database) - .and(expedition.$scientists.attach(people.pickRandomly(5), on: database)) - .and(expedition.$doctors.attach(people.pickRandomly(3), on: database)) - .map { _ in } - }, on: database.eventLoop) + func prepare(on database: any Database) -> EventLoopFuture { + if let sqlDatabase = database as? any SQLDatabase { + return + sqlDatabase.select().column("id").from(Expedition.schema).all().flatMapEachThrowing { try $0.decode(column: "id", as: UUID.self) } + .and(sqlDatabase.select().column("id").from(Person.schema).all().flatMapEachThrowing { try $0.decode(column: "id", as: UUID.self) }) + .flatMap { expeditions, people in + struct DTO: Codable { let id: UUID, expedition_id: UUID, person_id: UUID } + var officers: [DTO] = [], scientists: [DTO] = [], doctors: [DTO] = [] + + for expedition in expeditions { + officers.append(contentsOf: people.pickRandomly(2).map { DTO(id: UUID(), expedition_id: expedition, person_id: $0) }) + scientists.append(contentsOf: people.pickRandomly(5).map { DTO(id: UUID(), expedition_id: expedition, person_id: $0) }) + doctors.append(contentsOf: people.pickRandomly(3).map { DTO(id: UUID(), expedition_id: expedition, person_id: $0) }) + } + return .andAllSucceed([ + try! sqlDatabase.insert(into: ExpeditionOfficer.schema).models(officers).run(), + try! sqlDatabase.insert(into: ExpeditionScientist.schema).models(scientists).run(), + try! sqlDatabase.insert(into: ExpeditionDoctor.schema).models(doctors).run(), + ], on: sqlDatabase.eventLoop) + } + } else { + return Expedition.query(on: database).all() + .and(Person.query(on: database).all()) + .flatMap + { (expeditions, people) in + .andAllSucceed(expeditions.map { expedition in + expedition.$officers.attach(people.pickRandomly(2), on: database) + .and(expedition.$scientists.attach(people.pickRandomly(5), on: database)) + .and(expedition.$doctors.attach(people.pickRandomly(3), on: database)) + .map { _ in } + }, on: database.eventLoop) + } } } - func revert(on database: Database) -> EventLoopFuture { + func revert(on database: any Database) -> EventLoopFuture { .andAllSucceed([ ExpeditionOfficer.query(on: database).delete(), ExpeditionScientist.query(on: database).delete(), @@ -134,7 +194,7 @@ private struct PersonMigration: Migration { private final class Expedition: Model { static let schema = "expeditions" - @ID(key: "id") + @ID(key: .id) var id: UUID? @Field(key: "name") @@ -188,7 +248,7 @@ private struct ExpeditionMigration: Migration { private final class ExpeditionOfficer: Model { static let schema = "expedition+officer" - @ID(key: "id") + @ID(key: .id) var id: UUID? @Parent(key: "expedition_id") @@ -217,7 +277,7 @@ private struct ExpeditionOfficerMigration: Migration { private final class ExpeditionScientist: Model { static let schema = "expedition+scientist" - @ID(key: "id") + @ID(key: .id) var id: UUID? @Parent(key: "expedition_id") @@ -247,7 +307,7 @@ private struct ExpeditionScientistMigration: Migration { private final class ExpeditionDoctor: Model { static let schema = "expedition+doctor" - @ID(key: "id") + @ID(key: .id) var id: UUID? @Parent(key: "expedition_id") diff --git a/Sources/FluentBenchmark/Tests/PerformanceTests.swift b/Sources/FluentBenchmark/Tests/PerformanceTests.swift index 073c45f4..cf5a5ded 100644 --- a/Sources/FluentBenchmark/Tests/PerformanceTests.swift +++ b/Sources/FluentBenchmark/Tests/PerformanceTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testPerformance(decimalType: DatabaseSchema.DataType = .string) throws { try self.testPerformance_largeModel(decimalType: decimalType) diff --git a/Sources/FluentBenchmark/Tests/RangeTests.swift b/Sources/FluentBenchmark/Tests/RangeTests.swift index 35601a1a..dbc53df7 100644 --- a/Sources/FluentBenchmark/Tests/RangeTests.swift +++ b/Sources/FluentBenchmark/Tests/RangeTests.swift @@ -1,3 +1,6 @@ +import FluentKit +import XCTest + extension FluentBenchmarker { public func testRange() throws { try self.testRange_basic() diff --git a/Sources/FluentBenchmark/Tests/SQLTests.swift b/Sources/FluentBenchmark/Tests/SQLTests.swift index 8770d8a8..0bf07d93 100644 --- a/Sources/FluentBenchmark/Tests/SQLTests.swift +++ b/Sources/FluentBenchmark/Tests/SQLTests.swift @@ -1,4 +1,8 @@ -import FluentSQL +import FluentKit +import Foundation +import NIOCore +import XCTest +import SQLKit extension FluentBenchmarker { public func testSQL() throws { diff --git a/Sources/FluentBenchmark/Tests/SchemaTests.swift b/Sources/FluentBenchmark/Tests/SchemaTests.swift index 4c0146c0..f4db5152 100644 --- a/Sources/FluentBenchmark/Tests/SchemaTests.swift +++ b/Sources/FluentBenchmark/Tests/SchemaTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest +import SQLKit import FluentSQL extension FluentBenchmarker { @@ -10,6 +15,7 @@ extension FluentBenchmarker { if self.database is SQLDatabase { try self.testSchema_customSqlConstraints() try self.testSchema_customSqlFields() + try self.testSchema_deleteConstraints() } } @@ -72,7 +78,7 @@ extension FluentBenchmarker { XCTAssertThrowsError( try Star.query(on: self.database) .filter(\.$name == "Sun") - .delete().wait() + .delete(force: true).wait() ) } } @@ -98,7 +104,7 @@ extension FluentBenchmarker { if (self.database as! SQLDatabase).dialect.alterTableSyntax.allowsBatch { try self.database.schema("custom_constraints") // Test raw SQL for dropping constraints: - .deleteConstraint(.sql(embed: "\(SQLDropConstraint(name: SQLIdentifier("id_unq_1")))")) + .deleteConstraint(.sql(embed: "\(SQLDropTypedConstraint(name: SQLIdentifier("id_unq_1"), algorithm: .sql(raw: "")))")) .update().wait() } } @@ -136,6 +142,38 @@ extension FluentBenchmarker { } } } + + private func testSchema_deleteConstraints() throws { + try self.runTest(#function, [ + CreateCategories(), + DeleteTableMigration(name: "normal_constraints") + ]) { + try self.database.schema("normal_constraints") + .id() + + .field("catid", .uuid) + .foreignKey(["catid"], references: Category.schema, [.id], onDelete: .noAction, onUpdate: .noAction) + .foreignKey(["catid"], references: Category.schema, [.id], onDelete: .noAction, onUpdate: .noAction, name: "second_fkey") + .unique(on: "catid") + .unique(on: "id", name: "second_ukey") + + .create().wait() + + if (self.database as! SQLDatabase).dialect.alterTableSyntax.allowsBatch { + try self.database.schema("normal_constraints") + // Test `DROP FOREIGN KEY` (MySQL) or `DROP CONSTRAINT` (Postgres) + .deleteConstraint(.constraint(.foreignKey([.key("catid")], Category.schema, [.key(.id)], onDelete: .noAction, onUpdate: .noAction))) + // Test name-based `DROP FOREIGN KEY` (MySQL) + .deleteForeignKey(name: "second_fkey") + // Test `DROP KEY` (MySQL) or `DROP CONSTRAINT` (Postgres) + .deleteUnique(on: "catid") + // Test name-based `DROP KEY` (MySQL) or `DROP CONSTRAINT` (Postgres) + .deleteConstraint(name: "second_ukey") + + .update().wait() + } + } + } } final class Category: Model { diff --git a/Sources/FluentBenchmark/Tests/SetTests.swift b/Sources/FluentBenchmark/Tests/SetTests.swift index 2e9de609..f5095f83 100644 --- a/Sources/FluentBenchmark/Tests/SetTests.swift +++ b/Sources/FluentBenchmark/Tests/SetTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testSet() throws { try self.testSet_multiple() @@ -97,7 +102,9 @@ private struct Test2Migration: FluentKit.Migration { } func revert(on database: Database) -> EventLoopFuture { - database.schema("test").delete() + database.schema("test").delete().flatMap { + database.enum("foo").delete() + } } } diff --git a/Sources/FluentBenchmark/Tests/SiblingsTests.swift b/Sources/FluentBenchmark/Tests/SiblingsTests.swift index 5eb3cde3..97bf3ef1 100644 --- a/Sources/FluentBenchmark/Tests/SiblingsTests.swift +++ b/Sources/FluentBenchmark/Tests/SiblingsTests.swift @@ -1,3 +1,6 @@ +import XCTest +import FluentKit + extension FluentBenchmarker { public func testSiblings() throws { try self.testSiblings_attach() @@ -59,6 +62,23 @@ extension FluentBenchmarker { XCTAssertEqual(tags.count, 2) XCTAssertEqual(tags.map(\.name).sorted(), ["Inhabited", "Small Rocky"]) } + + try earth.$tags.detachAll(on: self.database).wait() + + // check pivot provided to the edit closure has the "to" model when attaching + try earth.$tags.attach([inhabited, smallRocky], on: self.database) { pivot in + guard pivot.$tag.value != nil else { + return XCTFail("planet tag pivot should have tag available during editing") + } + pivot.comments = "Tagged with name \(pivot.tag.name)" + }.wait() + + do { + let pivots = try earth.$tags.$pivots.get(reload: true, on: self.database).wait() + + XCTAssertEqual(pivots.count, 2) + XCTAssertEqual(pivots.compactMap(\.comments).sorted(), ["Tagged with name Inhabited", "Tagged with name Small Rocky"]) + } } } diff --git a/Sources/FluentBenchmark/Tests/SoftDeleteTests.swift b/Sources/FluentBenchmark/Tests/SoftDeleteTests.swift index 80e3dcdf..69ce93b6 100644 --- a/Sources/FluentBenchmark/Tests/SoftDeleteTests.swift +++ b/Sources/FluentBenchmark/Tests/SoftDeleteTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testSoftDelete() throws { try self.testSoftDelete_model() @@ -208,8 +213,16 @@ extension FluentBenchmarker { // this should throw an error now because one of the // parents is missing and the results cannot be loaded XCTAssertThrowsError(try Foo.query(on: self.database).with(\.$bar).all().wait()) { error in - XCTAssertEqual("\(error)", FluentError.missingParent.description) + guard case let .missingParent(from, to, key, id) = error as? FluentError else { + return XCTFail("Expected FluentError.missingParent, but got \(error)") + } + XCTAssertEqual(from, "\(Foo.self)") + XCTAssertEqual(to, "\(Bar.self)") + XCTAssertEqual(key, "bar") + XCTAssertEqual(id, "\(bar1.id!)") } + + XCTAssertNoThrow(try Foo.query(on: self.database).with(\.$bar, withDeleted: true).all().wait()) } } } diff --git a/Sources/FluentBenchmark/Tests/SortTests.swift b/Sources/FluentBenchmark/Tests/SortTests.swift index 9ad8414b..c9690942 100644 --- a/Sources/FluentBenchmark/Tests/SortTests.swift +++ b/Sources/FluentBenchmark/Tests/SortTests.swift @@ -1,4 +1,6 @@ import FluentSQL +import XCTest +import SQLKit extension FluentBenchmarker { public func testSort(sql: Bool = true) throws { diff --git a/Sources/FluentBenchmark/Tests/TimestampTests.swift b/Sources/FluentBenchmark/Tests/TimestampTests.swift index 6f3526fa..4e535474 100644 --- a/Sources/FluentBenchmark/Tests/TimestampTests.swift +++ b/Sources/FluentBenchmark/Tests/TimestampTests.swift @@ -1,4 +1,7 @@ -import class FluentKit.QueryBuilder +import FluentKit +import Foundation +import NIOCore +import XCTest extension FluentBenchmarker { public func testTimestamp() throws { diff --git a/Sources/FluentBenchmark/Tests/TransactionTests.swift b/Sources/FluentBenchmark/Tests/TransactionTests.swift index ff764f7f..7663dfa9 100644 --- a/Sources/FluentBenchmark/Tests/TransactionTests.swift +++ b/Sources/FluentBenchmark/Tests/TransactionTests.swift @@ -1,3 +1,7 @@ +import NIOCore +import XCTest +import FluentKit + extension FluentBenchmarker { public func testTransaction() throws { try self.testTransaction_basic() diff --git a/Sources/FluentBenchmark/Tests/UniqueTests.swift b/Sources/FluentBenchmark/Tests/UniqueTests.swift index fea04bab..069b8967 100644 --- a/Sources/FluentBenchmark/Tests/UniqueTests.swift +++ b/Sources/FluentBenchmark/Tests/UniqueTests.swift @@ -1,3 +1,8 @@ +import FluentKit +import Foundation +import NIOCore +import XCTest + extension FluentBenchmarker { public func testUnique() throws { try self.testUnique_fields() diff --git a/Sources/FluentKit/Concurrency/AsyncMigration.swift b/Sources/FluentKit/Concurrency/AsyncMigration.swift index 4cf16705..b7a152a4 100644 --- a/Sources/FluentKit/Concurrency/AsyncMigration.swift +++ b/Sources/FluentKit/Concurrency/AsyncMigration.swift @@ -1,13 +1,10 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public protocol AsyncMigration: Migration { func prepare(on database: Database) async throws func revert(on database: Database) async throws } -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension AsyncMigration { func prepare(on database: Database) -> EventLoopFuture { let promise = database.eventLoop.makePromise(of: Void.self) @@ -25,6 +22,3 @@ public extension AsyncMigration { return promise.futureResult } } - -#endif - diff --git a/Sources/FluentKit/Concurrency/AsyncModelMiddleware.swift b/Sources/FluentKit/Concurrency/AsyncModelMiddleware.swift index 7d374ec3..3f38ded1 100644 --- a/Sources/FluentKit/Concurrency/AsyncModelMiddleware.swift +++ b/Sources/FluentKit/Concurrency/AsyncModelMiddleware.swift @@ -1,6 +1,5 @@ -#if compiler(>=5.5) && canImport(_Concurrency) +import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public protocol AsyncModelMiddleware: AnyModelMiddleware { associatedtype Model: FluentKit.Model @@ -11,7 +10,6 @@ public protocol AsyncModelMiddleware: AnyModelMiddleware { func restore(model: Model, on db: Database, next: AnyAsyncModelResponder) async throws } -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) extension AsyncModelMiddleware { public func handle(_ event: ModelEvent, _ model: AnyModel, on db: Database, chainingTo next: AnyModelResponder) -> EventLoopFuture { let promise = db.eventLoop.makePromise(of: Void.self) @@ -61,5 +59,3 @@ extension AsyncModelMiddleware { try await next.restore(model, on: db) } } - -#endif diff --git a/Sources/FluentKit/Concurrency/Children+Concurrency.swift b/Sources/FluentKit/Concurrency/Children+Concurrency.swift index 012b18ba..8137fd6d 100644 --- a/Sources/FluentKit/Concurrency/Children+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/Children+Concurrency.swift @@ -1,9 +1,6 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension ChildrenProperty { - func load(on database: Database) async throws { try await self.load(on: database).get() } @@ -17,4 +14,8 @@ public extension ChildrenProperty { } } -#endif +public extension CompositeChildrenProperty { + func load(on database: Database) async throws { + try await self.load(on: database).get() + } +} diff --git a/Sources/FluentKit/Concurrency/Database+Concurrency.swift b/Sources/FluentKit/Concurrency/Database+Concurrency.swift index 0036e18e..15c3060f 100644 --- a/Sources/FluentKit/Concurrency/Database+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/Database+Concurrency.swift @@ -1,9 +1,7 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension Database { - func transaction(_ closure: @escaping (Database) async throws -> T) async throws -> T { + func transaction(_ closure: @Sendable @escaping (Database) async throws -> T) async throws -> T { try await self.transaction { db -> EventLoopFuture in let promise = self.eventLoop.makePromise(of: T.self) promise.completeWithTask{ try await closure(db) } @@ -11,7 +9,7 @@ public extension Database { }.get() } - func withConnection(_ closure: @escaping (Database) async throws -> T) async throws -> T { + func withConnection(_ closure: @Sendable @escaping (Database) async throws -> T) async throws -> T { try await self.withConnection { db -> EventLoopFuture in let promise = self.eventLoop.makePromise(of: T.self) promise.completeWithTask{ try await closure(db) } @@ -19,5 +17,3 @@ public extension Database { }.get() } } - -#endif diff --git a/Sources/FluentKit/Concurrency/EnumBuilder+Concurrency.swift b/Sources/FluentKit/Concurrency/EnumBuilder+Concurrency.swift index e1285dfd..d2b6f040 100644 --- a/Sources/FluentKit/Concurrency/EnumBuilder+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/EnumBuilder+Concurrency.swift @@ -1,7 +1,5 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension EnumBuilder { func create() async throws -> DatabaseSchema.DataType { try await self.create().get() @@ -19,5 +17,3 @@ public extension EnumBuilder { try await self.delete().get() } } - -#endif diff --git a/Sources/FluentKit/Concurrency/Model+Concurrency.swift b/Sources/FluentKit/Concurrency/Model+Concurrency.swift index 169649ad..012c2bc2 100644 --- a/Sources/FluentKit/Concurrency/Model+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/Model+Concurrency.swift @@ -1,7 +1,5 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension Model { static func find( _ id: Self.IDValue?, @@ -32,7 +30,6 @@ public extension Model { } } -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension Collection where Element: FluentKit.Model { func delete(force: Bool = false, on database: Database) async throws { try await self.delete(force: force, on: database).get() @@ -42,5 +39,3 @@ public extension Collection where Element: FluentKit.Model { try await self.create(on: database).get() } } - -#endif diff --git a/Sources/FluentKit/Concurrency/ModelResponder+Concurrency.swift b/Sources/FluentKit/Concurrency/ModelResponder+Concurrency.swift index 5b3a90f9..07a8b29d 100644 --- a/Sources/FluentKit/Concurrency/ModelResponder+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/ModelResponder+Concurrency.swift @@ -1,7 +1,5 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public protocol AnyAsyncModelResponder: AnyModelResponder { func handle( _ event: ModelEvent, @@ -10,7 +8,6 @@ public protocol AnyAsyncModelResponder: AnyModelResponder { ) async throws } -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) extension AnyAsyncModelResponder { func handle(_ event: ModelEvent, _ model: AnyModel, on db: Database) -> EventLoopFuture { let promise = db.eventLoop.makePromise(of: Void.self) @@ -21,7 +18,6 @@ extension AnyAsyncModelResponder { } } -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) extension AnyAsyncModelResponder { public func create(_ model: AnyModel, on db: Database) async throws { try await handle(.create, model, on: db) @@ -44,7 +40,6 @@ extension AnyAsyncModelResponder { } } -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) internal struct AsyncBasicModelResponder: AnyAsyncModelResponder { private let _handle: (ModelEvent, AnyModel, Database) async throws -> Void @@ -56,5 +51,3 @@ internal struct AsyncBasicModelResponder: AnyAsyncModelResponder { self._handle = handle } } - -#endif diff --git a/Sources/FluentKit/Concurrency/OptionalChild+Concurrency.swift b/Sources/FluentKit/Concurrency/OptionalChild+Concurrency.swift index 9963ba8d..c01088e4 100644 --- a/Sources/FluentKit/Concurrency/OptionalChild+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/OptionalChild+Concurrency.swift @@ -1,9 +1,6 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension OptionalChildProperty { - func load(on database: Database) async throws { try await self.load(on: database).get() } @@ -13,4 +10,8 @@ public extension OptionalChildProperty { } } -#endif +public extension CompositeOptionalChildProperty { + func load(on database: Database) async throws { + try await self.load(on: database).get() + } +} diff --git a/Sources/FluentKit/Concurrency/OptionalParent+Concurrency.swift b/Sources/FluentKit/Concurrency/OptionalParent+Concurrency.swift index 06c0dcff..1ee4296d 100644 --- a/Sources/FluentKit/Concurrency/OptionalParent+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/OptionalParent+Concurrency.swift @@ -1,12 +1,13 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension OptionalParentProperty { func load(on database: Database) async throws { try await self.load(on: database).get() } } -#endif - +public extension CompositeOptionalParentProperty { + func load(on database: Database) async throws { + try await self.load(on: database).get() + } +} diff --git a/Sources/FluentKit/Concurrency/Parent+Concurrency.swift b/Sources/FluentKit/Concurrency/Parent+Concurrency.swift index 37f408bf..f6b3b8e4 100644 --- a/Sources/FluentKit/Concurrency/Parent+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/Parent+Concurrency.swift @@ -1,12 +1,13 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension ParentProperty { func load(on database: Database) async throws { try await self.load(on: database).get() } } -#endif - +public extension CompositeParentProperty { + func load(on database: Database) async throws { + try await self.load(on: database).get() + } +} diff --git a/Sources/FluentKit/Concurrency/QueryBuilder+Concurrency.swift b/Sources/FluentKit/Concurrency/QueryBuilder+Concurrency.swift index c1091493..1f9d4697 100644 --- a/Sources/FluentKit/Concurrency/QueryBuilder+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/QueryBuilder+Concurrency.swift @@ -1,7 +1,5 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension QueryBuilder { // MARK: - Actions func create() async throws { @@ -27,9 +25,7 @@ public extension QueryBuilder { } func all(_ key: KeyPath) async throws -> [Field.Value] - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model { try await self.all(key).get() } @@ -38,14 +34,11 @@ public extension QueryBuilder { _ joined: Joined.Type, _ field: KeyPath ) async throws -> [Field.Value] - where - Joined: Schema, - Field: QueryableProperty, - Field.Model == Joined + where Joined: Schema, Field: QueryableProperty, Field.Model == Joined { try await self.all(joined, field).get() } - + func all() async throws -> [Model] { try await self.all().get() } @@ -68,101 +61,139 @@ public extension QueryBuilder { } func count(_ key: KeyPath) async throws -> Int - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model + { + try await self.count(key).get() + } + + func count(_ key: KeyPath) async throws -> Int + where Field: QueryableProperty, Field.Model == Model.IDValue { try await self.count(key).get() } + + func sum(_ key: KeyPath) async throws -> Field.Value? + where Field: QueryableProperty, Field.Model == Model + { + try await self.sum(key).get() + } func sum(_ key: KeyPath) async throws -> Field.Value? - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model.IDValue + { + try await self.sum(key).get() + } + + func sum(_ key: KeyPath) async throws -> Field.Value + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model { try await self.sum(key).get() } func sum(_ key: KeyPath) async throws -> Field.Value - where - Field: QueryableProperty, - Field.Value: OptionalType, - Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue { try await self.sum(key).get() } func average(_ key: KeyPath) async throws -> Field.Value? - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model + { + try await self.average(key).get() + } + + func average(_ key: KeyPath) async throws -> Field.Value? + where Field: QueryableProperty, Field.Model == Model.IDValue { try await self.average(key).get() } func average(_ key: KeyPath) async throws -> Field.Value - where - Field: QueryableProperty, - Field.Value: OptionalType, - Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model + { + try await self.average(key).get() + } + + func average(_ key: KeyPath) async throws -> Field.Value + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue { try await self.average(key).get() } func min(_ key: KeyPath) async throws -> Field.Value? - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model + { + try await self.min(key).get() + } + + func min(_ key: KeyPath) async throws -> Field.Value? + where Field: QueryableProperty, Field.Model == Model.IDValue + { + try await self.min(key).get() + } + + func min(_ key: KeyPath) async throws -> Field.Value + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model { try await self.min(key).get() } func min(_ key: KeyPath) async throws -> Field.Value - where - Field: QueryableProperty, - Field.Value: OptionalType, - Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue { try await self.min(key).get() } func max(_ key: KeyPath) async throws -> Field.Value? - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model + { + try await self.max(key).get() + } + + func max(_ key: KeyPath) async throws -> Field.Value? + where Field: QueryableProperty, Field.Model == Model.IDValue { try await self.max(key).get() } func max(_ key: KeyPath) async throws -> Field.Value - where - Field: QueryableProperty, - Field.Value: OptionalType, - Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model { try await self.max(key).get() } + func max(_ key: KeyPath) async throws -> Field.Value + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue + { + try await self.max(key).get() + } + func aggregate( _ method: DatabaseQuery.Aggregate.Method, _ field: KeyPath, as type: Result.Type = Result.self ) async throws -> Result - where - Field: QueryableProperty, - Field.Model == Model, - Result: Codable + where Field: QueryableProperty, Field.Model == Model, Result: Codable { try await self.aggregate(method, field, as: type).get() } + func aggregate( + _ method: DatabaseQuery.Aggregate.Method, + _ field: KeyPath, + as type: Result.Type = Result.self + ) async throws -> Result + where Field: QueryableProperty, Field.Model == Model.IDValue, Result: Codable + { + try await self.aggregate(method, field, as: type).get() + } func aggregate( _ method: DatabaseQuery.Aggregate.Method, _ field: FieldKey, as type: Result.Type = Result.self ) async throws -> Result - where Result: Codable + where Result: Codable { try await self.aggregate(method, field, as: type).get() } @@ -172,7 +203,7 @@ public extension QueryBuilder { _ path: [FieldKey], as type: Result.Type = Result.self ) async throws -> Result - where Result: Codable + where Result: Codable { try await self.aggregate(method, path, as: type).get() } @@ -183,6 +214,11 @@ public extension QueryBuilder { ) async throws -> Page { try await self.paginate(request).get() } + + func page( + withIndex page: Int, + size per: Int + ) async throws -> Page { + try await self.page(withIndex: page, size: per).get() + } } - -#endif diff --git a/Sources/FluentKit/Concurrency/Relation+Concurrency.swift b/Sources/FluentKit/Concurrency/Relation+Concurrency.swift index c3726fef..0e1406f5 100644 --- a/Sources/FluentKit/Concurrency/Relation+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/Relation+Concurrency.swift @@ -1,11 +1,7 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension Relation { func get(reload: Bool = false, on database: Database) async throws -> RelatedValue { try await self.get(reload: reload, on: database).get() } } - -#endif diff --git a/Sources/FluentKit/Concurrency/SchemaBuilder+Concurrency.swift b/Sources/FluentKit/Concurrency/SchemaBuilder+Concurrency.swift index 6262b240..0ab0e7b0 100644 --- a/Sources/FluentKit/Concurrency/SchemaBuilder+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/SchemaBuilder+Concurrency.swift @@ -1,7 +1,5 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension SchemaBuilder { func create() async throws { try await self.create().get() @@ -15,5 +13,3 @@ public extension SchemaBuilder { try await self.delete().get() } } - -#endif diff --git a/Sources/FluentKit/Concurrency/Siblings+Concurrency.swift b/Sources/FluentKit/Concurrency/Siblings+Concurrency.swift index c3ec6295..01c3db87 100644 --- a/Sources/FluentKit/Concurrency/Siblings+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/Siblings+Concurrency.swift @@ -1,58 +1,105 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) public extension SiblingsProperty { - func load(on database: Database) async throws { + func load(on database: any Database) async throws { try await self.load(on: database).get() } // MARK: Checking state - func isAttached(to: To, on database: Database) async throws -> Bool { + func isAttached(to: To, on database: any Database) async throws -> Bool { try await self.isAttached(to: to, on: database).get() } - func isAttached(toID: To.IDValue, on database: Database) async throws -> Bool { + func isAttached(toID: To.IDValue, on database: any Database) async throws -> Bool { try await self.isAttached(toID: toID, on: database).get() } // MARK: Operations - func attach( - _ tos: [To], - on database: Database, - _ edit: (Through) -> () = { _ in } - ) async throws { + /// Attach multiple models with plain edit closure. + func attach(_ tos: [To], on database: any Database, _ edit: (Through) -> () = { _ in }) async throws { try await self.attach(tos, on: database, edit).get() } + + /// Attach single model with plain edit closure. + func attach(_ to: To, on database: any Database, _ edit: @escaping (Through) -> () = { _ in }) async throws { + try await self.attach(to, method: .always, on: database, edit) + } + /// Attach single model by specific method with plain edit closure. func attach( - _ to: To, - method: AttachMethod, - on database: Database, + _ to: To, method: AttachMethod, on database: any Database, _ edit: @escaping (Through) -> () = { _ in } ) async throws { try await self.attach(to, method: method, on: database, edit).get() } + /// A version of ``attach(_:on:_:)-791gu`` whose edit closure is async and can throw. + /// + /// This method provides "all or none" semantics- if the edit closure throws an error, any already- + /// processed pivots are discarded. Only if all pivots are successfully edited are any of them saved. + /// + /// These semantics require us to reimplement, rather than calling through to, the ELF version. func attach( - _ to: To, - on database: Database, - _ edit: (Through) -> () = { _ in } + _ tos: [To], + on database: any Database, + _ edit: @Sendable @escaping (Through) async throws -> () ) async throws { - try await self.attach(to, on: database, edit).get() + guard let fromID = self.idValue else { + throw SiblingsPropertyError.owningModelIdRequired(property: self.name) + } + + var pivots: [Through] = [] + pivots.reserveCapacity(tos.count) + + for to in tos { + guard let toID = to.id else { + throw SiblingsPropertyError.operandModelIdRequired(property: self.name) + } + let pivot = Through() + pivot[keyPath: self.from].id = fromID + pivot[keyPath: self.to].id = toID + pivot[keyPath: self.to].value = to + try await edit(pivot) + pivots.append(pivot) + } + try await pivots.create(on: database) + } + + /// A version of ``attach(_:on:_:)-791gu`` whose edit closure is async and can throw. + /// + /// These semantics require us to reimplement, rather than calling through to, the ELF version. + func attach(_ to: To, on database: any Database, _ edit: @Sendable @escaping (Through) async throws -> ()) async throws { + try await self.attach(to, method: .always, on: database, edit) } + /// A version of ``attach(_:method:on:_:)-20vs`` whose edit closure is async and can throw. + /// + /// These semantics require us to reimplement, rather than calling through to, the ELF version. + func attach( + _ to: To, method: AttachMethod, on database: any Database, + _ edit: @Sendable @escaping (Through) async throws -> () + ) async throws { + switch method { + case .ifNotExists: + guard try await !self.isAttached(to: to, on: database) else { return } + fallthrough + case .always: + try await self.attach([to], on: database, edit) + } + } - func detach(_ tos: [To], on database: Database) async throws { + func detach(_ tos: [To], on database: any Database) async throws { try await self.detach(tos, on: database).get() } - func detach(_ to: To, on database: Database) async throws { + func detach(_ to: To, on database: any Database) async throws { try await self.detach(to, on: database).get() } + + func detachAll(on database: any Database) async throws { + try await self.detachAll(on: database).get() + } } - -#endif diff --git a/Sources/FluentKit/Database/Database+Logging.swift b/Sources/FluentKit/Database/Database+Logging.swift index 5430c9e6..54e5fd07 100644 --- a/Sources/FluentKit/Database/Database+Logging.swift +++ b/Sources/FluentKit/Database/Database+Logging.swift @@ -1,16 +1,20 @@ +import NIOCore +import Logging +import SQLKit + extension Database { - public func logging(to logger: Logger) -> Database { + public func logging(to logger: Logger) -> any Database { LoggingOverrideDatabase(database: self, logger: logger) } } -private struct LoggingOverrideDatabase { - let database: Database +private struct LoggingOverrideDatabase { + let database: D let logger: Logger } extension LoggingOverrideDatabase: Database { - var context: DatabaseContext { + var context: DatabaseContext { .init( configuration: self.database.context.configuration, logger: self.logger, @@ -51,3 +55,11 @@ extension LoggingOverrideDatabase: Database { self.database.withConnection(closure) } } + +extension LoggingOverrideDatabase: SQLDatabase where D: SQLDatabase { + func execute(sql query: SQLExpression, _ onRow: @escaping (SQLRow) -> ()) -> EventLoopFuture { + self.database.execute(sql: query, onRow) + } + var dialect: SQLDialect { self.database.dialect } + var version: (any SQLDatabaseReportedVersion)? { self.database.version } +} diff --git a/Sources/FluentKit/Database/Database.swift b/Sources/FluentKit/Database/Database.swift index 323920d5..f9329bd1 100644 --- a/Sources/FluentKit/Database/Database.swift +++ b/Sources/FluentKit/Database/Database.swift @@ -1,3 +1,6 @@ +import NIOCore +import Logging + public protocol Database { var context: DatabaseContext { get } diff --git a/Sources/FluentKit/Database/DatabaseID.swift b/Sources/FluentKit/Database/DatabaseID.swift index 1801c97a..3d73fe1a 100644 --- a/Sources/FluentKit/Database/DatabaseID.swift +++ b/Sources/FluentKit/Database/DatabaseID.swift @@ -1,4 +1,4 @@ -public struct DatabaseID: Hashable, Codable { +public struct DatabaseID: Hashable, Codable, Sendable { public let string: String public init(string: String) { self.string = string diff --git a/Sources/FluentKit/Database/DatabaseInput.swift b/Sources/FluentKit/Database/DatabaseInput.swift index 61781c8b..6e36304f 100644 --- a/Sources/FluentKit/Database/DatabaseInput.swift +++ b/Sources/FluentKit/Database/DatabaseInput.swift @@ -1,25 +1,166 @@ +/// A helper type for working with properties which conform to the ``AnyDatabaseProperty`` protocol. +/// +/// All types conforming to either ``Fields`` or ``AnyDatabaseProperty`` provide an `input(to:)` method +/// (see ``Fields/input(to:)-3g6gt`` and ``AnyDatabaseProperty/input(to:)``). This method in turn calls +/// the ``DatabaseInput/set(_:at:)`` method of the provided ``DatabaseInput`` once for each ``FieldKey`` +/// the implementing type is responsible for, providing both the key and the ``DatabaseQuery/Value`` +/// associated with that key. +/// +/// As the protocol name suggests, the primary purpose of ``DatabaseInput`` is to allow a complete set of +/// data, in the form of a key-value map, to be generically gathered for input "into" a database. However, +/// to allow useful semantics such as composition (such as transparently remapping field keys) and alternate +/// data handling (such as saving existing state so it can be temporarily overwritten), this mechanism is +/// expressed as a protocol rather than just handing around a dictionary or other similar structure. +/// +/// > TODO: Define a new protocol formalizing the `input(to:)` and `output(from:)` methods found on both +/// ``AnyDatabaseProperty`` and ``Fields``, and have them conform to it rather than independently +/// providing identical requirements. This will allow inputtable and outputtable (corresponding to +/// encodable and decodable) types to be generically addressed cleanly. +/// +/// > See Also: ``FluentKit/DatabaseOutput`` public protocol DatabaseInput { + /// Called by individual database properties to register a given field key and associated database + /// value as part of the data set represented by the ``DatabaseInput``. + /// + /// Implemented by conforming types to handle key/value pairs provided by callers. + /// + /// Setting a value for a key which has already been registered is expected to overwrite the old + /// value with the new. Conforming types _can_ choose alternative semantics, but must take care + /// that doing so is compatible with the expectations of callers. + /// + /// > Note: As a rule, a key being set multiple times for a single input usually indicates or at + /// least implies buggy behavior (such as a Model which specifies a particular key in more than + /// one of its properties). However, there are cases where doing so is useful; as such, no + /// attempt is made to diagnose multiple sets for the same key and the API must permit said + /// behavior unless the semantics of the conforming type explicitly require otherwise and + /// the alternate behavior is clearly documented. func set(_ value: DatabaseQuery.Value, at key: FieldKey) + + /// Indicates whether this ``DatabaseInput`` instance is requesting key/value pairs for _all_ defined + /// database fields regardless of status, or only those pairs where the current value + /// is known to be out of date (also referred to variously as "dirty", "modified", or "has changes"). + /// + /// By default, only changed values are requested. This choice was made because this property was + /// added long after the first release of the protocol, before which time unmodified properties were + /// always unconditionally omitted; as such, in order to remain fully source-compatible with existing + /// conforming types, there must be a default which is chosen so as to preserve existing behavior. + /// + /// For the purposes of this flag, when the value is `true`, both unmodified _and unset_ properties + /// should be included. The value of unset properties should be ``DatabaseQuery/Value/default``. + /// + /// > Important: The value of this property _MUST NOT_ change during the instance's lifetime. It is + /// generally recommended - though not required - that it be a constant value. This is the case for + /// all ``DatabaseInput`` types in FluentKit at the time of this writing. It has been left as an + /// instance property rather than being declared `static` to avoid artificially limiting the + /// flexibility of conforming types. + /// + /// > Warning: While all of FluentKit's built-in property wrapper types correctly honor this flag, if + /// there are any custom property types in use which do not defer to a builtin type as a backing + /// store (as ``IDProperty`` does, for example), that type's ``AnyDatabaseProperty`` conformance must + /// be updated accordingly. + var wantsUnmodifiedKeys: Bool { get } } extension DatabaseInput { + /// Default implementation of ``wantsUnmodifiedKeys-4tisb``. Always assume the old behavior (modified + /// data only) unless explcitly told otherwise. + public var wantsUnmodifiedKeys: Bool { + false + } +} + +extension DatabaseInput { + /// Return a ``DatabaseInput`` wrapping `self` so as to apply a given prefix to each field key + /// before processing. public func prefixed(by prefix: FieldKey) -> DatabaseInput { - PrefixedDatabaseInput(prefix: prefix, base: self) + PrefixedDatabaseInput(prefix: prefix, strategy: .none, base: self) + } + + /// Return a ``DatabaseInput`` wrapping `self` so as to apply a given prefix, according to a given + /// ``KeyPrefixingStrategy``, to each field key before processing. + public func prefixed(by prefix: FieldKey, using stratgey: KeyPrefixingStrategy) -> DatabaseInput { + PrefixedDatabaseInput(prefix: prefix, strategy: stratgey, base: self) } } -private struct PrefixedDatabaseInput: DatabaseInput { +/// A ``DatabaseInput`` which applies a key prefix according to a ``KeyPrefixingStrategy`` to each key +/// sent to it before passing the resulting key and the unmodified value on to another ``DatabaseInput``. +private struct PrefixedDatabaseInput: DatabaseInput { let prefix: FieldKey - let base: DatabaseInput + let strategy: KeyPrefixingStrategy + let base: Base + + var wantsUnmodifiedKeys: Bool { self.base.wantsUnmodifiedKeys } + + func set(_ value: DatabaseQuery.Value, at key: FieldKey) { + self.base.set(value, at: self.strategy.apply(prefix: self.prefix, to: key)) + } +} + +/// A ``DatabaseInput`` which generates a ``DatabaseQuery/Filter`` based on each key-value pair sent to it, +/// using ``DatabaseQuery/Filter/Method/equal``, and adds each such filter to a ``QueryBuilder``. +/// +/// All fields directed to the input are assumed to belong to the entity referenced by `InputModel`, which +/// need not be the same as `BuilderModel` (the base model of the query builder). This permits filtering +/// to be applied based on a joined model, and enables support for ``ModelAlias``. +/// +/// If ``QueryFilterInput/inverted`` is `true`, the added filters will use the ``DatabaseQuery/Filter/Method/notEqual`` +/// method instead. +/// +/// The ``DatabaseInput/wantsUnmodifiedKeys-1qajw`` flag is enabled for this input type. +/// +/// The query builder is modified in-place. Callers may either retain their own reference to the builder or +/// retrieve it from this structure when ready. It is the caller's responsibility to ensure that grouping of +/// multiple filters is handled appropriately for their use case - most commonly by using the builder passed +/// to a ``QueryBuilder/group(_:_:)`` closure to create an instance of this type. +/// +/// > Tip: Applying a query filter via database input is especially useful as a means of providing generic +/// support for filters involving a ``CompositeIDProperty``. For example, using an instance of this type +/// as the input for a ``CompositeParentProperty`` filters the query according to the set of appropriately +/// prefixed field keys the property encapsulates. +internal struct QueryFilterInput: DatabaseInput { + let builder: QueryBuilder + let inverted: Bool + + var wantsUnmodifiedKeys: Bool { true } + + init(builder: QueryBuilder, inverted: Bool = false) where BuilderModel == InputModel { + self.init(BuilderModel.self, builder: builder, inverted: inverted) + } + + init(_: InputModel.Type, builder: QueryBuilder, inverted: Bool = false) { + self.builder = builder + self.inverted = inverted + } func set(_ value: DatabaseQuery.Value, at key: FieldKey) { - self.base.set(value, at: .prefix(self.prefix, key)) + self.builder.filter( + .extendedPath([key], schema: InputModel.schemaOrAlias, space: InputModel.spaceIfNotAliased), + self.inverted ? .notEqual : .equal, + value + ) } } -//public struct DatabaseInput { -// public var values: [FieldKey: DatabaseQuery.Value] -// public init() { -// self.values = [:] -// } -//} +/// A ``DatabaseInput`` which passes all keys through to another ``DatabaseInput`` with +/// ``DatabaseQuery/Value/null`` as the value, ignoring any value provided. +/// +/// The ``DatabaseInput/wantsUnmodifiedKeys-1qajw`` flag is always set regardless of what the +/// "base" input requested, as the use case for this input is to easily specify an explicitly +/// nil composite relation. +internal struct NullValueOverrideInput: DatabaseInput { + let base: Base + var wantsUnmodifiedKeys: Bool { true } + + func set(_: DatabaseQuery.Value, at key: FieldKey) { + self.base.set(.null, at: key) + } +} + +extension DatabaseInput { + /// Returns `self` wrapped with a ``NullValueOverrideInput``. This is here primarily so the actual + /// implementation be defined generically rather than using existentials. + internal func nullValueOveridden() -> DatabaseInput { + NullValueOverrideInput(base: self) + } +} diff --git a/Sources/FluentKit/Database/DatabaseOutput+Cascade.swift b/Sources/FluentKit/Database/DatabaseOutput+Cascade.swift deleted file mode 100644 index 3739b4f3..00000000 --- a/Sources/FluentKit/Database/DatabaseOutput+Cascade.swift +++ /dev/null @@ -1,47 +0,0 @@ -extension DatabaseOutput { - public func cascading(to output: DatabaseOutput) -> DatabaseOutput { - return CombinedOutput(first: self, second: output) - } -} - -private struct CombinedOutput: DatabaseOutput { - var first: DatabaseOutput - var second: DatabaseOutput - - func schema(_ schema: String) -> DatabaseOutput { - CombinedOutput( - first: self.first.schema(schema), - second: self.second.schema(schema) - ) - } - - func contains(_ key: FieldKey) -> Bool { - self.first.contains(key) || self.second.contains(key) - } - - func decodeNil(_ key: FieldKey) throws -> Bool { - if self.first.contains(key) { - return try self.first.decodeNil(key) - } else if self.second.contains(key) { - return try self.second.decodeNil(key) - } else { - throw FluentError.missingField(name: key.description) - } - } - - func decode(_ key: FieldKey, as type: T.Type) throws -> T - where T: Decodable - { - if self.first.contains(key) { - return try self.first.decode(key) - } else if self.second.contains(key) { - return try self.second.decode(key) - } else { - throw FluentError.missingField(name: key.description) - } - } - - var description: String { - return self.first.description + " -> " + self.second.description - } -} diff --git a/Sources/FluentKit/Database/DatabaseOutput+Prefix.swift b/Sources/FluentKit/Database/DatabaseOutput+Prefix.swift deleted file mode 100644 index dcb86dc3..00000000 --- a/Sources/FluentKit/Database/DatabaseOutput+Prefix.swift +++ /dev/null @@ -1,36 +0,0 @@ -extension DatabaseOutput { - public func prefixed(by prefix: FieldKey) -> DatabaseOutput { - PrefixedOutput(prefix: prefix, base: self) - } -} - -private struct PrefixedOutput: DatabaseOutput { - let prefix: FieldKey - let base: DatabaseOutput - - func schema(_ schema: String) -> DatabaseOutput { - PrefixedOutput(prefix: self.prefix, base: self.base.schema(schema)) - } - - func contains(_ key: FieldKey) -> Bool { - return self.base.contains(self.key(key)) - } - - func decodeNil(_ key: FieldKey) throws -> Bool { - try self.base.decodeNil(self.key(key)) - } - - func decode(_ key: FieldKey, as type: T.Type) throws -> T - where T: Decodable - { - try self.base.decode(self.key(key)) - } - - func key(_ key: FieldKey) -> FieldKey { - .prefix(self.prefix, key) - } - - var description: String { - self.base.description - } -} diff --git a/Sources/FluentKit/Database/DatabaseOutput.swift b/Sources/FluentKit/Database/DatabaseOutput.swift index 5e40bbe3..b441be55 100644 --- a/Sources/FluentKit/Database/DatabaseOutput.swift +++ b/Sources/FluentKit/Database/DatabaseOutput.swift @@ -12,4 +12,72 @@ extension DatabaseOutput { { try self.decode(key, as: T.self) } + + public func qualifiedSchema(space: String?, _ schema: String) -> DatabaseOutput { + self.schema([space, schema].compactMap({ $0 }).joined(separator: "_")) + } +} + +extension DatabaseOutput { + public func prefixed(by prefix: FieldKey) -> DatabaseOutput { + PrefixedDatabaseOutput(prefix: prefix, strategy: .none, base: self) + } + + public func prefixed(by prefix: FieldKey, using stratgey: KeyPrefixingStrategy) -> DatabaseOutput { + PrefixedDatabaseOutput(prefix: prefix, strategy: stratgey, base: self) + } + + public func cascading(to output: DatabaseOutput) -> DatabaseOutput { + return CombinedOutput(first: self, second: output) + } } + +private struct CombinedOutput: DatabaseOutput { + let first: DatabaseOutput, second: DatabaseOutput + + func schema(_ schema: String) -> DatabaseOutput { + CombinedOutput(first: self.first.schema(schema), second: self.second.schema(schema)) + } + + func contains(_ key: FieldKey) -> Bool { + self.first.contains(key) || self.second.contains(key) + } + + func decodeNil(_ key: FieldKey) throws -> Bool { + try self.first.contains(key) ? self.first.decodeNil(key) : self.second.decodeNil(key) + } + + func decode(_ key: FieldKey, as type: T.Type) throws -> T where T: Decodable { + try self.first.contains(key) ? self.first.decode(key, as: T.self) : self.second.decode(key, as: T.self) + } + + var description: String { + self.first.description + " -> " + self.second.description + } +} + +private struct PrefixedDatabaseOutput: DatabaseOutput { + let prefix: FieldKey, strategy: KeyPrefixingStrategy + let base: DatabaseOutput + + func schema(_ schema: String) -> DatabaseOutput { + PrefixedDatabaseOutput(prefix: self.prefix, strategy: self.strategy, base: self.base.schema(schema)) + } + + func contains(_ key: FieldKey) -> Bool { + self.base.contains(self.strategy.apply(prefix: self.prefix, to: key)) + } + + func decodeNil(_ key: FieldKey) throws -> Bool { + try self.base.decodeNil(self.strategy.apply(prefix: self.prefix, to: key)) + } + + func decode(_ key: FieldKey, as type: T.Type) throws -> T where T : Decodable { + try self.base.decode(self.strategy.apply(prefix: self.prefix, to: key), as: T.self) + } + + var description: String { + "Prefix(\(self.prefix) by \(self.strategy), of: \(self.base.description))" + } +} + diff --git a/Sources/FluentKit/Database/Databases.swift b/Sources/FluentKit/Database/Databases.swift index 7f814714..3ca8d808 100644 --- a/Sources/FluentKit/Database/Databases.swift +++ b/Sources/FluentKit/Database/Databases.swift @@ -1,6 +1,8 @@ import Foundation -import class NIOConcurrencyHelpers.Lock -@_exported import class NIO.NIOThreadPool +import struct NIOConcurrencyHelpers.NIOLock +import NIOCore +import NIOPosix +import Logging public struct DatabaseConfigurationFactory { public let make: () -> DatabaseConfiguration @@ -22,8 +24,8 @@ public final class Databases { private var drivers: [DatabaseID: DatabaseDriver] // Synchronize access across threads. - private var lock: Lock - + private var lock: NIOLock + public struct Middleware { let databases: Databases @@ -31,21 +33,21 @@ public final class Databases { _ middleware: AnyModelMiddleware, on id: DatabaseID? = nil ) { - self.databases.lock.lock() - defer { self.databases.lock.unlock() } - let id = id ?? self.databases._requireDefaultID() - var configuration = self.databases._requireConfiguration(for: id) - configuration.middleware.append(middleware) - self.databases.configurations[id] = configuration + self.databases.lock.withLockVoid { + let id = id ?? self.databases._requireDefaultID() + var configuration = self.databases._requireConfiguration(for: id) + configuration.middleware.append(middleware) + self.databases.configurations[id] = configuration + } } public func clear(on id: DatabaseID? = nil) { - self.databases.lock.lock() - defer { self.databases.lock.unlock() } - let id = id ?? self.databases._requireDefaultID() - var configuration = self.databases._requireConfiguration(for: id) - configuration.middleware.removeAll() - self.databases.configurations[id] = configuration + self.databases.lock.withLockVoid { + let id = id ?? self.databases._requireDefaultID() + var configuration = self.databases._requireConfiguration(for: id) + configuration.middleware.removeAll() + self.databases.configurations[id] = configuration + } } } @@ -74,24 +76,24 @@ public final class Databases { as id: DatabaseID, isDefault: Bool? = nil ) { - self.lock.lock() - defer { self.lock.unlock() } - self.configurations[id] = driver - if isDefault == true || (self.defaultID == nil && isDefault != false) { - self.defaultID = id + self.lock.withLockVoid { + self.configurations[id] = driver + if isDefault == true || (self.defaultID == nil && isDefault != false) { + self.defaultID = id + } } } public func `default`(to id: DatabaseID) { - self.lock.lock() - defer { self.lock.unlock() } - self.defaultID = id + self.lock.withLockVoid { + self.defaultID = id + } } public func configuration(for id: DatabaseID? = nil) -> DatabaseConfiguration? { - self.lock.lock() - defer { self.lock.unlock() } - return self.configurations[id ?? self._requireDefaultID()] + self.lock.withLock { + self.configurations[id ?? self._requireDefaultID()] + } } public func database( @@ -101,51 +103,51 @@ public final class Databases { history: QueryHistory? = nil, pageSizeLimit: Int? = nil ) -> Database? { - self.lock.lock() - defer { self.lock.unlock() } - let id = id ?? self._requireDefaultID() - var logger = logger - logger[metadataKey: "database-id"] = .string(id.string) - let configuration = self._requireConfiguration(for: id) - let context = DatabaseContext( - configuration: configuration, - logger: logger, - eventLoop: eventLoop, - history: history, - pageSizeLimit: pageSizeLimit - ) - let driver: DatabaseDriver - if let existing = self.drivers[id] { - driver = existing - } else { - let new = configuration.makeDriver(for: self) - self.drivers[id] = new - driver = new + self.lock.withLock { + let id = id ?? self._requireDefaultID() + var logger = logger + logger[metadataKey: "database-id"] = .string(id.string) + let configuration = self._requireConfiguration(for: id) + let context = DatabaseContext( + configuration: configuration, + logger: logger, + eventLoop: eventLoop, + history: history, + pageSizeLimit: pageSizeLimit + ) + let driver: DatabaseDriver + if let existing = self.drivers[id] { + driver = existing + } else { + let new = configuration.makeDriver(for: self) + self.drivers[id] = new + driver = new + } + return driver.makeDatabase(with: context) } - return driver.makeDatabase(with: context) } public func reinitialize(_ id: DatabaseID? = nil) { - self.lock.lock() - defer { self.lock.unlock() } - let id = id ?? self._requireDefaultID() - if let driver = self.drivers[id] { - self.drivers[id] = nil - driver.shutdown() + self.lock.withLockVoid { + let id = id ?? self._requireDefaultID() + if let driver = self.drivers[id] { + self.drivers[id] = nil + driver.shutdown() + } } } public func ids() -> Set { - return self.lock.withLock { Set(self.configurations.keys) } + self.lock.withLock { Set(self.configurations.keys) } } public func shutdown() { - self.lock.lock() - defer { self.lock.unlock() } - for driver in self.drivers.values { - driver.shutdown() + self.lock.withLockVoid { + for driver in self.drivers.values { + driver.shutdown() + } + self.drivers = [:] } - self.drivers = [:] } private func _requireConfiguration(for id: DatabaseID) -> DatabaseConfiguration { diff --git a/Sources/FluentKit/Database/KeyPrefixingStrategy.swift b/Sources/FluentKit/Database/KeyPrefixingStrategy.swift new file mode 100644 index 00000000..52fd3d68 --- /dev/null +++ b/Sources/FluentKit/Database/KeyPrefixingStrategy.swift @@ -0,0 +1,65 @@ +/// A strategy describing how to apply a prefix to a ``FieldKey``. +public enum KeyPrefixingStrategy: CustomStringConvertible { + /// The "do nothing" strategy - the prefix is applied to each key by simple concatenation. + case none + + /// Each key has its first character capitalized and the prefix is applied to the result. + case camelCase + + /// An underscore is placed between the prefix and each key. + case snakeCase + + /// A custom strategy - for each key, the closure is called with that key and the prefix with which the + /// wrapper was initialized, and must return the field key to actually use. The closure must be "pure" + /// (i.e. for any given pair of inputs it must always return the same result, in the same way that hash + /// values must be consistent within a single execution context). + case custom((_ prefix: FieldKey, _ idFieldKey: FieldKey) -> FieldKey) + + // See `CustomStringConvertible.description`. + public var description: String { + switch self { + case .none: + return ".useDefaultKeys" + case .camelCase: + return ".camelCase" + case .snakeCase: + return ".snakeCase" + case .custom(_): + return ".custom(...)" + } + } + + /// Apply this prefixing strategy and the given prefix to the given key, and return the result. + public func apply(prefix: FieldKey, to key: FieldKey) -> FieldKey { + switch self { + case .none: + return .prefix(prefix, key) + + // This strategy converts `.id` and `.aggregate` keys (but not prefixes) into generic `.string()`s. + case .camelCase: + switch key { + case .id, .aggregate, .string(_): + return .prefix(prefix, .string(key.description.withUppercasedFirstCharacter())) + + case .prefix(let originalPrefix, let originalSuffix): + return .prefix(self.apply(prefix: prefix, to: originalPrefix), originalSuffix) + } + + case .snakeCase: + return .prefix(.prefix(prefix, .string("_")), key) + + case .custom(let closure): + return closure(prefix, key) + } + } +} + +fileprivate extension String { + func withUppercasedFirstCharacter() -> String { + guard !self.isEmpty else { return self } + + var result = self + result.replaceSubrange(result.startIndex ... result.startIndex, with: result[result.startIndex].uppercased()) + return result + } +} diff --git a/Sources/FluentKit/Database/TransactionControlDatabase.swift b/Sources/FluentKit/Database/TransactionControlDatabase.swift new file mode 100644 index 00000000..ff6cee1c --- /dev/null +++ b/Sources/FluentKit/Database/TransactionControlDatabase.swift @@ -0,0 +1,27 @@ +import NIOCore + +/// Protocol for describing a database that allows fine-grained control over transcactions +/// when you need more control than provided by ``Database/transaction(_:)-1x3ds`` +/// +/// ⚠️ **WARNING**: it is the developer's responsiblity to get hold of a ``Database``, +/// execute the transaction functions on that connection, and ensure that the functions aren't called across +/// different conenctions. You are also responsible for ensuring that you commit or rollback queries +/// when you're ready. +/// +/// Do not mix these functions and `Database.transaction(_:)`. +public protocol TransactionControlDatabase: Database { + /// Start the transaction on the current connection. This is equivalent to an SQL `BEGIN` + /// - Returns: future `Void` when the transaction has been started + func beginTransaction() -> EventLoopFuture + + /// Commit the queries executed for the transaction and write them to the database + /// This is equivalent to an SQL `COMMIT` + /// - Returns: future `Void` when the transaction has been committed + func commitTransaction() -> EventLoopFuture + + /// Rollback the current transaction's queries. You may want to trigger this when handling an error + /// when trying to create models. + /// This is equivalent to an SQL `ROLLBACK` + /// - Returns: future `Void` when the transaction has been rollbacked + func rollbackTransaction() -> EventLoopFuture +} diff --git a/Sources/FluentKit/Docs.docc/index.md b/Sources/FluentKit/Docs.docc/index.md new file mode 100644 index 00000000..ec8f1015 --- /dev/null +++ b/Sources/FluentKit/Docs.docc/index.md @@ -0,0 +1,15 @@ +# ``FluentKit`` + +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. + +An example query looks like: + +```swift +let planets = try await Planet.query(on: database) + .filter(\.$type == .gasGiant) + .sort(\.$name) + .with(\.$star) + .all() +``` + +For more information, see the [Vapor documentation](https://docs.vapor.codes/fluent/overview/). \ No newline at end of file diff --git a/Sources/FluentKit/Enum/EnumBuilder.swift b/Sources/FluentKit/Enum/EnumBuilder.swift index 55b0c341..292cdb90 100644 --- a/Sources/FluentKit/Enum/EnumBuilder.swift +++ b/Sources/FluentKit/Enum/EnumBuilder.swift @@ -1,3 +1,6 @@ +import NIOCore +import SQLKit + extension Database { public func `enum`(_ name: String) -> EnumBuilder { .init(database: self, name: name) diff --git a/Sources/FluentKit/Enum/EnumMetadata.swift b/Sources/FluentKit/Enum/EnumMetadata.swift index 477f1f7b..f95a3471 100644 --- a/Sources/FluentKit/Enum/EnumMetadata.swift +++ b/Sources/FluentKit/Enum/EnumMetadata.swift @@ -1,3 +1,6 @@ +import NIOCore +import Foundation + final class EnumMetadata: Model { static let schema = "_fluent_enums" diff --git a/Sources/FluentKit/Enum/EnumProperty.swift b/Sources/FluentKit/Enum/EnumProperty.swift index d9018454..f84b5a83 100644 --- a/Sources/FluentKit/Enum/EnumProperty.swift +++ b/Sources/FluentKit/Enum/EnumProperty.swift @@ -68,6 +68,17 @@ extension EnumProperty: QueryableProperty { } } +// MARK: Query-addressable + +extension EnumProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self } + public var queryablePath: [FieldKey] { self.path } +} + +extension EnumProperty: QueryAddressableProperty { + public var queryableProperty: EnumProperty { self } +} + // MARK: Database extension EnumProperty: AnyDatabaseProperty { @@ -76,8 +87,23 @@ extension EnumProperty: AnyDatabaseProperty { } public func input(to input: DatabaseInput) { - if let value = self.value { - input.set(.enumCase(value.rawValue), at: self.field.key) + let value: DatabaseQuery.Value + if !input.wantsUnmodifiedKeys { + guard let ivalue = self.field.inputValue else { return } + value = ivalue + } else { + value = self.field.inputValue ?? .default + } + + switch value { + case .bind(let bind as String): + input.set(.enumCase(bind), at: self.field.key) + case .enumCase(let string): + input.set(.enumCase(string), at: self.field.key) + case .default: + input.set(.default, at: self.field.key) + default: + fatalError("Unexpected input value type for '\(Model.self)'.'\(self.field.key)': \(value)") } } diff --git a/Sources/FluentKit/Enum/OptionalEnumProperty.swift b/Sources/FluentKit/Enum/OptionalEnumProperty.swift index 1c9d1a51..4c212a14 100644 --- a/Sources/FluentKit/Enum/OptionalEnumProperty.swift +++ b/Sources/FluentKit/Enum/OptionalEnumProperty.swift @@ -48,8 +48,13 @@ extension OptionalEnumProperty: Property { } } set { - self.field.value = newValue?.map { - $0.rawValue + switch newValue { + case .some(.some(let newValue)): + self.field.value = .some(.some(newValue.rawValue)) + case .some(.none): + self.field.value = .some(.none) + case .none: + self.field.value = .none } } } @@ -69,6 +74,17 @@ extension OptionalEnumProperty: QueryableProperty { } } +// MARK: Query-addressable + +extension OptionalEnumProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self } + public var queryablePath: [FieldKey] { self.path } +} + +extension OptionalEnumProperty: QueryAddressableProperty { + public var queryableProperty: OptionalEnumProperty { self } +} + // MARK: Database extension OptionalEnumProperty: AnyDatabaseProperty { @@ -77,8 +93,26 @@ extension OptionalEnumProperty: AnyDatabaseProperty { } public func input(to input: DatabaseInput) { - if let value = self.value { - input.set(value.map { .enumCase($0.rawValue) } ?? .null, at: self.field.key) + let value: DatabaseQuery.Value + if !input.wantsUnmodifiedKeys { + guard let ivalue = self.field.inputValue else { return } + value = ivalue + } else { + value = self.field.inputValue ?? .default + } + + + switch value { + case .bind(let bind as String): + input.set(.enumCase(bind), at: self.field.key) + case .enumCase(let string): + input.set(.enumCase(string), at: self.field.key) + case .null: + input.set(.null, at: self.field.key) + case .default: + input.set(.default, at: self.field.key) + default: + fatalError("Unexpected input value type for '\(Model.self)'.'\(self.field.key)': \(value)") } } @@ -104,4 +138,3 @@ extension OptionalEnumProperty: AnyCodableProperty { } } } - diff --git a/Sources/FluentKit/Exports.swift b/Sources/FluentKit/Exports.swift index 9ee00ab4..9cded41b 100644 --- a/Sources/FluentKit/Exports.swift +++ b/Sources/FluentKit/Exports.swift @@ -1,3 +1,18 @@ +#if swift(>=5.8) + +@_documentation(visibility: internal) @_exported import struct Foundation.Date +@_documentation(visibility: internal) @_exported import struct Foundation.UUID + +@_documentation(visibility: internal) @_exported import Logging + +@_documentation(visibility: internal) @_exported import protocol NIO.EventLoop +@_documentation(visibility: internal) @_exported import class NIO.EventLoopFuture +@_documentation(visibility: internal) @_exported import struct NIO.EventLoopPromise +@_documentation(visibility: internal) @_exported import protocol NIO.EventLoopGroup +@_documentation(visibility: internal) @_exported import class NIO.NIOThreadPool + +#else + @_exported import struct Foundation.Date @_exported import struct Foundation.UUID @@ -7,3 +22,6 @@ @_exported import class NIO.EventLoopFuture @_exported import struct NIO.EventLoopPromise @_exported import protocol NIO.EventLoopGroup +@_exported import class NIO.NIOThreadPool + +#endif diff --git a/Sources/FluentKit/FluentError.swift b/Sources/FluentKit/FluentError.swift index 8d487f76..fad72cba 100644 --- a/Sources/FluentKit/FluentError.swift +++ b/Sources/FluentKit/FluentError.swift @@ -1,13 +1,14 @@ import Foundation -public enum FluentError: Error, LocalizedError, CustomStringConvertible { +public enum FluentError: Error, LocalizedError, CustomStringConvertible, CustomDebugStringConvertible { case idRequired case invalidField(name: String, valueType: Any.Type, error: Error) case missingField(name: String) case relationNotLoaded(name: String) - case missingParent + case missingParent(from: String, to: String, key: String, id: String) case noResults + // `CustomStringConvertible` conformance. public var description: String { switch self { case .idRequired: @@ -16,16 +17,120 @@ public enum FluentError: Error, LocalizedError, CustomStringConvertible { return "field missing: \(name)" case .relationNotLoaded(let name): return "relation not loaded: \(name)" - case .missingParent: - return "parent missing" + case .missingParent(let model, let parent, let key, let id): + return "parent missing: \(model).\(key): \(parent).\(id)" case .invalidField(let name, let valueType, let error): - return "invalid field: \(name) type: \(valueType) error: \(error)" + return "invalid field: '\(name)', type: \(valueType), error: \(String(describing: error))" case .noResults: return "Query returned no results" } } + // `CustomDebugStringConvertible` conformance. + public var debugDescription: String { + switch self { + case .idRequired, .missingField(_), .relationNotLoaded(_), .missingParent(_, _, _, _), .noResults: + return self.description + case .invalidField(let name, let valueType, let error): + return "invalid field: '\(name)', type: \(valueType), error: \(String(reflecting: error))" + } + } + + // `LocalizedError` conformance. public var errorDescription: String? { - return self.description + self.description + } + + // `LocalizedError` conformance. + public var failureReason: String? { + self.description + } +} + +extension FluentError { + internal static func missingParentError( + _: Child.Type = Child.self, _: Parent.Type = Parent.self, keyPath: KeyPath>, id: Parent.IDValue + ) -> Self { + .missingParent( + from: "\(Child.self)", + to: "\(Parent.self)", + key: Child.path(for: keyPath.appending(path: \.$id)).map(\.description).joined(separator: ".->"), + id: "\(id)" + ) + } + + internal static func missingParentError( + _: Child.Type = Child.self, _: Parent.Type = Parent.self, keyPath: KeyPath>, id: Parent.IDValue + ) -> Self where Parent.IDValue: Fields { + .missingParent( + from: "\(Child.self)", + to: "\(Parent.self)", + key: Child()[keyPath: keyPath].prefix.description, + id: "\(id)" + ) } + + internal static func missingParentError( + _: Child.Type = Child.self, _: Parent.Type = Parent.self, keyPath: KeyPath>, id: Parent.IDValue + ) -> Self { + .missingParent( + from: "\(Child.self)", + to: "\(Parent.self)", + key: Child.path(for: keyPath.appending(path: \.$id)).map(\.description).joined(separator: ".->"), + id: "\(id)" + ) + } + + internal static func missingParentError( + _: Child.Type = Child.self, _: Parent.Type = Parent.self, keyPath: KeyPath>, id: Parent.IDValue + ) -> Self where Parent.IDValue: Fields { + .missingParent( + from: "\(Child.self)", + to: "\(Parent.self)", + key: Child()[keyPath: keyPath].prefix.description, + id: "\(id)" + ) + } +} + +/// An error describing a failure during an an operation on an ``SiblingsProperty``. +/// +/// > Note: This should just be another case on ``FluentError``, not a separate error type, but at the time +/// of this writing, non-frozen enums are still not available to non-stdlib packages, so to avoid source +/// breakage we chose this as the least annoying of the several annoying workarounds. +public enum SiblingsPropertyError: Error, LocalizedError, CustomStringConvertible, CustomDebugStringConvertible { + /// An attempt was made to query, attach to, or detach from a siblings property whose owning model's ID + /// is not currently known (usually because that model has not yet been saved to the database). + /// + /// Includes the relation name of the siblings property. + case owningModelIdRequired(property: String) + + /// An attempt was made to attach, detach, or check attachment to a siblings property of a model whose + /// ID is not currently known (usually because that model has not yet been saved to the database). + /// + /// More explicitly, this case means that the model to be attached or detached (an instance of the "To" + /// model) is unsaved, whereas the above ``owningModelIdRequired`` case means that the model containing + /// the sublings property itself (an instead of the "From") model is unsaved. + /// + /// Includes the relation name of the siblings property. + case operandModelIdRequired(property: String) + + // `CustomStringConvertible` conformance. + public var description: String { + switch self { + case .owningModelIdRequired(property: let property): + return "siblings relation \(property) is missing owning model's ID (owner likely unsaved)" + case .operandModelIdRequired(property: let property): + return "operant model for siblings relation \(property) has no ID (attach/detach/etc. model likely unsaved)" + } + } + + // `CustomDebugStringConvertible` conformance. + public var debugDescription: String { self.description } + + // `LocalizedError` conformance. + public var errorDescription: String? { self.description } + + // `LocalizedError` conformance. + public var failureReason: String? { self.description } } diff --git a/Sources/FluentKit/Middleware/ModelMiddleware.swift b/Sources/FluentKit/Middleware/ModelMiddleware.swift index 73c5cfaf..2e2b72e2 100644 --- a/Sources/FluentKit/Middleware/ModelMiddleware.swift +++ b/Sources/FluentKit/Middleware/ModelMiddleware.swift @@ -1,3 +1,5 @@ +import NIOCore + public protocol AnyModelMiddleware { func handle( _ event: ModelEvent, diff --git a/Sources/FluentKit/Middleware/ModelResponder.swift b/Sources/FluentKit/Middleware/ModelResponder.swift index 2bba7097..a1368cfb 100644 --- a/Sources/FluentKit/Middleware/ModelResponder.swift +++ b/Sources/FluentKit/Middleware/ModelResponder.swift @@ -1,3 +1,5 @@ +import NIOCore + public protocol AnyModelResponder { func handle( _ event: ModelEvent, diff --git a/Sources/FluentKit/Migration/Migration.swift b/Sources/FluentKit/Migration/Migration.swift index 642f7477..73a4dc78 100644 --- a/Sources/FluentKit/Migration/Migration.swift +++ b/Sources/FluentKit/Migration/Migration.swift @@ -1,3 +1,5 @@ +import NIOCore + /// Fluent's `Migration` can handle database migrations, which can include /// adding new table, changing existing tables or adding /// seed data. These actions are executed only once. diff --git a/Sources/FluentKit/Migration/MigrationLog.swift b/Sources/FluentKit/Migration/MigrationLog.swift index 0e252956..84dcda78 100644 --- a/Sources/FluentKit/Migration/MigrationLog.swift +++ b/Sources/FluentKit/Migration/MigrationLog.swift @@ -1,3 +1,6 @@ +import NIOCore +import Foundation + /// Stores information about `Migration`s that have been run. public final class MigrationLog: Model { public static let schema = "_fluent_migrations" diff --git a/Sources/FluentKit/Migration/Migrator.swift b/Sources/FluentKit/Migration/Migrator.swift index e5ec8a78..c47c586c 100644 --- a/Sources/FluentKit/Migration/Migrator.swift +++ b/Sources/FluentKit/Migration/Migrator.swift @@ -1,35 +1,41 @@ import Foundation import AsyncKit import Logging +import NIOCore public struct Migrator { public let databaseFactory: (DatabaseID?) -> (Database) public let migrations: Migrations public let eventLoop: EventLoop + public let migrationLogLevel: Logger.Level public init( databases: Databases, migrations: Migrations, logger: Logger, - on eventLoop: EventLoop + on eventLoop: EventLoop, + migrationLogLevel: Logger.Level = .info ) { self.init( databaseFactory: { databases.database($0, logger: logger, on: eventLoop)! }, migrations: migrations, - on: eventLoop + on: eventLoop, + migrationLogLevel: migrationLogLevel ) } public init( databaseFactory: @escaping (DatabaseID?) -> (Database), migrations: Migrations, - on eventLoop: EventLoop + on eventLoop: EventLoop, + migrationLogLevel: Logger.Level = .info ) { self.databaseFactory = databaseFactory self.migrations = migrations self.eventLoop = eventLoop + self.migrationLogLevel = migrationLogLevel } // MARK: Setup @@ -104,12 +110,13 @@ public struct Migrator { } } - private func migrators( _ handler: (DatabaseMigrator) -> EventLoopFuture ) -> EventLoopFuture<[Result]> { - return self.migrations.storage.map { handler(.init(id: $0, database: self.databaseFactory($0), migrations: $1)) } - .flatten(on: self.eventLoop) + return self.migrations.storage.map { + handler(.init(id: $0, database: self.databaseFactory($0), migrations: $1, migrationLogLeveL: self.migrationLogLevel)) + } + .flatten(on: self.eventLoop) } } @@ -117,19 +124,20 @@ private final class DatabaseMigrator { let migrations: [Migration] let database: Database let id: DatabaseID? + let migrationLogLevel: Logger.Level - init(id: DatabaseID?, database: Database, migrations: [Migration]) { + init(id: DatabaseID?, database: Database, migrations: [Migration], migrationLogLeveL: Logger.Level) { self.migrations = migrations self.database = database self.id = id + self.migrationLogLevel = migrationLogLeveL } // MARK: Setup func setupIfNeeded() -> EventLoopFuture { return MigrationLog.migration.prepare(on: self.database) - .flatMap(self.preventUnstableNames) - .flatMap(self.fixPrereleaseMigrationNames) + .map(self.preventUnstableNames) } /// An unstable name is a name that is not the same every time migrations @@ -137,67 +145,15 @@ private final class DatabaseMigrator { /// /// For example, the default name for `Migrations` in private contexts /// will include an identifier that can change from one execution to the next. - private func preventUnstableNames() -> EventLoopFuture { - for migration in self.migrations { - let migrationName = migration.name - guard migration.name == migration.defaultName else { continue } - guard migrationName.contains("$") else { continue } - - if migrationName.contains("unknown context at") { - self.database.logger.critical("The migration at \(migrationName) is in a private context. Either explicitly give it a name by adding the `var name: String` property or make the migration `internal` or `public` instead of `private`.") + private func preventUnstableNames() { + for migration in self.migrations + where migration.name == migration.defaultName && migration.name.contains("$") + { + if migration.name.contains("unknown context at") { + self.database.logger.critical("The migration at \(migration.name) is in a private context. Either explicitly give it a name by adding the `var name: String` property or make the migration `internal` or `public` instead of `private`.") fatalError("Private migrations not allowed") } - self.database.logger.error("The migration at \(migrationName) has an unexpected default name. Consider giving it an explicit name by adding a `var name: String` property before applying these migrations.") - } - return self.database.eventLoop.makeSucceededFuture(()) - } - - // This migration just exists to smooth the gap between - // how migrations were named between the first FluentKit 1 - // alpha and the FluentKit 1.0.0 release. - // TODO: Remove in future version. - private func fixPrereleaseMigrationNames() -> EventLoopFuture { - // map from old style default names - // to new style default names - var migrationNameMap = [String: String]() - - // a set of names that are manually - // chosen by migration author. - var nameOverrides = Set() - - for migration in self.migrations { - // if the migration does not override the default name - // then it is a candidate for a name change. - if migration.name == migration.defaultName { - let releaseCandidateDefaultName = "\(type(of: migration))" - - migrationNameMap[releaseCandidateDefaultName] = migration.defaultName - } else { - nameOverrides.insert(migration.name) - } - } - // we must not rename anything that has an overridden - // name that happens to be the same as the old-style - // of default name. - for overriddenName in nameOverrides { - migrationNameMap.removeValue(forKey: overriddenName) - } - - self.database.logger.debug("Checking for pre-release migration names.") - return MigrationLog.query(on: self.database).filter(\.$name ~~ migrationNameMap.keys).count().flatMap { count in - if count > 0 { - self.database.logger.info("Fixing pre-release migration names") - let queries = migrationNameMap.map { oldName, newName -> EventLoopFuture in - self.database.logger.info("Renaming migration \(oldName) to \(newName)") - return MigrationLog.query(on: self.database) - .filter(\.$name == oldName) - .set(\.$name, to: newName) - .update() - } - return self.database.eventLoop.flatten(queries) - } else { - return self.database.eventLoop.makeSucceededFuture(()) - } + self.database.logger.error("The migration at \(migration.name) has an unexpected default name. Consider giving it an explicit name by adding a `var name: String` property before applying these migrations.") } } @@ -246,14 +202,24 @@ private final class DatabaseMigrator { // MARK: Private private func prepare(_ migration: Migration, batch: Int) -> EventLoopFuture { + self.database.logger.log(level: self.migrationLogLevel, "[Migrator] Starting prepare", metadata: ["migration": .string(migration.name)]) return migration.prepare(on: self.database).flatMap { + self.database.logger.log(level: self.migrationLogLevel, "[Migrator] Finished prepare", metadata: ["migration": .string(migration.name)]) return MigrationLog(name: migration.name, batch: batch).save(on: self.database) + }.flatMapErrorThrowing { + self.database.logger.error("[Migrator] Failed prepare: \(String(reflecting: $0))", metadata: ["migration": .string(migration.name)]) + throw $0 } } private func revert(_ migration: Migration) -> EventLoopFuture { + self.database.logger.log(level: self.migrationLogLevel, "[Migrator] Starting revert", metadata: ["migration": .string(migration.name)]) return migration.revert(on: self.database).flatMap { + self.database.logger.log(level: self.migrationLogLevel, "[Migrator] Finished revert", metadata: ["migration": .string(migration.name)]) return MigrationLog.query(on: self.database).filter(\.$name == migration.name).delete() + }.flatMapErrorThrowing { + self.database.logger.error("[Migrator] Failed revert: \(String(reflecting: $0))", metadata: ["migration": .string(migration.name)]) + throw $0 } } diff --git a/Sources/FluentKit/Model/AnyModel.swift b/Sources/FluentKit/Model/AnyModel.swift index 2e901209..454635ab 100644 --- a/Sources/FluentKit/Model/AnyModel.swift +++ b/Sources/FluentKit/Model/AnyModel.swift @@ -6,18 +6,13 @@ extension AnyModel { extension AnyModel { public var description: String { - var info: [InfoKey: CustomStringConvertible] = [:] - let input = self.collectInput() - if !input.isEmpty { - info["input"] = input - } - - if let output = self.anyID.cachedOutput { - info["output"] = output - } - - return "\(Self.self)(\(info.debugDescription.dropFirst().dropLast()))" + let info = [ + "input": !input.isEmpty ? input.description : nil, + "output": self.anyID.cachedOutput?.description + ].compactMapValues({ $0 }) + + return "\(Self.self)(\(info.isEmpty ? ":" : info.map { "\($0): \($1)" }.joined(separator: ", ")))" } // MARK: Joined @@ -26,27 +21,25 @@ extension AnyModel { where Joined: Schema { guard let output = self.anyID.cachedOutput else { - fatalError("Can only access joined models using models fetched from database.") + fatalError("Can only access joined models using models fetched from database (from \(Self.self) to \(Joined.self)).") } let joined = Joined() - try joined.output(from: output.schema(Joined.schemaOrAlias)) + try joined.output(from: output.qualifiedSchema(space: Joined.spaceIfNotAliased, Joined.schemaOrAlias)) return joined } var anyID: AnyID { - guard let id = Mirror(reflecting: self).descendant("_id") as? AnyID else { - fatalError("id property must be declared using @ID") + for (nameC, child) in _FastChildSequence(subject: self) { + /// Match a property named `_id` which conforms to `AnyID`. `as?` is expensive, so check that last. + if nameC?[0] == 0x5f/* '_' */, + nameC?[1] == 0x69/* 'i' */, + nameC?[2] == 0x64/* 'd' */, + nameC?[3] == 0x00/* '\0' */, + let idChild = child as? AnyID + { + return idChild + } } - return id - } -} - -private struct InfoKey: ExpressibleByStringLiteral, Hashable, CustomStringConvertible { - let value: String - var description: String { - return self.value - } - init(stringLiteral value: String) { - self.value = value + fatalError("id property must be declared using @ID or @CompositeID") } } diff --git a/Sources/FluentKit/Model/EagerLoad.swift b/Sources/FluentKit/Model/EagerLoad.swift index 15382162..4c0ff36a 100644 --- a/Sources/FluentKit/Model/EagerLoad.swift +++ b/Sources/FluentKit/Model/EagerLoad.swift @@ -1,3 +1,5 @@ +import NIOCore + public protocol EagerLoader: AnyEagerLoader { associatedtype Model: FluentKit.Model func run(models: [Model], on database: Database) -> EventLoopFuture @@ -21,6 +23,12 @@ public protocol EagerLoadable { _ relationKey: KeyPath, to builder: Builder ) where Builder: EagerLoadBuilder, Builder.Model == From + + static func eagerLoad( + _ relationKey: KeyPath, + withDeleted: Bool, + to builder: Builder + ) where Builder: EagerLoadBuilder, Builder.Model == From static func eagerLoad( _ loader: Loader, @@ -31,3 +39,13 @@ public protocol EagerLoadable { Loader.Model == To, Builder.Model == From } + +extension EagerLoadable { + public static func eagerLoad( + _ relationKey: KeyPath, + withDeleted: Bool, + to builder: Builder + ) where Builder: EagerLoadBuilder, Builder.Model == From { + return Self.eagerLoad(relationKey, to: builder) + } +} diff --git a/Sources/FluentKit/Model/Fields+Codable.swift b/Sources/FluentKit/Model/Fields+Codable.swift index 48da64a0..fe1e8686 100644 --- a/Sources/FluentKit/Model/Fields+Codable.swift +++ b/Sources/FluentKit/Model/Fields+Codable.swift @@ -1,140 +1,66 @@ extension Fields { public init(from decoder: Decoder) throws { self.init() - let container = try decoder.container(keyedBy: ModelCodingKey.self) - try self.labeledProperties.forEach { label, property in + + let container = try decoder.container(keyedBy: SomeCodingKey.self) + + for (key, property) in self.codableProperties { +#if swift(<5.7.1) + let propDecoder = WorkaroundSuperDecoder(container: container, key: key) +#else + let propDecoder = try container.superDecoder(forKey: key) +#endif do { - let decoder = ContainerDecoder(container: container, key: .string(label)) - try property.decode(from: decoder) + try property.decode(from: propDecoder) } catch { - throw DecodingError.typeMismatch( - type(of: property).anyValueType, - .init( - codingPath: [ModelCodingKey.string(label)], - debugDescription: "Could not decode property", - underlyingError: error - ) - ) + throw DecodingError.typeMismatch(type(of: property).anyValueType, .init( + codingPath: container.codingPath + [key], + debugDescription: "Could not decode property", + underlyingError: error + )) } } } public func encode(to encoder: Encoder) throws { - let container = encoder.container(keyedBy: ModelCodingKey.self) - try self.labeledProperties.forEach { label, property in + var container = encoder.container(keyedBy: SomeCodingKey.self) + + for (key, property) in self.codableProperties where !property.skipPropertyEncoding { do { - let encoder = ContainerEncoder(container: container, key: .string(label)) - try property.encode(to: encoder) - } catch { - throw EncodingError.invalidValue( - property.anyValue ?? "null", - .init( - codingPath: [ModelCodingKey.string(label)], - debugDescription: "Could not encode property", - underlyingError: error - ) - ) + try property.encode(to: container.superEncoder(forKey: key)) + } catch let error where error is EncodingError { // trapping all errors breaks value handling logic in database driver layers + throw EncodingError.invalidValue(property.anyValue ?? "null", .init( + codingPath: container.codingPath + [key], + debugDescription: "Could not encode property", + underlyingError: error + )) } } } } -enum ModelCodingKey: CodingKey { - case string(String) - case int(Int) - - var stringValue: String { - switch self { - case .int(let int): return int.description - case .string(let string): return string - } - } - - var intValue: Int? { - switch self { - case .int(let int): return int - case .string(let string): return Int(string) - } - } - - init?(stringValue: String) { - self = .string(stringValue) - } - - init?(intValue: Int) { - self = .int(intValue) - } -} - - -private struct ContainerDecoder: Decoder, SingleValueDecodingContainer { - let container: KeyedDecodingContainer - let key: ModelCodingKey - - var codingPath: [CodingKey] { - self.container.codingPath - } - - var userInfo: [CodingUserInfoKey : Any] { - [:] - } - - func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { - try self.container.nestedContainer(keyedBy: Key.self, forKey: self.key) - } - - func unkeyedContainer() throws -> UnkeyedDecodingContainer { - try self.container.nestedUnkeyedContainer(forKey: self.key) - } - - func singleValueContainer() throws -> SingleValueDecodingContainer { - self - } - - func decode(_ type: T.Type) throws -> T where T : Decodable { - try self.container.decode(T.self, forKey: self.key) - } - - func decodeNil() -> Bool { - if self.container.contains(self.key) { - return try! self.container.decodeNil(forKey: self.key) - } else { - return true - } - } -} - -private struct ContainerEncoder: Encoder, SingleValueEncodingContainer { - var container: KeyedEncodingContainer - let key: ModelCodingKey - - var codingPath: [CodingKey] { - self.container.codingPath - } - - var userInfo: [CodingUserInfoKey : Any] { - [:] - } - - func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key : CodingKey { - var container = self.container - return container.nestedContainer(keyedBy: Key.self, forKey: self.key) - } - - func unkeyedContainer() -> UnkeyedEncodingContainer { - var container = self.container - return container.nestedUnkeyedContainer(forKey: self.key) - } - - func singleValueContainer() -> SingleValueEncodingContainer { - self - } - - mutating func encode(_ value: T) throws where T : Encodable { - try self.container.encode(value, forKey: self.key) - } - - mutating func encodeNil() throws { - try self.container.encodeNil(forKey: self.key) - } +#if swift(<5.7.1) +/// This ``Decoder`` compensates for a bug in `KeyedDecodingContainerProtocol.superDecoder(forKey:)` on Linux +/// which first appeared in Swift 5.5 and was fixed in Swift 5.7.1. +/// +/// When a given key is not present in the input JSON, `.superDecoder(forKey:)` is expected to return a valid +/// ``Decoder`` that will only decode a nil value. However, in affected versions of Swift, the method instead +/// throws a ``DecodingError/keyNotFound``. +/// +/// As a workaround, instead of calling `.superDecoder(forKey:)`, an instance of this type is created and +/// provided with the decoding container; the apporiate decoding methods are intercepted to provide the +/// desired semantics, with everything else being forwarded directly to the container. This has a minor but +/// nonzero impact on performance, but was determined to be the best and cleanest option. +private struct WorkaroundSuperDecoder: Decoder, SingleValueDecodingContainer { + var codingPath: [CodingKey] { self.container.codingPath } + var userInfo: [CodingUserInfoKey: Any] { [:] } + let container: KeyedDecodingContainer + let key: K + + func container(keyedBy: NK.Type) throws -> KeyedDecodingContainer { try self.container.nestedContainer(keyedBy: NK.self, forKey: self.key) } + func unkeyedContainer() throws -> UnkeyedDecodingContainer { try self.container.nestedUnkeyedContainer(forKey: self.key) } + func singleValueContainer() throws -> SingleValueDecodingContainer { self } + func decode(_: T.Type) throws -> T { try self.container.decode(T.self, forKey: self.key) } + func decodeNil() -> Bool { self.container.contains(self.key) ? try! self.container.decodeNil(forKey: self.key) : true } } +#endif diff --git a/Sources/FluentKit/Model/Fields.swift b/Sources/FluentKit/Model/Fields.swift index 7478d934..d36867d2 100644 --- a/Sources/FluentKit/Model/Fields.swift +++ b/Sources/FluentKit/Model/Fields.swift @@ -1,39 +1,50 @@ +/// A type conforming to ``Fields`` is able to use FluentKit's various property wrappers to declare +/// name, type, and semantic information for individual properties corresponding to fields in a +/// generic database storage system. +/// +/// ``Fields`` is usually only used directly when in conjunction with the `@Group` and `@CompositeID` +/// property types. The ``Schema`` and ``Model`` protocols build on ``Fields`` to provide additional +/// capabilities and semantics. +/// +/// Most of the protocol requirements of ``Fields`` are implemented in FluentKit. A conformant type +/// needs only to provide the definition of ``init()``, which will in turn almost always be empty. +/// (In fact, FluentKit would provide this implementation as well if the language permitted.) Providing +/// custom implementations of any other requirements is **strongly** discouraged; under most +/// circumstances, such implementations will not be invoked in any event. They are only declared on +/// the base protocol rather than solely in extensions because static dispatch improves performance. public protocol Fields: AnyObject, Codable { + /// Returns a fully generic list of every property on the given instance of the type which uses any of + /// the FluentKit property wrapper types (e.g. any wrapper conforming to ``AnyProperty``). This accessor + /// is not static because FluentKit depends upon access to the backing storage of the property wrappers, + /// which is specific to each instance. + /// + /// - Warning: This accessor triggers the use of reflection, which is at the time of this writing the + /// most severe performance bottleneck in FluentKit by a huge margin. Every access of this property + /// carries the same cost; it is not possible to meaningfully cache the results. See + /// `MirrorBypass.swift` for a considerable amount of very low-level detail. var properties: [AnyProperty] { get } + init() + func input(to input: DatabaseInput) func output(from output: DatabaseOutput) throws } -// MARK: Has Changes - -extension Fields { - /// Indicates whether the model has fields that have been set, but the model - /// has not yet been saved to the database. - public var hasChanges: Bool { - let input = HasChangesInput() - self.input(to: input) - return input.hasChanges - } -} - -private final class HasChangesInput: DatabaseInput { - var hasChanges: Bool - - init() { - self.hasChanges = false - } - - func set(_ value: DatabaseQuery.Value, at key: FieldKey) { - self.hasChanges = true - } -} - // MARK: Path extension Fields { + /// Returns an array of ``FieldKey``s representing the individual textual components of the full path + /// of the database field corresponding to the given Swift property. This method can only reference + /// properties which represent actual fields in the database, corresponding to the ``AnyQueryableProperty`` + /// protocol - for example, it can not be used with with the `@Children` property type, nor directly + /// with an `@Parent` property. + /// + /// Almost all properties have only a single path component; support for multistep paths is primarily + /// intended to support drilling down into JSON structures. At the time of this writing, the current + /// version of FluentKit will always yield field paths with exactly one component. Unfortunately, the + /// API can not be changed to eliminate the array wrapper without major source breakage. public static func path(for field: KeyPath) -> [FieldKey] - where Property: QueryableProperty + where Property: AnyQueryableProperty { Self.init()[keyPath: field].path } @@ -42,26 +53,34 @@ extension Fields { // MARK: Database extension Fields { + /// Return an array of all database keys (i.e. non-nested field names) defined by all properties + /// declared on the type. This includes properties which may contain multiple fields at once, such + /// as `@Group`. public static var keys: [FieldKey] { - self.init().properties.compactMap { - $0 as? AnyDatabaseProperty - }.flatMap { - $0.keys - } + self.init().databaseProperties.flatMap(\.keys) } + /// For each property declared on the type, if that property is marked as having changed since the + /// type was either loaded or created, add the key-value pair for said property to the given database + /// input object. This prepares data in memory to be written to the database. + /// + /// - Note: It is trivial to construct ``DatabaseInput`` objects which do not in fact actually transfer + /// their contents to a database. FluentKit itself does this to implement a save/restore operation for + /// model state under certain conditions (see ``Model``). public func input(to input: DatabaseInput) { - self.properties.compactMap { - $0 as? AnyDatabaseProperty - }.forEach { field in + for field in self.databaseProperties { field.input(to: input) } } + /// For each property declared on the type, if that property's key is available in the given database + /// output object, attempt to load the corresponding value into the property. This transfers data + /// received from the database into memory. + /// + /// - Note: It is trivial to construct ``DatabaseOutput`` objects which do not in fact actually represent + /// data from a database. FluentKit itself does this to help keep models up to date (see ``Model``). public func output(from output: DatabaseOutput) throws { - try self.properties.compactMap { - $0 as? AnyDatabaseProperty - }.forEach { field in + for field in self.databaseProperties { try field.output(from: output) } } @@ -69,89 +88,88 @@ extension Fields { // MARK: Properties -#if compiler(<5.6) && compiler(>=5.2) -@_silgen_name("swift_reflectionMirror_normalizedType") -internal func _getNormalizedType(_: T, type: Any.Type) -> Any.Type +extension Fields { + /// Default implementation of ``Fields/properties-dup4``. + public var properties: [AnyProperty] { + return _FastChildSequence(subject: self).compactMap { $1 as? AnyProperty } + } + + /// A wrapper around ``properties`` which returns only the properties which have database keys and can be + /// input to and output from a database (corresponding to the ``AnyDatabaseProperty`` protocol). + internal var databaseProperties: [AnyDatabaseProperty] { + self.properties.compactMap { $0 as? AnyDatabaseProperty } + } -@_silgen_name("swift_reflectionMirror_count") -internal func _getChildCount(_: T, type: Any.Type) -> Int + /// Returns all properties which can be serialized and deserialized independently of a database via the + /// built-in ``Codable`` machinery (corresponding to the ``AnyCodableProperty`` protocol), indexed by + /// the coding key for each property. + /// + /// - Important: A property's _coding_ key is not the same as a _database_ key. The coding key is derived + /// directly from the property's Swift name as provided by reflection, while database keys are provided + /// in the property wrapper initializer declarations. + /// + /// - Warning: Even if the type has a custom ``CodingKeys`` enum, the property's coding key will _not_ + /// correspond to the definition provided therein; it will always be based solely on the Swift + /// property name. + /// + /// - Warning: Like ``properties``, this method uses reflection, and incurs all of the accompanying + /// performance penalties. + internal var codableProperties: [SomeCodingKey: AnyCodableProperty] { + return .init(uniqueKeysWithValues: _FastChildSequence(subject: self).compactMap { + guard let value = $1 as? AnyCodableProperty, + let nameC = $0, nameC[0] != 0, nameC[1] != 0, + let name = String(utf8String: nameC + 1) + else { + return nil + } + return (.init(stringValue: name), value) + }) + } +} -@_silgen_name("swift_reflectionMirror_subscript") -internal func _getChild( - of: T, type: Any.Type, index: Int, - outName: UnsafeMutablePointer?>, - outFreeFunc: UnsafeMutablePointer<(@convention(c) (UnsafePointer?) -> Void)?> -) -> Any -#endif +// MARK: Has Changes extension Fields { - public var properties: [AnyProperty] { -#if compiler(<5.6) && compiler(>=5.2) && swift(>=5.2) - let type = _getNormalizedType(self, type: Swift.type(of: self)) - let childCount = _getChildCount(self, type: type) - return (0 ..< childCount).compactMap({ - var nameC: UnsafePointer? = nil - var freeFunc: (@convention(c) (UnsafePointer?) -> Void)? = nil - defer { freeFunc?(nameC) } - return _getChild(of: self, type: Self.self, index: $0, outName: &nameC, outFreeFunc: &freeFunc) as? AnyProperty - }) -#else - Mirror(reflecting: self).children.compactMap { - $0.value as? AnyProperty - } -#endif + /// Returns `true` if a model has fields whose values have been explicitly set or modified + /// since the most recent load from and/or save to the database (if any). + /// + /// If `false` is returned, attempts to save changes to the database (or more precisely, to + /// send values to any given ``DatabaseInput``) will do nothing. + public var hasChanges: Bool { + let input = HasChangesInput() + self.input(to: input) + return input.hasChanges } +} - internal var labeledProperties: [String: AnyCodableProperty] { -#if compiler(<5.6) && compiler(>=5.2) && swift(>=5.2) - let type = _getNormalizedType(self, type: Swift.type(of: self)) - let childCount = _getChildCount(self, type: type) - - return .init(uniqueKeysWithValues: - (0 ..< childCount).compactMap({ - var nameC: UnsafePointer? = nil - var freeFunc: (@convention(c) (UnsafePointer?) -> Void)? = nil - defer { freeFunc?(nameC) } - guard let value = _getChild( - of: self, type: Self.self, index: $0, outName: &nameC, outFreeFunc: &freeFunc - ) as? AnyCodableProperty, - let nameCC = nameC, nameCC.pointee != 0, nameCC.advanced(by: 1).pointee != 0, - let name = String(validatingUTF8: nameCC.advanced(by: 1)) - else { return nil } - return (name, value) - }) - ) -#else - .init(uniqueKeysWithValues: - Mirror(reflecting: self).children.compactMap { child in - guard let label = child.label else { - return nil - } - guard let field = child.value as? AnyCodableProperty else { - return nil - } - // remove underscore - return (String(label.dropFirst()), field) - } - ) -#endif +/// Helper type for the implementation of ``Fields/hasChanges``. +private final class HasChangesInput: DatabaseInput { + var hasChanges: Bool = false + + func set(_: DatabaseQuery.Value, at _: FieldKey) { + self.hasChanges = true } } // MARK: Collect Input extension Fields { - internal func collectInput() -> [FieldKey: DatabaseQuery.Value] { - let input = DictionaryInput() + /// Returns a dictionary of field keys and associated values representing all "pending" + /// data - e.g. all fields (if any) which have been changed by something other than Fluent. + internal func collectInput(withDefaultedValues defaultedValues: Bool = false) -> [FieldKey: DatabaseQuery.Value] { + let input = DictionaryInput(wantsUnmodifiedKeys: defaultedValues) self.input(to: input) return input.storage } } -final class DictionaryInput: DatabaseInput { - var storage: [FieldKey: DatabaseQuery.Value] - init() { - self.storage = [:] +/// Helper type for the implementation of `Fields.collectInput()`. +private final class DictionaryInput: DatabaseInput { + var storage: [FieldKey: DatabaseQuery.Value] = [:] + let wantsUnmodifiedKeys: Bool + + init(wantsUnmodifiedKeys: Bool = false) { + self.wantsUnmodifiedKeys = wantsUnmodifiedKeys } func set(_ value: DatabaseQuery.Value, at key: FieldKey) { diff --git a/Sources/FluentKit/Model/MirrorBypass.swift b/Sources/FluentKit/Model/MirrorBypass.swift new file mode 100644 index 00000000..99906be5 --- /dev/null +++ b/Sources/FluentKit/Model/MirrorBypass.swift @@ -0,0 +1,133 @@ +#if compiler(<5.10) +@_silgen_name("swift_reflectionMirror_normalizedType") +internal func _getNormalizedType(_: T, type: Any.Type) -> Any.Type + +@_silgen_name("swift_reflectionMirror_count") +internal func _getChildCount(_: T, type: Any.Type) -> Int + +@_silgen_name("swift_reflectionMirror_subscript") +internal func _getChild( + of: T, type: Any.Type, index: Int, + outName: UnsafeMutablePointer?>, + outFreeFunc: UnsafeMutablePointer<(@convention(c) (UnsafePointer?) -> Void)?> +) -> Any +#endif + +internal struct _FastChildIterator: IteratorProtocol { + private final class _CStringBox { + let ptr: UnsafePointer + let freeFunc: (@convention(c) (UnsafePointer?) -> Void) + init(ptr: UnsafePointer, freeFunc: @escaping @convention(c) (UnsafePointer?) -> Void) { + self.ptr = ptr + self.freeFunc = freeFunc + } + deinit { self.freeFunc(self.ptr) } + } + +#if compiler(<5.10) + private let subject: AnyObject + private let type: Any.Type + private let childCount: Int + private var index: Int +#else + private var iterator: Mirror.Children.Iterator +#endif + private var lastNameBox: _CStringBox? + +#if compiler(<5.10) + fileprivate init(subject: AnyObject, type: Any.Type, childCount: Int) { + self.subject = subject + self.type = type + self.childCount = childCount + self.index = 0 + } +#else + fileprivate init(iterator: Mirror.Children.Iterator) { + self.iterator = iterator + } +#endif + + init(subject: AnyObject) { +#if compiler(<5.10) + let type = _getNormalizedType(subject, type: Swift.type(of: subject)) + self.init( + subject: subject, + type: type, + childCount: _getChildCount(subject, type: type) + ) +#else + self.init(iterator: Mirror(reflecting: subject).children.makeIterator()) +#endif + } + + /// The `name` pointer returned by this iterator has a rather unusual lifetime guarantee - it shall remain valid + /// until either the proceeding call to `next()` or the end of the iterator's scope. This admittedly bizarre + /// semantic is a concession to the fact that this entire API is intended to bypass the massive speed penalties of + /// `Mirror` as much as possible, and copying a name that many callers will never even access to begin with is + /// hardly a means to that end. + /// + /// - Note: Ironically, in the fallback case that uses `Mirror` directly, preserving this semantic actually imposes + /// an _additional_ performance penalty. + mutating func next() -> (name: UnsafePointer?, child: Any)? { +#if compiler(<5.10) + guard self.index < self.childCount else { + self.lastNameBox = nil // ensure any lingering name gets freed + return nil + } + + var nameC: UnsafePointer? = nil + var freeFunc: (@convention(c) (UnsafePointer?) -> Void)? = nil + let child = _getChild(of: self.subject, type: self.type, index: self.index, outName: &nameC, outFreeFunc: &freeFunc) + + self.index += 1 + self.lastNameBox = nameC.flatMap { nameC in freeFunc.map { _CStringBox(ptr: nameC, freeFunc: $0) } } // don't make a box if there's no name or no free function to call + return (name: nameC, child: child) +#else + guard let child = self.iterator.next() else { + self.lastNameBox = nil + return nil + } + if var label = child.label { + let nameC = label.withUTF8 { + let buf = UnsafeMutableBufferPointer.allocate(capacity: $0.count + 1) + buf.initialize(repeating: 0) + _ = $0.withMemoryRebound(to: CChar.self) { buf.update(fromContentsOf: $0) } + return buf.baseAddress! + } + self.lastNameBox = _CStringBox(ptr: UnsafePointer(nameC), freeFunc: { $0?.deallocate() }) + return (name: UnsafePointer(nameC), child: child.value) + } else { + self.lastNameBox = nil + return (name: nil, child: child.value) + } +#endif + } +} + +internal struct _FastChildSequence: Sequence { +#if compiler(<5.10) + private let subject: AnyObject + private let type: Any.Type + private let childCount: Int +#else + private let children: Mirror.Children +#endif + + init(subject: AnyObject) { +#if compiler(<5.10) + self.subject = subject + self.type = _getNormalizedType(subject, type: Swift.type(of: subject)) + self.childCount = _getChildCount(subject, type: self.type) +#else + self.children = Mirror(reflecting: subject).children +#endif + } + + func makeIterator() -> _FastChildIterator { +#if compiler(<5.10) + return _FastChildIterator(subject: self.subject, type: self.type, childCount: self.childCount) +#else + return _FastChildIterator(iterator: self.children.makeIterator()) +#endif + } +} diff --git a/Sources/FluentKit/Model/Model+CRUD.swift b/Sources/FluentKit/Model/Model+CRUD.swift index 07048458..9f205eba 100644 --- a/Sources/FluentKit/Model/Model+CRUD.swift +++ b/Sources/FluentKit/Model/Model+CRUD.swift @@ -1,6 +1,9 @@ +import NIOCore +import protocol SQLKit.SQLDatabase + extension Model { public func save(on database: Database) -> EventLoopFuture { - if self._$id.exists { + if self._$idExists { return self.update(on: database) } else { return self.create(on: database) @@ -9,45 +12,56 @@ extension Model { public func create(on database: Database) -> EventLoopFuture { return database.configuration.middleware.chainingTo(Self.self) { event, model, db in - model.handle(event, on: db) + try model.handle(event, on: db) }.handle(.create, self, on: database) } private func _create(on database: Database) -> EventLoopFuture { - precondition(!self._$id.exists) + precondition(!self._$idExists) self.touchTimestamps(.create, .update) - self._$id.generate() - let promise = database.eventLoop.makePromise(of: DatabaseOutput.self) - Self.query(on: database) - .set(self.collectInput()) - .action(.create) - .run { promise.succeed($0) } - .cascadeFailure(to: promise) - return promise.futureResult.flatMapThrowing { output in - var input = self.collectInput() - if case .default = self._$id.inputValue { - let idKey = Self()._$id.key - input[idKey] = try .bind(output.decode(idKey, as: Self.IDValue.self)) + if self.anyID is AnyQueryableProperty { + self.anyID.generate() + let promise = database.eventLoop.makePromise(of: DatabaseOutput.self) + Self.query(on: database) + .set(self.collectInput(withDefaultedValues: database is SQLDatabase)) + .action(.create) + .run { promise.succeed($0) } + .cascadeFailure(to: promise) + return promise.futureResult.flatMapThrowing { output in + var input = self.collectInput() + if case .default = self._$id.inputValue { + let idKey = Self()._$id.key + input[idKey] = try .bind(output.decode(idKey, as: Self.IDValue.self)) + } + try self.output(from: SavedInput(input)) } - try self.output(from: SavedInput(input)) + } else { + return Self.query(on: database) + .set(self.collectInput(withDefaultedValues: database is SQLDatabase)) + .action(.create) + .run() + .flatMapThrowing { + try self.output(from: SavedInput(self.collectInput())) + } } } public func update(on database: Database) -> EventLoopFuture { return database.configuration.middleware.chainingTo(Self.self) { event, model, db in - model.handle(event, on: db) + try model.handle(event, on: db) }.handle(.update, self, on: database) } - private func _update(on database: Database) -> EventLoopFuture { - precondition(self._$id.exists) + private func _update(on database: Database) throws -> EventLoopFuture { + precondition(self._$idExists) guard self.hasChanges else { return database.eventLoop.makeSucceededFuture(()) } self.touchTimestamps(.update) let input = self.collectInput() + guard let id = self.id else { throw FluentError.idRequired } return Self.query(on: database) - .filter(\._$id == self.id!) + .filter(id: id) .set(input) .update() .flatMapThrowing @@ -60,75 +74,77 @@ extension Model { if !force, let timestamp = self.deletedTimestamp { timestamp.touch() return database.configuration.middleware.chainingTo(Self.self) { event, model, db in - model.handle(event, on: db) + try model.handle(event, on: db) }.handle(.softDelete, self, on: database) } else { return database.configuration.middleware.chainingTo(Self.self) { event, model, db in - model.handle(event, on: db) + try model.handle(event, on: db) }.handle(.delete(force), self, on: database) } } - private func _delete(force: Bool = false, on database: Database) -> EventLoopFuture { + private func _delete(force: Bool = false, on database: Database) throws -> EventLoopFuture { + guard let id = self.id else { throw FluentError.idRequired } return Self.query(on: database) - .filter(\._$id == self.id!) + .filter(id: id) .delete(force: force) .map { if force || self.deletedTimestamp == nil { - self._$id.exists = false + self._$idExists = false } } } public func restore(on database: Database) -> EventLoopFuture { return database.configuration.middleware.chainingTo(Self.self) { event, model, db in - model.handle(event, on: db) + try model.handle(event, on: db) }.handle(.restore, self, on: database) } - private func _restore(on database: Database) -> EventLoopFuture { + private func _restore(on database: Database) throws -> EventLoopFuture { guard let timestamp = self.timestamps.filter({ $0.trigger == .delete }).first else { fatalError("no delete timestamp on this model") } timestamp.touch(date: nil) - precondition(self._$id.exists) + precondition(self._$idExists) + guard let id = self.id else { throw FluentError.idRequired } return Self.query(on: database) .withDeleted() - .filter(\._$id == self.id!) + .filter(id: id) .set(self.collectInput()) .action(.update) .run() .flatMapThrowing { try self.output(from: SavedInput(self.collectInput())) - self._$id.exists = true + self._$idExists = true } } - private func handle(_ event: ModelEvent, on db: Database) -> EventLoopFuture { + private func handle(_ event: ModelEvent, on db: Database) throws -> EventLoopFuture { switch event { case .create: return _create(on: db) case .delete(let force): - return _delete(force: force, on: db) + return try _delete(force: force, on: db) case .restore: - return _restore(on: db) + return try _restore(on: db) case .softDelete: - return _delete(force: false, on: db) + return try _delete(force: false, on: db) case .update: - return _update(on: db) + return try _update(on: db) } } } extension Collection where Element: FluentKit.Model { public func delete(force: Bool = false, on database: Database) -> EventLoopFuture { - guard self.count > 0 else { + guard !self.isEmpty else { return database.eventLoop.makeSucceededFuture(()) } - - precondition(self.allSatisfy { $0._$id.exists }) + + precondition(self.allSatisfy { $0._$idExists }) return EventLoopFuture.andAllSucceed(self.map { model in database.configuration.middleware.chainingTo(Element.self) { event, model, db in @@ -136,37 +152,39 @@ extension Collection where Element: FluentKit.Model { }.delete(model, force: force, on: database) }, on: database.eventLoop).flatMap { Element.query(on: database) - .filter(\._$id ~~ self.map { $0.id! }) + .filter(ids: self.map { $0.id! }) .delete(force: force) }.map { guard force else { return } for model in self where model.deletedTimestamp == nil { - model._$id.exists = false + model._$idExists = false } } } public func create(on database: Database) -> EventLoopFuture { - guard self.count > 0 else { + guard !self.isEmpty else { return database.eventLoop.makeSucceededFuture(()) } - precondition(self.allSatisfy { !$0._$id.exists }) + precondition(self.allSatisfy { !$0._$idExists }) return EventLoopFuture.andAllSucceed(self.enumerated().map { idx, model in database.configuration.middleware.chainingTo(Element.self) { event, model, db in - model._$id.generate() + if model.anyID is AnyQueryableProperty { + model._$id.generate() + } model.touchTimestamps(.create, .update) return db.eventLoop.makeSucceededFuture(()) }.create(model, on: database) }, on: database.eventLoop).flatMap { Element.query(on: database) - .set(self.map { $0.collectInput() }) + .set(self.map { $0.collectInput(withDefaultedValues: database is SQLDatabase) }) .create() }.map { for model in self { - model._$id.exists = true + model._$idExists = true } } } diff --git a/Sources/FluentKit/Model/Model.swift b/Sources/FluentKit/Model/Model.swift index baa8843b..754b18df 100644 --- a/Sources/FluentKit/Model/Model.swift +++ b/Sources/FluentKit/Model/Model.swift @@ -1,3 +1,5 @@ +import NIOCore + public protocol Model: AnyModel { associatedtype IDValue: Codable, Hashable var id: IDValue? { get set } @@ -16,7 +18,7 @@ extension Model { return database.eventLoop.makeSucceededFuture(nil) } return Self.query(on: database) - .filter(\._$id == id) + .filter(id: id) .first() } @@ -26,6 +28,21 @@ extension Model { } return id } + + /// Replaces the existing common usage of `model._$id.exists`, which indicates whether any + /// particular generic model has a non-`nil` ID that was loaded from a database query (or + /// was overridden to allow Fluent to assume as such without having to check first). This + /// version works for models which use `@CompositeID()`. It would not be necessary if + /// support existed for property wrappers in protocols. + /// + /// - Note: Adding this property to ``Model`` rather than making the ``AnyID`` protocol + /// and ``anyID`` property public was chosen because implementing a new conformance for + /// ``AnyID`` can not be done correctly from outside FluentKit; it would be mostly useless + /// and potentially confusing public API surface. + public var _$idExists: Bool { + get { self.anyID.exists } + set { self.anyID.exists = newValue } + } public var _$id: ID { self.anyID as! ID diff --git a/Sources/FluentKit/Model/ModelAlias.swift b/Sources/FluentKit/Model/ModelAlias.swift index e0e7d9b6..d1866fde 100644 --- a/Sources/FluentKit/Model/ModelAlias.swift +++ b/Sources/FluentKit/Model/ModelAlias.swift @@ -1,91 +1,212 @@ +/// Describes a model whose schema has an alias. +/// +/// The ``ModelAlias`` protocol allows creating model types which are identical to +/// an existing ``Model`` except that any Fluent query referencing the aliased type +/// will use the provided alias name to refer to the model's ``schema`` rather than +/// the one specified by the model type. This allows, for example, referencing the +/// same model more than once within the same query, such as when joining to a +/// a parent model twice in the same query when the original model has multiple +/// parent references of the same type. +/// +/// Types conforming to this protocol can be used anywhere the original model's type +/// may be referenced. The alias type will mirror the ``space`` and ``schema`` of the +/// original model, and provide its name for the ``alias`` property, affecting the +/// result of the ``Schema/schemaOrAlias`` accessor. This accessor is used anywhere +/// that a schema name that has been aliased may appear in place of the original. +/// +/// Example: +/// +/// ```` +/// final class Team: Model { +/// static let schema = "teams" +/// @ID( key: .id) var id: UUID? +/// @Field(key: "name") var name: String +/// init() {} +/// } +/// final class Match: Model { +/// static let schema = "matches" +/// @ID( key: .id) var id: UUID? +/// @Parent(key: "home_team_id") var homeTeam: Team +/// @Parent(key: "away_team_id") var awayTeam: Team +/// init() {} +/// } +/// final class HomeTeam: ModelAlias { static let name = "home_teams" ; let model = Team() } +/// final class AwayTeam: ModelAlias { static let name = "away_teams" ; let model = Team() } +/// +/// for match in try await Match.query(on: self.database) +/// .join(HomeTeam.self, on: \Match.$homeTeam.$id == \HomeTeam.$id) +/// .join(AwayTeam.self, on: \Match.$awayTeam.$id == \AwayTeam.$id) +/// .all() +/// { +/// self.database.logger.debug("home: \(try match.joined(HomeTeam.self))") +/// self.database.logger.debug("away: \(try match.joined(AwayTeam.self))") +/// } +/// ```` @dynamicMemberLookup -public protocol ModelAlias: _ModelAlias { - subscript( - dynamicMember keyPath: KeyPath - ) -> AliasedField - where Field.Model == Model - { get } - - subscript( - dynamicMember keyPath: KeyPath - ) -> Value { get } -} - -// https://bugs.swift.org/browse/SR-12256 -public protocol _ModelAlias: Schema { +public protocol ModelAlias: Schema { + /// The model type to be aliased. associatedtype Model: FluentKit.Model + + /// The actual alias name to be used in place of `Model.schema`. static var name: String { get } + + /// An instance of the orignal model type. Holds returned data from lookups, and + /// is used as a data source for CRUD operations. + /// + /// When applying an alias to data that will be returned from a query, set this + /// property to `Model.init()` in the alias type's initializer. + /// + /// When applying an alias to data that will updated or removed, or for creation + /// from pre-filled data, set this property to an existing model object. var model: Model { get } + + /// `@dynamicMemberLookup` support. The implementation of this subscript is provided + /// automatically and should not be overriden by conforming types. See + /// ``ModelAlias/subscript(dynamicMember:)-8hc9u`` for details. + subscript(dynamicMember keyPath: KeyPath) -> AliasedField + where Field.Model == Self.Model + { get } + + /// `@dynamicMemberLookup` support. The implementation of this subscript is provided + /// automatically and should not be overriden by conforming types. See + /// ``ModelAlias/subscript(dynamicMember:)-9fej6`` for details. + subscript(dynamicMember keyPath: KeyPath) -> Value { get } } -extension _ModelAlias { - public static var schema: String { - Model.schema - } +extension ModelAlias { + /// An alias's ``space`` is always that of the original Model. + public static var space: String? { Model.space } + + /// An alias's ``schema`` is always that of the original Model. This is the full, unaliased + /// schema name , and must remain so even for the aliased model type in order to correctly + /// specify for the database driver what identifier the alias applies to. + public static var schema: String { Model.schema } + /// The aliased name, which stands in for the actual schema name. ``ModelAlias`` strictly + /// enforces the same constraint most dialects of SQL do for aliasing syntax: That the + /// original schema name may only appear at points where aliasing is declared, and becomes + /// syntactically incorrect usage should it appear in any other part of the query. In effect, + /// the alias becomes the only name by which the model type may be referenced. public static var alias: String? { self.name } -} -extension ModelAlias { - public subscript( - dynamicMember keyPath: KeyPath - ) -> AliasedField - where Field.Model == Model + /// An `@dynamicMemberLookup` subscript which access to the projected values of individual + /// properties of `self.model` without having to actually add `.model` to each usage. The + /// ``AliasedField`` helper type further ensures that the alias propagates correctly through + /// further helpers and subsysems, most particularly `.with()` closures in a query for eager- + /// loading. + /// + /// The presence of ``subscript(dynamicMember:)-19xu5`` and this subscript together enables + /// nearly transparent use of a ``ModelAlias`` type as if it were the underlying ``Model`` type. + /// + /// Example: + /// + /// ```swift + /// let alias = HomeTeam() + /// print(alias.$id.exists) // false + /// ``` + public subscript(dynamicMember keyPath: KeyPath) -> AliasedField + where Field.Model == Self.Model { .init(field: self.model[keyPath: keyPath]) } - public subscript( - dynamicMember keyPath: KeyPath - ) -> Value { + /// An `@dynamicMemberLookup` subscript which enables direct access to the values of individual + /// properties of `self.model` without having to actually add `.model` to each usage. + /// + /// The presence of ``subscript(dynamicMember:)-64vdz`` and this subscript together enables + /// nearly transparent use of a ``ModelAlias`` type as if it were the underlying ``Model`` type. + /// + /// Example: + /// + /// ```swift + /// let alias = HomeTeam() + /// print(alias.name) // fatalError("Cannot access field before it is initialized or fetched: name") + /// ``` + public subscript(dynamicMember keyPath: KeyPath) -> Value { self.model[keyPath: keyPath] } - public var properties: [AnyProperty] { - self.model.properties - } + /// A passthrough to ``Fields/properties-7z9l1``, as invoked on `self.model`. This is a deliberate shadowing + /// override of ``Fields/properties-7z9l1`` for the alias type itself, required to allow projected property + /// values (i.e. instances of ``AliasedField``) to correctly behave as the properties they provide + /// automatic access to. Without this override, the "parent" implementation would always return an empty + /// array, as the alias type does not itself make direct use of any of the property wrapper types. + public var properties: [AnyProperty] { self.model.properties } } +/// Provides support for `@dynamicMemberLookup` to continue descending through arbitrary +/// levels of nested projected properties values. @dynamicMemberLookup public final class AliasedField - where Alias: ModelAlias, Field: Property + where Alias: ModelAlias, Field: Property, Alias.Model == Field.Model { public let field: Field - init(field: Field) { - self.field = field - } - public subscript( - dynamicMember keyPath: KeyPath - ) -> AliasedField { + fileprivate init(field: Field) { self.field = field } + + public subscript(dynamicMember keyPath: KeyPath) -> AliasedField { .init(field: self.field[keyPath: keyPath]) } } +/// Forwarded ``AnyProperty`` conformance for ``AliasedField``. +extension AliasedField: AnyProperty { + public static var anyValueType: Any.Type { Field.anyValueType } + public var anyValue: Any? { self.field.anyValue } +} + +/// Forwarded ``Property`` conformance for ``AliasedField``. extension AliasedField: Property { + /// N.B.: The definition of the aliased field's ``Model`` as the alias rather than the original ``Model`` is + /// the core purpose of ``AliasedField`` and of chained projected values; without this redefinition, the + /// `.joined(...)` helper would not work correctly for aliases. public typealias Model = Alias public typealias Value = Field.Value public var value: Field.Value? { - get { - self.field.value - } - set { - self.field.value = newValue - } + get { self.field.value } + set { self.field.value = newValue } } } +/// Conditionally forwarded ``AnyQueryableProperty`` conformance for ``AliasedField``. extension AliasedField: AnyQueryableProperty where Field: AnyQueryableProperty { - public var path: [FieldKey] { - self.field.path - } + public func queryableValue() -> DatabaseQuery.Value? { self.field.queryableValue() } + public var path: [FieldKey] { self.field.path } } +/// Conditionally forwarded ``QueryableProperty`` confromance for ``AliasedField``. extension AliasedField: QueryableProperty where Field: QueryableProperty { - public static func queryValue(_ value: Field.Value) -> DatabaseQuery.Value { - Field.queryValue(value) - } + public static func queryValue(_ value: Field.Value) -> DatabaseQuery.Value { Field.queryValue(value) } } + +/// Conditionally forwarded ``AnyQueryAddressableProperty`` conformance for ``AliasedField``. +extension AliasedField: AnyQueryAddressableProperty where Field: AnyQueryAddressableProperty { + public var queryablePath: [FieldKey] { self.field.queryablePath } + public var anyQueryableProperty: AnyQueryableProperty { self.field.anyQueryableProperty } +} + +/// Conditionally forwarded ``QueryAddressableProperty`` conformance for ``AliasedField``. +/// +/// N.B.: It might seem at a glance as if this conformance could be used to propagate the model +/// alias rather than requiring all this tedious boilerpolate - however, this perception is +/// misleading. A query-addressable property is either also a queryable property (in which case +/// it must always address itself) _or_ addresses a single queryable property which shall be +/// substituted in its place at all points of usage - there is by design nothing which could +/// carry the propagated alias. +extension AliasedField: QueryAddressableProperty where Field: QueryAddressableProperty { + public var queryableProperty: Field.QueryablePropertyType { self.field.queryableProperty } +} + +/// N.B. Forwarding ``AnyCodableProperty`` conformance for ``AliasedField`` would be ineffective, and +/// also wrong even if it did function. Encoding and decoding of properties (and by extension the +/// ``Fields`` which contain them) uses the Swift names of the properties as coding keys, not database +/// names; the namespacing a model alias introduces does not apply to there. + +/// N.B. In the same vein, forwarding ``AnyDatabaseProperty`` conformance would also be undesirable. +/// The correct application of aliases to schemas during `input()` and `output()` operations is already +/// handled explcitly, and with correct handling of nesting, long before the individual property +/// conformances come into play. Which means that trying to account for the alias at this level would +/// end up mangling the correct fully-qualified property identifier quite badly. diff --git a/Sources/FluentKit/Model/Schema.swift b/Sources/FluentKit/Model/Schema.swift index 6d611a92..52c06670 100644 --- a/Sources/FluentKit/Model/Schema.swift +++ b/Sources/FluentKit/Model/Schema.swift @@ -1,10 +1,17 @@ public protocol Schema: Fields { + static var space: String? { get } static var schema: String { get } static var alias: String? { get } } extension Schema { + public static var space: String? { nil } + public static var schemaOrAlias: String { self.alias ?? self.schema } + + static var spaceIfNotAliased: String? { + return self.alias == nil ? self.space : nil + } } diff --git a/Sources/FluentKit/Operators/FieldOperators.swift b/Sources/FluentKit/Operators/FieldOperators.swift index abc01870..69793d99 100644 --- a/Sources/FluentKit/Operators/FieldOperators.swift +++ b/Sources/FluentKit/Operators/FieldOperators.swift @@ -2,9 +2,9 @@ extension QueryBuilder { @discardableResult public func filter(_ filter: ModelFieldFilter) -> Self { self.filter( - .path(filter.lhsPath, schema: Model.schema), + .extendedPath(filter.lhsPath, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), filter.method, - .path(filter.rhsPath, schema: Model.schema) + .extendedPath(filter.rhsPath, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased) ) } @@ -13,9 +13,9 @@ extension QueryBuilder { where Left: Schema, Right: Schema { self.filter( - .path(filter.lhsPath, schema: Left.schemaOrAlias), + .extendedPath(filter.lhsPath, schema: Left.schemaOrAlias, space: Left.spaceIfNotAliased), filter.method, - .path(filter.rhsPath, schema: Right.schemaOrAlias) + .extendedPath(filter.rhsPath, schema: Right.schemaOrAlias, space: Right.spaceIfNotAliased) ) } } @@ -24,9 +24,9 @@ public func == ( lhs: KeyPath, rhs: KeyPath ) -> ModelFieldFilter - where LeftField.Model == Left, + where Left: Schema, LeftField: QueryableProperty, - RightField.Model == Right, + Right: Schema, RightField: QueryableProperty { .init(lhs, .equal, rhs) @@ -36,9 +36,9 @@ public func != ( lhs: KeyPath, rhs: KeyPath ) -> ModelFieldFilter - where LeftField.Model == Left, + where Left: Schema, LeftField: QueryableProperty, - RightField.Model == Right, + Right: Schema, RightField: QueryableProperty { .init(lhs, .notEqual, rhs) @@ -48,9 +48,9 @@ public func >= ( lhs: KeyPath, rhs: KeyPath ) -> ModelFieldFilter - where LeftField.Model == Left, + where Left: Schema, LeftField: QueryableProperty, - RightField.Model == Right, + Right: Schema, RightField: QueryableProperty { .init(lhs, .greaterThanOrEqual, rhs) @@ -60,9 +60,9 @@ public func > ( lhs: KeyPath, rhs: KeyPath ) -> ModelFieldFilter - where LeftField.Model == Left, + where Left: Schema, LeftField: QueryableProperty, - RightField.Model == Right, + Right: Schema, RightField: QueryableProperty { .init(lhs, .greaterThan, rhs) @@ -72,9 +72,9 @@ public func < ( lhs: KeyPath, rhs: KeyPath ) -> ModelFieldFilter - where LeftField.Model == Left, + where Left: Schema, LeftField: QueryableProperty, - RightField.Model == Right, + Right: Schema, RightField: QueryableProperty { .init(lhs, .lessThan, rhs) @@ -177,7 +177,7 @@ public func !=~ ( } public struct ModelFieldFilter - where Left: FluentKit.Model, Right: FluentKit.Model + where Left: FluentKit.Schema, Right: FluentKit.Schema { public init( _ lhs: KeyPath, diff --git a/Sources/FluentKit/Operators/ValueOperators+Array.swift b/Sources/FluentKit/Operators/ValueOperators+Array.swift index e26c2ef1..09f26807 100644 --- a/Sources/FluentKit/Operators/ValueOperators+Array.swift +++ b/Sources/FluentKit/Operators/ValueOperators+Array.swift @@ -1,7 +1,7 @@ // MARK: Field.Value public func ~~ (lhs: KeyPath, rhs: Values) -> ModelValueFilter - where Model: FluentKit.Model, + where Model: FluentKit.Schema, Field: QueryableProperty, Values: Collection, Values.Element == Field.Value @@ -10,18 +10,18 @@ public func ~~ (lhs: KeyPath, rhs: Values) - } public func ~~ (lhs: KeyPath, rhs: Values) -> ModelValueFilter - where Model: FluentKit.Model, + where Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped: Codable, Values: Collection, Values.Element == Field.Value.Wrapped { - lhs ~~ .array(rhs.map { .bind($0) }) + lhs ~~ .array(rhs.map { Field.queryValue(.init($0)) }) } public func !~ (lhs: KeyPath, rhs: Values) -> ModelValueFilter - where Model: FluentKit.Model, + where Model: FluentKit.Schema, Field: QueryableProperty, Values: Collection, Values.Element == Field.Value @@ -30,26 +30,26 @@ public func !~ (lhs: KeyPath, rhs: Values) - } public func !~ (lhs: KeyPath, rhs: Values) -> ModelValueFilter - where Model: FluentKit.Model, + where Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped: Codable, Values: Collection, Values.Element == Field.Value.Wrapped { - lhs !~ .array(rhs.map { .bind($0) }) + lhs !~ .array(rhs.map { Field.queryValue(.init($0)) }) } // MARK: DatabaseQuery.Value public func ~~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter - where Model: FluentKit.Model, Field: QueryableProperty + where Model: FluentKit.Schema, Field: QueryableProperty { .init(lhs, .subset(inverse: false), rhs) } public func !~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter - where Model: FluentKit.Model, Field: QueryableProperty + where Model: FluentKit.Schema, Field: QueryableProperty { .init(lhs, .subset(inverse: true), rhs) } diff --git a/Sources/FluentKit/Operators/ValueOperators+String.swift b/Sources/FluentKit/Operators/ValueOperators+String.swift index 7a3df9d8..01864534 100644 --- a/Sources/FluentKit/Operators/ValueOperators+String.swift +++ b/Sources/FluentKit/Operators/ValueOperators+String.swift @@ -2,7 +2,7 @@ public func ~= (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value == String { @@ -11,7 +11,7 @@ public func ~= (lhs: KeyPath, rhs: String) -> ModelV public func ~= (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped == String @@ -21,7 +21,7 @@ public func ~= (lhs: KeyPath, rhs: String) -> ModelV public func ~~ (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value == String { @@ -30,7 +30,7 @@ public func ~~ (lhs: KeyPath, rhs: String) -> ModelV public func ~~ (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped == String @@ -40,7 +40,7 @@ public func ~~ (lhs: KeyPath, rhs: String) -> ModelV public func =~ (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value == String { @@ -49,7 +49,7 @@ public func =~ (lhs: KeyPath, rhs: String) -> ModelV public func =~ (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped == String @@ -59,7 +59,7 @@ public func =~ (lhs: KeyPath, rhs: String) -> ModelV public func !~= (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value == String { @@ -68,7 +68,7 @@ public func !~= (lhs: KeyPath, rhs: String) -> Model public func !~= (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped == String @@ -78,7 +78,7 @@ public func !~= (lhs: KeyPath, rhs: String) -> Model public func !~ (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value == String { @@ -87,7 +87,7 @@ public func !~ (lhs: KeyPath, rhs: String) -> ModelV public func !~ (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped == String @@ -97,7 +97,7 @@ public func !~ (lhs: KeyPath, rhs: String) -> ModelV public func !=~ (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value == String { @@ -106,7 +106,7 @@ public func !=~ (lhs: KeyPath, rhs: String) -> Model public func !=~ (lhs: KeyPath, rhs: String) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped == String @@ -118,7 +118,7 @@ public func !=~ (lhs: KeyPath, rhs: String) -> Model public func ~= (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: CustomStringConvertible { @@ -127,7 +127,7 @@ public func ~= (lhs: KeyPath, rhs: DatabaseQuery.Val public func ~= (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped: CustomStringConvertible @@ -137,7 +137,7 @@ public func ~= (lhs: KeyPath, rhs: DatabaseQuery.Val public func ~~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: CustomStringConvertible { @@ -146,7 +146,7 @@ public func ~~ (lhs: KeyPath, rhs: DatabaseQuery.Val public func ~~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped: CustomStringConvertible @@ -156,7 +156,7 @@ public func ~~ (lhs: KeyPath, rhs: DatabaseQuery.Val public func =~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: CustomStringConvertible { @@ -165,7 +165,7 @@ public func =~ (lhs: KeyPath, rhs: DatabaseQuery.Val public func =~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped: CustomStringConvertible @@ -175,7 +175,7 @@ public func =~ (lhs: KeyPath, rhs: DatabaseQuery.Val public func !~= (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: CustomStringConvertible { @@ -184,7 +184,7 @@ public func !~= (lhs: KeyPath, rhs: DatabaseQuery.Va public func !~= (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped: CustomStringConvertible @@ -194,7 +194,7 @@ public func !~= (lhs: KeyPath, rhs: DatabaseQuery.Va public func !~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: CustomStringConvertible { @@ -203,7 +203,7 @@ public func !~ (lhs: KeyPath, rhs: DatabaseQuery.Val public func !~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped: CustomStringConvertible @@ -213,7 +213,7 @@ public func !~ (lhs: KeyPath, rhs: DatabaseQuery.Val public func !=~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: CustomStringConvertible { @@ -222,7 +222,7 @@ public func !=~ (lhs: KeyPath, rhs: DatabaseQuery.Va public func !=~ (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter where - Model: FluentKit.Model, + Model: FluentKit.Schema, Field: QueryableProperty, Field.Value: OptionalType, Field.Value.Wrapped: CustomStringConvertible diff --git a/Sources/FluentKit/Operators/ValueOperators.swift b/Sources/FluentKit/Operators/ValueOperators.swift index 0c23029e..e7339142 100644 --- a/Sources/FluentKit/Operators/ValueOperators.swift +++ b/Sources/FluentKit/Operators/ValueOperators.swift @@ -12,11 +12,36 @@ extension QueryBuilder { where Joined: Schema { self.filter( - .path(filter.path, schema: Joined.schemaOrAlias), + .extendedPath(filter.path, schema: Joined.schemaOrAlias, space: Joined.spaceIfNotAliased), filter.method, filter.value ) } + + @discardableResult + public func filter(_ filter: ModelCompositeIDFilter) -> Self + where Model.IDValue: Fields + { + self.filter(Model.self, filter) + } + + @discardableResult + public func filter( + _ schema: Joined.Type, + _ filter: ModelCompositeIDFilter + ) -> Self + where Joined: Schema, Joined.IDValue: Fields + { + let relation: DatabaseQuery.Filter.Relation + let inverted: Bool + switch filter.method { + case .equality(false): (relation, inverted) = (.and, false) + case .equality(true): (relation, inverted) = (.or, true) + default: fatalError("unreachable") + } + + return self.group(relation) { filter.value.input(to: QueryFilterInput(builder: $0, inverted: inverted)) } + } } // MARK: Field.Value @@ -57,6 +82,16 @@ public func <= (lhs: KeyPath, rhs: Field.Value) -> M lhs <= Field.queryValue(rhs) } +// MARK: CompositeID.Value + +public func == (lhs: KeyPath>, rhs: Model.IDValue) -> ModelCompositeIDFilter { + .init(.equal, rhs) +} + +public func != (lhs: KeyPath>, rhs: Model.IDValue) -> ModelCompositeIDFilter { + .init(.notEqual, rhs) +} + // MARK: DatabaseQuery.Value public func == (lhs: KeyPath, rhs: DatabaseQuery.Value) -> ModelValueFilter @@ -112,3 +147,18 @@ public struct ModelValueFilter where Model: Fields { let method: DatabaseQuery.Filter.Method let value: DatabaseQuery.Value } + +public struct ModelCompositeIDFilter where Model: FluentKit.Model, Model.IDValue: Fields { + public init( + _ method: DatabaseQuery.Filter.Method, + _ rhs: Model.IDValue + ) { + guard case .equality(_) = method else { preconditionFailure("Composite IDs may only be compared for equality or inequality.") } + + self.method = method + self.value = rhs + } + + let method: DatabaseQuery.Filter.Method + let value: Model.IDValue +} diff --git a/Sources/FluentKit/Properties/Boolean.swift b/Sources/FluentKit/Properties/Boolean.swift new file mode 100644 index 00000000..d6df05dc --- /dev/null +++ b/Sources/FluentKit/Properties/Boolean.swift @@ -0,0 +1,129 @@ +extension Fields { + public typealias Boolean = BooleanProperty + where Format: BooleanPropertyFormat +} + +/// A Fluent model property which represents a boolean (true/false) value. +/// +/// By default, `Bool` properties are stored in a database using the storage format +/// defined by the database driver, which corresponds to using the `.bool` data type +/// on the appropriate field in a migration. This property wrapper allows specifying +/// an alternative storage format - such the strings "true" and "false" - which is +/// automatically translated to and from a Swift `Bool` when loading and saving the +/// owning model. This is expected to be most useful when working with existing +/// database schemas. +/// +/// Example: +/// +/// final class MyModel: Model { +/// let schema = "my_models" +/// +/// @ID(key: .id) var id: UUID? +/// +/// // This field will be stored using the database's native boolean format. +/// @Field(key: "rawEnabled") var rawEnabled: Bool +/// +/// // This field will be stored as a string, either "true" or "false". +/// @Boolean(key: "enabled", format: .trueFalse) var enabled: Bool +/// +/// init() {} +/// } +/// +/// struct MyModelMigration: AsyncMigration { +/// func prepare(on database: Database) async throws -> Void { +/// try await database.schema(MyModel.schema) +/// .id() +/// .field("rawEnabled", .bool, .required) +/// .field("enabled", .string, .required) +/// .create() +/// } +/// +/// func revert(on database: Database) async throws -> Void { try await database.schema(MyModel.schema).delete() } +/// } +/// +/// - Note: See also ``OptionalBooleanProperty`` and ``BooleanPropertyFormat``. +@propertyWrapper +public final class BooleanProperty + where Model: FluentKit.Fields, Format: BooleanPropertyFormat +{ + @FieldProperty + public var field: Format.Value + public let format: Format + + public var projectedValue: BooleanProperty { self } + + public var wrappedValue: Bool { + get { + guard let value = self.value else { + fatalError("Cannot access bool field before it is initialized or fetched: \(self.$field.key)") + } + return value + } + set { self.value = newValue } + } + + public init(key: FieldKey, format: Format) { + self._field = .init(key: key) + self.format = format + } +} + +extension BooleanProperty where Format == DefaultBooleanPropertyFormat { + public convenience init(key: FieldKey) { + self.init(key: key, format: .default) + } +} + +/// This is a workaround for Swift 5.4's inability to correctly infer the format type +/// using the `Self` constraints on the various static properties. +extension BooleanProperty { + public convenience init(key: FieldKey, format factory: BooleanPropertyFormatFactory) { + self.init(key: key, format: factory.format) + } +} + +extension BooleanProperty: AnyProperty {} + +extension BooleanProperty: Property { + public var value: Bool? { + get { self.$field.value.map { self.format.parse($0)! } } + set { self.$field.value = newValue.map { self.format.serialize($0) } } + } +} + +extension BooleanProperty: AnyQueryableProperty { + public var path: [FieldKey] { self.$field.path } +} + +extension BooleanProperty: QueryableProperty { + public static func queryValue(_ value: Bool) -> DatabaseQuery.Value { + .bind(Format.init().serialize(value)) + } +} + +extension BooleanProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self } + public var queryablePath: [FieldKey] { self.path } +} + +extension BooleanProperty: QueryAddressableProperty { + public var queryableProperty: BooleanProperty { self } +} + +extension BooleanProperty: AnyDatabaseProperty { + public var keys: [FieldKey] { self.$field.keys } + public func input(to input: DatabaseInput) { self.$field.input(to: input) } + public func output(from output: DatabaseOutput) throws { try self.$field.output(from: output) } +} + +extension BooleanProperty: AnyCodableProperty { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } + + public func decode(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.value = try container.decode(Value.self) + } +} diff --git a/Sources/FluentKit/Properties/BooleanPropertyFormat.swift b/Sources/FluentKit/Properties/BooleanPropertyFormat.swift new file mode 100644 index 00000000..b2131752 --- /dev/null +++ b/Sources/FluentKit/Properties/BooleanPropertyFormat.swift @@ -0,0 +1,189 @@ +/// A conversion between `Bool` and an arbitrary alternative storage format, usually a string. +public protocol BooleanPropertyFormat { + associatedtype Value: Codable + + init() + + func parse(_ value: Value) -> Bool? + func serialize(_ bool: Bool) -> Value +} + +/// Represent a `Bool` natively, using the database's underlying support (if any). This is the default. +public struct DefaultBooleanPropertyFormat: BooleanPropertyFormat { + public init() {} + + public func parse(_ value: Bool) -> Bool? { + return value + } + + public func serialize(_ bool: Bool) -> Bool { + return bool + } +} + +extension BooleanPropertyFormat where Self == DefaultBooleanPropertyFormat { + public static var `default`: Self { .init() } +} + +/// Represent a `Bool` as any integer type. Any value other than `0` or `1` is considered invalid. +/// +/// - Note: This format is primarily useful when the underlying database's native boolean format is +/// an integer of different width than the one that was used by the model - for example, a MySQL +/// model with a `BIGINT` field instead of the default `TINYINT`. +public struct IntegerBooleanPropertyFormat: BooleanPropertyFormat { + public init() {} + + public func parse(_ value: T) -> Bool? { + switch value { + case .zero: return false + case .zero.advanced(by: 1): return true + default: return nil + } + } + + public func serialize(_ bool: Bool) -> T { + return .zero.advanced(by: bool ? 1 : 0) + } +} + +extension BooleanPropertyFormat where Self == IntegerBooleanPropertyFormat { + public static var integer: Self { .init() } +} + +/// Represent a `Bool` as the strings "0" and "1". Any other value is considered invalid. +public struct OneZeroBooleanPropertyFormat: BooleanPropertyFormat { + public init() {} + + public func parse(_ value: String) -> Bool? { + switch value { + case "0": return false + case "1": return true + default: return nil + } + } + + public func serialize(_ bool: Bool) -> String { + return bool ? "1" : "0" + } +} + +extension BooleanPropertyFormat where Self == OneZeroBooleanPropertyFormat { + public static var oneZero: Self { .init() } +} + +/// Represent a `Bool` as the strings "N" and "Y". Parsing is case-insensitive. Serialization always stores uppercase. +public struct YNBooleanPropertyFormat: BooleanPropertyFormat { + public init() {} + + public func parse(_ value: String) -> Bool? { + switch value.lowercased() { + case "n": return false + case "y": return true + default: return nil + } + } + + public func serialize(_ bool: Bool) -> String { + return bool ? "Y" : "N" + } +} + +extension BooleanPropertyFormat where Self == YNBooleanPropertyFormat { + public static var yn: Self { .init() } +} + + +/// Represent a `Bool` as the strings "NO" and "YES". Parsing is case-insensitive. Serialization always stores uppercase. +public struct YesNoBooleanPropertyFormat: BooleanPropertyFormat { + public init() {} + + public func parse(_ value: String) -> Bool? { + switch value.lowercased() { + case "no": return false + case "yes": return true + default: return nil + } + } + + public func serialize(_ bool: Bool) -> String { + return bool ? "YES" : "NO" + } +} + +extension BooleanPropertyFormat where Self == YesNoBooleanPropertyFormat { + public static var yesNo: Self { .init() } +} + +/// Represent a `Bool` as the strings "OFF" and "ON". Parsing is case-insensitive. Serialization always stores uppercase. +public struct OnOffBooleanPropertyFormat: BooleanPropertyFormat { + public init() {} + + public func parse(_ value: String) -> Bool? { + switch value.lowercased() { + case "off": return false + case "on": return true + default: return nil + } + } + + public func serialize(_ bool: Bool) -> String { + return bool ? "ON" : "OFF" + } +} + +extension BooleanPropertyFormat where Self == OnOffBooleanPropertyFormat { + public static var onOff: Self { .init() } +} + +/// Represent a `Bool` as the strings "false" and "true". Parsing is case-insensitive. Serialization always stores lowercase. +public struct TrueFalseBooleanPropertyFormat: BooleanPropertyFormat { + public init() {} + + public func parse(_ value: String) -> Bool? { + switch value.lowercased() { + case "false": return false + case "true": return true + default: return nil + } + } + + public func serialize(_ bool: Bool) -> String { + return bool ? "true" : "false" + } +} + +extension BooleanPropertyFormat where Self == TrueFalseBooleanPropertyFormat { + public static var trueFalse: Self { .init() } +} + +/// This is a workaround for Swift 5.4's inability to correctly infer the format type +/// using the `Self` constraints on the various static properties. +public struct BooleanPropertyFormatFactory { + public var format: Format +} + +extension BooleanPropertyFormatFactory { + public static var integer: BooleanPropertyFormatFactory> { + .init(format: .init()) + } + + public static var oneZero: BooleanPropertyFormatFactory { + .init(format: .init()) + } + + public static var yn: BooleanPropertyFormatFactory { + .init(format: .init()) + } + + public static var yesNo: BooleanPropertyFormatFactory { + .init(format: .init()) + } + + public static var onOff: BooleanPropertyFormatFactory { + .init(format: .init()) + } + + public static var trueFalse: BooleanPropertyFormatFactory { + .init(format: .init()) + } +} diff --git a/Sources/FluentKit/Properties/Children.swift b/Sources/FluentKit/Properties/Children.swift index 5fae815d..58fb0df1 100644 --- a/Sources/FluentKit/Properties/Children.swift +++ b/Sources/FluentKit/Properties/Children.swift @@ -1,3 +1,5 @@ +import NIOCore + extension Model { public typealias Children = ChildrenProperty where To: FluentKit.Model @@ -9,33 +11,34 @@ extension Model { public final class ChildrenProperty where From: Model, To: Model { - public enum Key { - case required(KeyPath>) - case optional(KeyPath>) - } + public typealias Key = RelationParentKey public let parentKey: Key var idValue: From.IDValue? public var value: [To]? - public init(for parent: KeyPath>) { - self.parentKey = .required(parent) + public convenience init(for parent: KeyPath>) { + self.init(for: .required(parent)) } - public init(for optionalParent: KeyPath>) { - self.parentKey = .optional(optionalParent) + public convenience init(for optionalParent: KeyPath>) { + self.init(for: .optional(optionalParent)) + } + + private init(for parentKey: Key) { + self.parentKey = parentKey } public var wrappedValue: [To] { get { guard let value = self.value else { - fatalError("Children relation not eager loaded, use $ prefix to access: \(name)") + fatalError("Children relation not eager loaded, use $ prefix to access: \(self.name)") } return value } set { - fatalError("Children relation is get-only.") + fatalError("Children relation \(self.name) is get-only.") } } @@ -44,12 +47,13 @@ public final class ChildrenProperty } public var fromId: From.IDValue? { - return self.idValue + get { return self.idValue } + set { self.idValue = newValue } } public func query(on database: Database) -> QueryBuilder { guard let id = self.idValue else { - fatalError("Cannot query children relation from unsaved model.") + fatalError("Cannot query children relation \(self.name) from unsaved model.") } let builder = To.query(on: database) switch self.parentKey { @@ -63,7 +67,7 @@ public final class ChildrenProperty public func create(_ to: [To], on database: Database) -> EventLoopFuture { guard let id = self.idValue else { - fatalError("Cannot save child to unsaved model.") + fatalError("Cannot save child in relation \(self.name) to unsaved model.") } to.forEach { switch self.parentKey { @@ -78,7 +82,7 @@ public final class ChildrenProperty public func create(_ to: To, on database: Database) -> EventLoopFuture { guard let id = self.idValue else { - fatalError("Cannot save child to unsaved model.") + fatalError("Cannot save child in relation \(self.name) to unsaved model.") } switch self.parentKey { case .required(let keyPath): @@ -137,6 +141,10 @@ extension ChildrenProperty: AnyCodableProperty { public func decode(from decoder: Decoder) throws { // don't decode } + + public var skipPropertyEncoding: Bool { + self.value == nil // Avoids leaving an empty JSON object lying around in some cases. + } } // MARK: Relation @@ -153,27 +161,26 @@ extension ChildrenProperty: Relation { } } -extension ChildrenProperty.Key: CustomStringConvertible { - public var description: String { - switch self { - case .optional(let keyPath): - return To.path(for: keyPath.appending(path: \.$id)).description - case .required(let keyPath): - return To.path(for: keyPath.appending(path: \.$id)).description - } - } -} - // MARK: Eager Loadable extension ChildrenProperty: EagerLoadable { + public static func eagerLoad( + _ relationKey: KeyPath>, + to builder: Builder + ) + where Builder : EagerLoadBuilder, From == Builder.Model + { + self.eagerLoad(relationKey, withDeleted: false, to: builder) + } + public static func eagerLoad( _ relationKey: KeyPath>, + withDeleted: Bool, to builder: Builder ) where Builder: EagerLoadBuilder, Builder.Model == From { - let loader = ChildrenEagerLoader(relationKey: relationKey) + let loader = ChildrenEagerLoader(relationKey: relationKey, withDeleted: withDeleted) builder.add(loader: loader) } @@ -197,7 +204,8 @@ private struct ChildrenEagerLoader: EagerLoader where From: Model, To: Model { let relationKey: KeyPath> - + let withDeleted: Bool + func run(models: [From], on database: Database) -> EventLoopFuture { let ids = models.map { $0.id! } @@ -209,6 +217,9 @@ private struct ChildrenEagerLoader: EagerLoader case .required(let required): builder.filter(required.appending(path: \.$id) ~~ Set(ids)) } + if (self.withDeleted) { + builder.withDeleted() + } return builder.all().map { for model in models { let id = model[keyPath: self.relationKey].idValue! diff --git a/Sources/FluentKit/Properties/CompositeChildren.swift b/Sources/FluentKit/Properties/CompositeChildren.swift new file mode 100644 index 00000000..7a606ad7 --- /dev/null +++ b/Sources/FluentKit/Properties/CompositeChildren.swift @@ -0,0 +1,222 @@ +import NIOCore + +extension Model { + /// A convenience alias for ``CompositeChildrenProperty``. It is strongly recommended that callers use this + /// alias rather than referencing ``CompositeChildrenProperty`` directly whenever possible. + public typealias CompositeChildren = CompositeChildrenProperty + where To: FluentKit.Model, Self.IDValue: Fields +} + +/// Declares a many-to-one relation between the referenced ("child") model and the referencing ("parent") model, +/// where the parent model specifies its ID with ``CompositeIDProperty``. +/// +/// ``CompositeChildrenProperty`` serves the same purpose for child models with parents which use `@CompositeID` +/// that ``ChildrenProperty`` serves for parent models which use `@ID`. +/// +/// Unfortunately, while the type of ID used by the child model makes no difference, limitations of Swift's +/// generics syntax make it impractical to support both `@ID`-using and `@CompositeID`-using models as the parent +/// model with a single property type. +/// +/// ``CompositeChildrenProperty`` cannot reference a ``ParentProperty`` or ``OptionalParentProperty``; use +/// ``ChildrenProperty`` instead. +/// +/// Example: +/// +/// - Note: This example is somewhat contrived; in reality, this kind of metadata would have much more +/// complex relationships. +/// +/// ``` +/// final class TableMetadata: Model { +/// static let schema = "table_metadata" +/// +/// final class IDValue: Fields, Hashable { +/// @Field(key: "table_schema") var schema: String +/// @Field(key: "table_name") var name: String +/// init() {} +/// static func ==(lhs: IDValue, rhs: IDValue) -> Bool { lhs.schema == rhs.schema && lhs.name == rhs.name } +/// func hash(into hasher: inout Hasher) { hasher.combine(self.schema); hasher.combine(self.name) } +/// } +/// +/// @CompositeID var id: IDValue? +/// @CompositeChildren(for: \.$referencedTable) var referencingForeignKeys: [ForeignKeyMetadata] +/// @CompositeChildren(for: \.$nextCrossReferencedTable) var indirectReferencingForeignKeys: [ForeignKeyMetadata] +/// // ... +/// } +/// +/// final class ForeignKeyMetadata: Model { +/// static let schema = "foreign_key_metadata" +/// +/// @ID(custom: "constraint_name") var id: String? +/// @CompositeParent(prefix: "referenced") var referencedTable: TableMetadata +/// @CompositeOptionalParent(prefix: "next_xref") var nextCrossReferencedTable: TableMetadata? +/// // ... +/// +/// struct CreateTableMigration: AsyncMigration { +/// func prepare(on database: Database) async throws { +/// try await database.schema(ForeignKeyMetadata.schema) +/// .field("constraint_name", .string, .required, .identifier(auto: false)) +/// .field("referenced_table_schema", .string, .required) +/// .field("referenced_table_name", .string, .required) +/// .foreignKey(["referenced_table_schema", "referenced_table_name"], references: TableMetadata.schema, ["table_schema", "table_name"]) +/// .field("next_xref_table_schema", .string) +/// .field("next_xref_table_name", .string) +/// .foreignKey(["next_xref_table_schema", "next_xref_table_name"], references: TableMetadata.schema, ["table_schema", "table_name"]) +/// .constraint(.sql(.check(SQLBinaryExpression( // adds a check constraint to ensure that neither field is ever NULL when the other isn't +/// left: SQLBinaryExpression(left: SQLIdentifier("next_xref_table_schema"), .is, right: SQLLiteral.null), +/// .equal, +/// right: SQLBinaryExpression(left: SQLIdentifier("next_xref_table_name"), .is, right: SQLLiteral.null) +/// )))) +/// // ... +/// .create() +/// } +/// } +/// } +/// ``` +@propertyWrapper +public final class CompositeChildrenProperty + where From: Model, To: Model, From.IDValue: Fields +{ + public typealias Key = CompositeRelationParentKey + + public let parentKey: Key + var idValue: From.IDValue? + + public var value: [To]? + + public init(for parentKey: KeyPath>) { + self.parentKey = .required(parentKey) + } + + public init(for parentKey: KeyPath>) { + self.parentKey = .optional(parentKey) + } + + public var wrappedValue: [To] { + get { + guard let value = self.value else { + fatalError("Children relation not eager loaded, use $ prefix to access: \(self.name)") + } + return value + } + set { + fatalError("Children relation \(self.name) is get-only.") + } + } + + public var projectedValue: CompositeChildrenProperty { self } + + public var fromId: From.IDValue? { + get { return self.idValue } + set { self.idValue = newValue } + } + + public func query(on database: Database) -> QueryBuilder { + guard let id = self.idValue else { + fatalError("Cannot query children relation \(self.name) from unsaved model.") + } + + /// We route the value through an instance of the child model's parent property to ensure the + /// correct prefix and strategy for this specific relation are applied to the filter keys, then + /// apply filters for each property of the ID to a query builder for the child model. See the + /// implementation of `ParentKey.queryFilterIds(_:in:)` for the implementation, and the + /// documentation for ``QueryFilterInput`` for details of how the actual filtering works. + return self.parentKey.queryFilterIds([id], in: To.query(on: database)) + } +} + +extension CompositeChildrenProperty: CustomStringConvertible { + public var description: String { self.name } +} + +extension CompositeChildrenProperty: AnyProperty { } + +extension CompositeChildrenProperty: Property { + public typealias Model = From + public typealias Value = [To] +} + +extension CompositeChildrenProperty: AnyDatabaseProperty { + public var keys: [FieldKey] { [] } + public func input(to input: DatabaseInput) {} + public func output(from output: DatabaseOutput) throws { + if From.IDValue.keys.reduce(true, { $0 && output.contains($1) }) { // don't output unless all keys are present + self.idValue = From.IDValue() + try self.idValue!.output(from: output) + } + } +} + +extension CompositeChildrenProperty: AnyCodableProperty { + public func encode(to encoder: Encoder) throws { + if let value = self.value { + var container = encoder.singleValueContainer() + try container.encode(value) + } + } + public func decode(from decoder: Decoder) throws {} + public var skipPropertyEncoding: Bool { self.value == nil } +} + +extension CompositeChildrenProperty: Relation { + public var name: String { "CompositeChildren<\(From.self), \(To.self)>(for: \(self.parentKey))" } + public func load(on database: Database) -> EventLoopFuture { self.query(on: database).all().map { self.value = $0 } } +} + +extension CompositeChildrenProperty: EagerLoadable { + public static func eagerLoad(_ relationKey: KeyPath>, to builder: Builder) + where Builder : EagerLoadBuilder, From == Builder.Model + { + self.eagerLoad(relationKey, withDeleted: false, to: builder) + } + + public static func eagerLoad(_ relationKey: KeyPath>, withDeleted: Bool, to builder: Builder) + where Builder: EagerLoadBuilder, Builder.Model == From + { + let loader = CompositeChildrenEagerLoader(relationKey: relationKey, withDeleted: withDeleted) + builder.add(loader: loader) + } + + + public static func eagerLoad(_ loader: Loader, through: KeyPath>, to builder: Builder) + where Loader: EagerLoader, Loader.Model == To, Builder: EagerLoadBuilder, Builder.Model == From + { + let loader = ThroughCompositeChildrenEagerLoader(relationKey: through, loader: loader) + builder.add(loader: loader) + } +} + +private struct CompositeChildrenEagerLoader: EagerLoader + where From: Model, To: Model, From.IDValue: Fields +{ + let relationKey: KeyPath> + let withDeleted: Bool + + func run(models: [From], on database: Database) -> EventLoopFuture { + let ids = Set(models.map(\.id!)) + let parentKey = From()[keyPath: self.relationKey].parentKey + let builder = To.query(on: database) + + builder.group(.or) { query in + _ = parentKey.queryFilterIds(ids, in: query) + } + + return builder.all().map { + let indexedResults = Dictionary(grouping: $0, by: { parentKey.referencedId(in: $0)! }) + + for model in models { + model[keyPath: self.relationKey].value = indexedResults[model[keyPath: self.relationKey].idValue!] ?? [] + } + } + } +} + +private struct ThroughCompositeChildrenEagerLoader: EagerLoader + where From: Model, From.IDValue: Fields, Loader: EagerLoader, Loader.Model == Through +{ + let relationKey: KeyPath> + let loader: Loader + + func run(models: [From], on database: Database) -> EventLoopFuture { + return self.loader.run(models: models.flatMap { $0[keyPath: self.relationKey].value! }, on: database) + } +} diff --git a/Sources/FluentKit/Properties/CompositeID.swift b/Sources/FluentKit/Properties/CompositeID.swift new file mode 100644 index 00000000..ff99e5f1 --- /dev/null +++ b/Sources/FluentKit/Properties/CompositeID.swift @@ -0,0 +1,84 @@ +extension Model { + public typealias CompositeID = CompositeIDProperty + where Value: Fields +} + +// MARK: Type + +@propertyWrapper @dynamicMemberLookup +public final class CompositeIDProperty + where Model: FluentKit.Model, Value: FluentKit.Fields +{ + public var value: Value? + public var exists: Bool + var cachedOutput: DatabaseOutput? + + public var projectedValue: CompositeIDProperty { self } + + public var wrappedValue: Value? { + get { return self.value } + set { self.value = newValue } + } + + public init() { + self.value = .init() + self.exists = false + self.cachedOutput = nil + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> Nested + where Nested: Property + { + return self.value![keyPath: keyPath] + } +} + +extension CompositeIDProperty: CustomStringConvertible { + public var description: String { + "@\(Model.self).CompositeID<\(Value.self))>()" + } +} + +// MARK: Property + +extension CompositeIDProperty: AnyProperty, Property {} + +// MARK: Database + +extension CompositeIDProperty: AnyDatabaseProperty { + public var keys: [FieldKey] { + Value.keys + } + + public func input(to input: DatabaseInput) { + self.value!.input(to: input) + } + + public func output(from output: DatabaseOutput) throws { + self.exists = true + self.cachedOutput = output + try self.value!.output(from: output) + } +} + +// MARK: Codable + +extension CompositeIDProperty: AnyCodableProperty { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.value) + } + + public func decode(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.value = try container.decode(Value?.self) + } +} + +// MARK: AnyID + +extension CompositeIDProperty: AnyID { + func generate() {} +} diff --git a/Sources/FluentKit/Properties/CompositeOptionalChild.swift b/Sources/FluentKit/Properties/CompositeOptionalChild.swift new file mode 100644 index 00000000..34c37ab8 --- /dev/null +++ b/Sources/FluentKit/Properties/CompositeOptionalChild.swift @@ -0,0 +1,209 @@ +import NIOCore + +extension Model { + /// A convenience alias for ``CompositeOptionalChildProperty``. It is strongly recommended that callers use this + /// alias rather than referencing ``CompositeOptionalChildProperty`` directly whenever possible. + public typealias CompositeOptionalChild = CompositeOptionalChildProperty + where To: FluentKit.Model, Self.IDValue: Fields +} + +/// Declares an optional one-to-one relation between the referenced ("child") model and the referencing +/// ("parent") model, where the parent model specifies its ID with ``CompositeIDProperty``. +/// +/// ``CompositeOptionalChildProperty`` serves the same purpose for child models with parents which use +/// `@CompositeID` that ``OptionalChildProperty`` serves for parent models which use `@ID`. +/// +/// Unfortunately, while the type of ID used by the child model makes no difference, limitations of Swift's +/// generics syntax make it impractical to support both `@ID`-using and `@CompositeID`-using models as the parent +/// model with a single property type. +/// +/// ``CompositeOptionalChildProperty`` cannot reference a ``ParentProperty`` or ``OptionalParentProperty``; use +/// ``OptionalChildProperty`` instead. +/// +/// Example: +/// +/// - Note: This example is somewhat contrived; in reality, this kind of metadata would have much more +/// complex relationships. +/// +/// ``` +/// final class TableMetadata: Model { +/// static let schema = "table_metadata" +/// +/// final class IDValue: Fields, Hashable { +/// @Field(key: "table_schema") var schema: String +/// @Field(key: "table_name") var name: String +/// init() {} +/// static func ==(lhs: IDValue, rhs: IDValue) -> Bool { lhs.schema == rhs.schema && lhs.name == rhs.name } +/// func hash(into hasher: inout Hasher) { hasher.combine(self.schema); hasher.combine(self.name) } +/// } +/// +/// @CompositeID var id: IDValue? +/// @CompositeParent(prefix: "meta") var metaTable: TableMetadata +/// @CompositeOptionalChild(for: \.$metaTable) var realizedTable: TableMetadata? +/// // ... +/// +/// struct CreateTableMigration: AsyncMigration { +/// func prepare(on database: Database) async throws { +/// try await database.schema(TableMetadata.schema) +/// .field("table_schema", .string, .required) +/// .field("table_name", .string, .required) +/// .compositeIdentifier(over: ["table_schema", "table_name"]) +/// .field("meta_table_schema", .string, .required) +/// .field("meta_table_name", .string, .required) +/// .foreignKey(["meta_table_schema", "meta_table_name"], references: TableMetadata.schema, ["table_schema", "table_name"]) +/// // ... +/// .create() +/// } +/// } +/// } +/// ``` +@propertyWrapper +public final class CompositeOptionalChildProperty + where From: Model, To: Model, From.IDValue: Fields +{ + public typealias Key = CompositeRelationParentKey + + public let parentKey: Key + var idValue: From.IDValue? + + public var value: To?? + + public init(for parentKey: KeyPath>) { + self.parentKey = .required(parentKey) + } + + public init(for parentKey: KeyPath>) { + self.parentKey = .optional(parentKey) + } + + public var wrappedValue: To? { + get { + guard let value = self.value else { + fatalError("Child relation not eager loaded, use $ prefix to access: \(self.name)") + } + return value + } + set { + fatalError("Child relation \(self.name) is get-only.") + } + } + + public var projectedValue: CompositeOptionalChildProperty { self } + + public var fromId: From.IDValue? { + get { return self.idValue } + set { self.idValue = newValue } + } + + public func query(on database: Database) -> QueryBuilder { + guard let id = self.idValue else { + fatalError("Cannot query child relation \(self.name) from unsaved model.") + } + + /// We route the value through an instance of the child model's parent property to ensure the + /// correct prefix and strategy for this specific relation are applied to the filter keys, then + /// apply filters for each property of the ID to a query builder for the child model. See the + /// implementation of `ParentKey.queryFilterIds(_:in:)` for the implementation, and the + /// documentation for ``QueryFilterInput`` for details of how the actual filtering works. + return self.parentKey.queryFilterIds([id], in: To.query(on: database)) + } +} + +extension CompositeOptionalChildProperty: CustomStringConvertible { + public var description: String { self.name } +} + +extension CompositeOptionalChildProperty: AnyProperty { } + +extension CompositeOptionalChildProperty: Property { + public typealias Model = From + public typealias Value = To? +} + +extension CompositeOptionalChildProperty: AnyDatabaseProperty { + public var keys: [FieldKey] { [] } + public func input(to input: DatabaseInput) {} + public func output(from output: DatabaseOutput) throws { + if From.IDValue.keys.reduce(true, { $0 && output.contains($1) }) { // don't output unless all keys are present + self.idValue = From.IDValue() + try self.idValue!.output(from: output) + } + } +} + +extension CompositeOptionalChildProperty: AnyCodableProperty { + public func encode(to encoder: Encoder) throws { + if let value = self.value { + var container = encoder.singleValueContainer() + try container.encode(value) + } + } + public func decode(from decoder: Decoder) throws {} + public var skipPropertyEncoding: Bool { self.value == nil } +} + +extension CompositeOptionalChildProperty: Relation { + public var name: String { "CompositeOptionalChild<\(From.self), \(To.self)>(for: \(self.parentKey))" } + public func load(on database: Database) -> EventLoopFuture { self.query(on: database).first().map { self.value = $0 } } +} + +extension CompositeOptionalChildProperty: EagerLoadable { + public static func eagerLoad(_ relationKey: KeyPath>, to builder: Builder) + where Builder : EagerLoadBuilder, From == Builder.Model + { + self.eagerLoad(relationKey, withDeleted: false, to: builder) + } + + public static func eagerLoad(_ relationKey: KeyPath>, withDeleted: Bool, to builder: Builder) + where Builder: EagerLoadBuilder, Builder.Model == From + { + let loader = CompositeOptionalChildEagerLoader(relationKey: relationKey, withDeleted: withDeleted) + builder.add(loader: loader) + } + + + public static func eagerLoad(_ loader: Loader, through: KeyPath>, to builder: Builder) + where Loader: EagerLoader, Loader.Model == To, Builder: EagerLoadBuilder, Builder.Model == From + { + let loader = ThroughCompositeOptionalChildEagerLoader(relationKey: through, loader: loader) + builder.add(loader: loader) + } +} + +private struct CompositeOptionalChildEagerLoader: EagerLoader + where From: Model, To: Model, From.IDValue: Fields +{ + let relationKey: KeyPath> + let withDeleted: Bool + + func run(models: [From], on database: Database) -> EventLoopFuture { + let ids = Set(models.map(\.id!)) + let parentKey = From()[keyPath: self.relationKey].parentKey + let builder = To.query(on: database) + + builder.group(.or) { query in + _ = parentKey.queryFilterIds(ids, in: query) + } + if (self.withDeleted) { + builder.withDeleted() + } + return builder.all().map { + let indexedResults = Dictionary(grouping: $0, by: { parentKey.referencedId(in: $0)! }) + + for model in models { + model[keyPath: self.relationKey].value = indexedResults[model[keyPath: self.relationKey].idValue!]?.first + } + } + } +} + +private struct ThroughCompositeOptionalChildEagerLoader: EagerLoader + where From: Model, From.IDValue: Fields, Loader: EagerLoader, Loader.Model == Through +{ + let relationKey: KeyPath> + let loader: Loader + + func run(models: [From], on database: Database) -> EventLoopFuture { + return self.loader.run(models: models.compactMap { $0[keyPath: self.relationKey].value! }, on: database) + } +} diff --git a/Sources/FluentKit/Properties/CompositeOptionalParent.swift b/Sources/FluentKit/Properties/CompositeOptionalParent.swift new file mode 100644 index 00000000..1c3845e8 --- /dev/null +++ b/Sources/FluentKit/Properties/CompositeOptionalParent.swift @@ -0,0 +1,255 @@ +import NIOCore + +extension Model { + /// A convenience alias for ``CompositeOptionalParentProperty``. It is strongly recommended that callers + /// use this alias rather than referencing ``CompositeOptionalParentProperty`` directly whenever possible. + public typealias CompositeOptionalParent = CompositeOptionalParentProperty + where To: Model, To.IDValue: Fields +} + +/// Declares an _optional_ one-to-many relation between the referenced ("parent") model and the referencing +/// ("child") model, where the parent model specifies its ID with ``CompositeIDProperty``. +/// +/// ``CompositeOptionalParentProperty`` serves the same purpose for parent models which use `@CompositeID` +/// that ``OptionalParentProperty`` serves for parent models which use `@ID`. +/// +/// Unfortunately, while the type of ID used by the child model makes no difference, limitations of Swift's +/// generics syntax make it impractical to support both `@ID`-using and `@CompositeID`-using models as the parent +/// model with a single property type. A similar limitation applies in the opposite direction for +/// ``ChildrenProperty`` and ``OptionalChildProperty``. +/// +/// Example: +/// +/// - Note: This example is somewhat contrived; in reality, this kind of metadata would have much more +/// complex relationships. +/// +/// ``` +/// final class TableMetadata: Model { +/// static let schema = "table_metadata" +/// +/// final class IDValue: Fields, Hashable { +/// @Field(key: "table_schema") var schema: String +/// @Field(key: "table_name") var name: String +/// init() {} +/// static func ==(lhs: IDValue, rhs: IDValue) -> Bool { lhs.schema == rhs.schema && lhs.name == rhs.name } +/// func hash(into hasher: inout Hasher) { hasher.combine(self.schema); hasher.combine(self.name) } +/// } +/// +/// @CompositeID var id: IDValue? +/// // ... +/// } +/// +/// final class ForeignKeyMetadata: Model { +/// static let schema = "foreign_key_metadata" +/// +/// @ID(custom: "constraint_name") var id: String? +/// @CompositeParent(prefix: "referenced") var referencedTable: TableMetadata +/// @CompositeOptionalParent(prefix: "next_xref") var nextCrossReferencedTable: TableMetadata? +/// // ... +/// +/// struct CreateTableMigration: AsyncMigration { +/// func prepare(on database: Database) async throws { +/// try await database.schema(ForeignKeyMetadata.schema) +/// .field("constraint_name", .string, .required, .identifier(auto: false)) +/// .field("referenced_table_schema", .string, .required) +/// .field("referenced_table_name", .string, .required) +/// .foreignKey(["referenced_table_schema", "referenced_table_name"], references: TableMetadata.schema, ["table_schema", "table_name"]) +/// .field("next_xref_table_schema", .string) +/// .field("next_xref_table_name", .string) +/// .foreignKey(["next_xref_table_schema", "next_xref_table_name"], references: TableMetadata.schema, ["table_schema", "table_name"]) +/// .constraint(.sql(.check(SQLBinaryExpression( // adds a check constraint to ensure that neither field is ever NULL when the other isn't +/// left: SQLBinaryExpression(left: SQLIdentifier("next_xref_table_schema"), .is, right: SQLLiteral.null), +/// .equal, +/// right: SQLBinaryExpression(left: SQLIdentifier("next_xref_table_name"), .is, right: SQLLiteral.null) +/// )))) +/// // ... +/// .create() +/// } +/// } +/// } +/// ``` +@propertyWrapper @dynamicMemberLookup +public final class CompositeOptionalParentProperty + where From: Model, To: Model, To.IDValue: Fields +{ + public let prefix: FieldKey + public let prefixingStrategy: KeyPrefixingStrategy + public var value: To?? + + var inputId: To.IDValue?? + var outputId: To.IDValue?? + public var id: To.IDValue? { + get { self.inputId ?? self.outputId ?? nil } + set { self.inputId = .some(newValue) } + } + + public var wrappedValue: To? { + get { self.value ?? nil } + set { fatalError("use $ prefix to access \(self.name)") } + } + + public var projectedValue: CompositeOptionalParentProperty { self } + + /// Configure a ``CompositeOptionalParentProperty`` with a key prefix and prefix strategy. + /// + /// - Parameters: + /// - prefix: A prefix to be applied to the key of each individual field of the referenced model's `IDValue`. + /// - strategy: The strategy to use when applying prefixes to keys. ``KeyPrefixingStrategy/snakeCase`` is + /// the default. + public init(prefix: FieldKey, strategy: KeyPrefixingStrategy = .snakeCase) { + self.prefix = prefix + self.prefixingStrategy = strategy + } + + public func query(on database: Database) -> QueryBuilder { + return To.query(on: database).group(.and) { + self.id?.input(to: QueryFilterInput(builder: $0)) ?? To.IDValue().input(to: QueryFilterInput(builder: $0).nullValueOveridden()) + } + } + + public subscript(dynamicMember keyPath: KeyPath) -> Nested? + where Nested: Property + { + self.id?[keyPath: keyPath] + } +} + +extension CompositeOptionalParentProperty: CustomStringConvertible { + public var description: String { + self.name + } +} + +extension CompositeOptionalParentProperty: Relation { + public var name: String { + "CompositeOptionalParent<\(From.self), \(To.self)>(prefix: \(self.prefix), strategy: \(self.prefixingStrategy))" + } + + public func load(on database: Database) -> EventLoopFuture { + self.query(on: database) + .first() + .map { + self.value = $0 + } + } +} + +extension CompositeOptionalParentProperty: AnyProperty {} + +extension CompositeOptionalParentProperty: Property { + public typealias Model = From + public typealias Value = To? +} + +extension CompositeOptionalParentProperty: AnyDatabaseProperty { + public var keys: [FieldKey] { + To.IDValue.keys.map { + self.prefixingStrategy.apply(prefix: self.prefix, to: $0) + } + } + + public func input(to input: DatabaseInput) { + let prefixedInput = input.prefixed(by: self.prefix, using: self.prefixingStrategy) + let id: To.IDValue? + + if input.wantsUnmodifiedKeys { id = self.id } + else if let inId = self.inputId { id = inId } + else { return } + + id?.input(to: prefixedInput) ?? To.IDValue().input(to: prefixedInput.nullValueOveridden()) + } + + public func output(from output: DatabaseOutput) throws { + if self.keys.reduce(true, { $0 && output.contains($1) }) { + self.inputId = nil + if try self.keys.reduce(true, { try $0 && output.decodeNil($1) }) { + self.outputId = .some(.none) + } else { + let id = To.IDValue() + try id.output(from: output.prefixed(by: self.prefix, using: self.prefixingStrategy)) + self.outputId = .some(.some(id)) + } + } + } +} + +extension CompositeOptionalParentProperty: AnyCodableProperty { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if case .some(.some(let value)) = self.value { + try container.encode(value) + } else { + try container.encode(["id": self.id]) + } + } + + public func decode(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: SomeCodingKey.self) + self.id = try container.decode(To.IDValue?.self, forKey: .init(stringValue: "id")) + } +} + +extension CompositeOptionalParentProperty: EagerLoadable { + public static func eagerLoad(_ relationKey: KeyPath>, to builder: Builder) + where Builder : EagerLoadBuilder, From == Builder.Model + { + self.eagerLoad(relationKey, withDeleted: false, to: builder) + } + + public static func eagerLoad(_ relationKey: KeyPath>, withDeleted: Bool, to builder: Builder) + where Builder: EagerLoadBuilder, Builder.Model == From + { + builder.add(loader: CompositeOptionalParentEagerLoader(relationKey: relationKey, withDeleted: withDeleted)) + } + + public static func eagerLoad(_ loader: Loader, through: KeyPath>, to builder: Builder) + where Loader: EagerLoader, Loader.Model == To, Builder: EagerLoadBuilder, Builder.Model == From + { + builder.add(loader: ThroughCompositeOptionalParentEagerLoader(relationKey: through, loader: loader)) + } +} + +private struct CompositeOptionalParentEagerLoader: EagerLoader + where From: Model, To: Model, To.IDValue: Fields +{ + let relationKey: KeyPath> + let withDeleted: Bool + + func run(models: [From], on database: Database) -> EventLoopFuture { + var sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) + let nilParentModels = sets.removeValue(forKey: nil) ?? [] + + let builder = To.query(on: database) + .group(.or) { _ = sets.keys.reduce($0) { query, id in query.group(.and) { id!.input(to: QueryFilterInput(builder: $0)) } } } + if (self.withDeleted) { + builder.withDeleted() + } + return builder.all().flatMapThrowing { + let parents = Dictionary(uniqueKeysWithValues: $0.map { ($0.id!, $0) }) + + for (parentId, models) in sets { + guard let parent = parents[parentId!] else { + database.logger.debug( + "Missing parent model in eager-load lookup results.", + metadata: ["parent": "\(To.self)", "id": "\(parentId!)"] + ) + throw FluentError.missingParentError(keyPath: self.relationKey, id: parentId!) + } + models.forEach { $0[keyPath: self.relationKey].value = .some(.some(parent)) } + } + nilParentModels.forEach { $0[keyPath: self.relationKey].value = .some(.none) } + } + } +} + +private struct ThroughCompositeOptionalParentEagerLoader: EagerLoader + where From: Model, Loader: EagerLoader, Loader.Model == Through, Through.IDValue: Fields +{ + let relationKey: KeyPath> + let loader: Loader + + func run(models: [From], on database: Database) -> EventLoopFuture { + self.loader.run(models: models.compactMap { $0[keyPath: self.relationKey].value! }, on: database) + } +} diff --git a/Sources/FluentKit/Properties/CompositeParent.swift b/Sources/FluentKit/Properties/CompositeParent.swift new file mode 100644 index 00000000..f0ebd7f9 --- /dev/null +++ b/Sources/FluentKit/Properties/CompositeParent.swift @@ -0,0 +1,237 @@ +import NIOCore + +extension Model { + /// A convenience alias for ``CompositeParentProperty``. It is strongly recommended that callers use this + /// alias rather than referencing ``CompositeParentProperty`` directly whenever possible. + public typealias CompositeParent = CompositeParentProperty + where To: Model, To.IDValue: Fields +} + +/// Declares a one-to-many relation between the referenced ("parent") model and the referencing ("child") model, +/// where the parent model specifies its ID with ``CompositeIDProperty``. +/// +/// ``CompositeParentProperty`` serves the same purpose for parent models which use `@CompositeID` +/// that ``ParentProperty`` serves for parent models which use `@ID`. +/// +/// Unfortunately, while the type of ID used by the child model makes no difference, limitations of Swift's +/// generics syntax make it impractical to support both `@ID`-using and `@CompositeID`-using models as the parent +/// model with a single property type. A similar limitation applies in the opposite direction for +/// ``ChildrenProperty`` and ``OptionalChildProperty``. +/// +/// Example: +/// +/// - Note: This example is somewhat contrived; in reality, this kind of metadata would have much more +/// complex relationships. +/// +/// ``` +/// final class TableMetadata: Model { +/// static let schema = "table_metadata" +/// +/// final class IDValue: Fields, Hashable { +/// @Field(key: "table_schema") var schema: String +/// @Field(key: "table_name") var name: String +/// init() {} +/// static func ==(lhs: IDValue, rhs: IDValue) -> Bool { lhs.schema == rhs.schema && lhs.name == rhs.name } +/// func hash(into hasher: inout Hasher) { hasher.combine(self.schema); hasher.combine(self.name) } +/// } +/// +/// @CompositeID var id: IDValue? +/// // ... +/// } +/// +/// final class ForeignKeyMetadata: Model { +/// static let schema = "foreign_key_metadata" +/// +/// @ID(custom: "constraint_name") var id: String? +/// @CompositeParent(prefix: "referencing") var referencingTable: TableMetadata +/// @CompositeParent(prefix: "referenced") var referencedTable: TableMetadata +/// // ... +/// +/// struct CreateTableMigration: AsyncMigration { +/// func prepare(on database: Database) async throws { +/// try await database.schema(ForeignKeyMetadata.schema) +/// .field("constraint_name", .string, .required, .identifier(auto: false)) +/// .field("referencing_table_schema", .string, .required) +/// .field("referencing_table_name", .string, .required) +/// .foreignKey(["referencing_table_schema", "referencing_table_name"], references: TableMetadata.schema, ["table_schema", "table_name"]) +/// .field("referenced_table_schema", .string, .required) +/// .field("referenced_table_name", .string, .required) +/// .foreignKey(["referenced_table_schema", "referenced_table_name"], references: TableMetadata.schema, ["table_schema", "table_name"]) +/// // ... +/// .create() +/// } +/// } +/// } +/// ``` +@propertyWrapper @dynamicMemberLookup +public final class CompositeParentProperty + where From: Model, To: Model, To.IDValue: Fields +{ + public let prefix: FieldKey + public let prefixingStrategy: KeyPrefixingStrategy + public var id: To.IDValue + public var value: To? + + public var wrappedValue: To { + get { + guard let value = self.value else { + fatalError("Parent relation not eager loaded, use $ prefix to access: \(self.name)") + } + return value + } + set { fatalError("use $ prefix to access \(self.name)") } + } + + public var projectedValue: CompositeParentProperty { self } + + /// Configure a ``CompositeParentProperty`` with a key prefix and prefix strategy. + /// + /// - Parameters: + /// - prefix: A prefix to be applied to the key of each individual field of the referenced model's `IDValue`. + /// - strategy: The strategy to use when applying prefixes to keys. ``KeyPrefixingStrategy/snakeCase`` is + /// the default. + public init(prefix: FieldKey, strategy: KeyPrefixingStrategy = .snakeCase) { + self.id = .init() + self.prefix = prefix + self.prefixingStrategy = strategy + } + + public func query(on database: Database) -> QueryBuilder { + return To.query(on: database).group(.and) { self.id.input(to: QueryFilterInput(builder: $0)) } + } + + public subscript(dynamicMember keyPath: KeyPath) -> Nested + where Nested: Property + { + self.id[keyPath: keyPath] + } +} + +extension CompositeParentProperty: CustomStringConvertible { + public var description: String { + self.name + } +} + +extension CompositeParentProperty: Relation { + public var name: String { + "CompositeParent<\(From.self), \(To.self)>(prefix: \(self.prefix), strategy: \(self.prefixingStrategy))" + } + + public func load(on database: Database) -> EventLoopFuture { + self.query(on: database) + .first() + .map { + self.value = $0 + } + } +} + +extension CompositeParentProperty: AnyProperty {} + +extension CompositeParentProperty: Property { + public typealias Model = From + public typealias Value = To +} + +extension CompositeParentProperty: AnyDatabaseProperty { + public var keys: [FieldKey] { + To.IDValue.keys.map { + self.prefixingStrategy.apply(prefix: self.prefix, to: $0) + } + } + + public func input(to input: DatabaseInput) { + self.id.input(to: input.prefixed(by: self.prefix, using: self.prefixingStrategy)) + } + + public func output(from output: DatabaseOutput) throws { + try self.id.output(from: output.prefixed(by: self.prefix, using: self.prefixingStrategy)) + } +} + +extension CompositeParentProperty: AnyCodableProperty { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if let value = self.value { + try container.encode(value) + } else { + try container.encode(["id": self.id]) + } + } + + public func decode(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: SomeCodingKey.self) + self.id = try container.decode(To.IDValue.self, forKey: .init(stringValue: "id")) + } +} + +extension CompositeParentProperty: EagerLoadable { + public static func eagerLoad(_ relationKey: KeyPath>, to builder: Builder) + where Builder : EagerLoadBuilder, From == Builder.Model + { + self.eagerLoad(relationKey, withDeleted: false, to: builder) + } + + public static func eagerLoad(_ relationKey: KeyPath>, withDeleted: Bool, to builder: Builder) + where Builder: EagerLoadBuilder, Builder.Model == From + { + builder.add(loader: CompositeParentEagerLoader(relationKey: relationKey, withDeleted: withDeleted)) + } + + public static func eagerLoad(_ loader: Loader, through: KeyPath>, to builder: Builder) + where Loader: EagerLoader, Loader.Model == To, Builder: EagerLoadBuilder, Builder.Model == From + { + builder.add(loader: ThroughCompositeParentEagerLoader(relationKey: through, loader: loader)) + } +} + +private struct CompositeParentEagerLoader: EagerLoader + where From: Model, To: Model, To.IDValue: Fields +{ + let relationKey: KeyPath> + let withDeleted: Bool + + func run(models: [From], on database: Database) -> EventLoopFuture { + let sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) + + let builder = To.query(on: database) + .group(.or) { + _ = sets.keys.reduce($0) { query, id in query.group(.and) { id.input(to: QueryFilterInput(builder: $0)) } } + } + if (self.withDeleted) { + builder.withDeleted() + } + return builder.all() + .flatMapThrowing { + let parents = Dictionary(uniqueKeysWithValues: $0.map { ($0.id!, $0) }) + + for (parentId, models) in sets { + guard let parent = parents[parentId] else { + database.logger.debug( + "Missing parent model in eager-load lookup results.", + metadata: ["parent": "\(To.self)", "id": "\(parentId)"] + ) + throw FluentError.missingParentError(keyPath: self.relationKey, id: parentId) + } + models.forEach { + $0[keyPath: self.relationKey].value = parent + } + } + } + } +} + +private struct ThroughCompositeParentEagerLoader: EagerLoader + where From: Model, Loader: EagerLoader, Loader.Model == Through, Through.IDValue: Fields +{ + let relationKey: KeyPath> + let loader: Loader + + func run(models: [From], on database: Database) -> EventLoopFuture { + self.loader.run(models: models.map { + $0[keyPath: self.relationKey].value! + }, on: database) + } +} diff --git a/Sources/FluentKit/Properties/Field.swift b/Sources/FluentKit/Properties/Field.swift index fc7721fa..ffff2981 100644 --- a/Sources/FluentKit/Properties/Field.swift +++ b/Sources/FluentKit/Properties/Field.swift @@ -80,6 +80,17 @@ extension FieldProperty: AnyQueryableProperty { extension FieldProperty: QueryableProperty { } +// MARK: Query-addressable + +extension FieldProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self } + public var queryablePath: [FieldKey] { self.path } +} + +extension FieldProperty: QueryAddressableProperty { + public var queryableProperty: FieldProperty { self } +} + // MARK: Database extension FieldProperty: AnyDatabaseProperty { @@ -88,7 +99,9 @@ extension FieldProperty: AnyDatabaseProperty { } public func input(to input: DatabaseInput) { - if let inputValue = self.inputValue { + if input.wantsUnmodifiedKeys { + input.set(self.inputValue ?? self.outputValue.map { .bind($0) } ?? .default, at: self.key) + } else if let inputValue = self.inputValue { input.set(inputValue, at: self.key) } } diff --git a/Sources/FluentKit/Properties/Group.swift b/Sources/FluentKit/Properties/Group.swift index c0ebf170..79325034 100644 --- a/Sources/FluentKit/Properties/Group.swift +++ b/Sources/FluentKit/Properties/Group.swift @@ -19,7 +19,7 @@ public final class GroupProperty public var wrappedValue: Value { get { guard let value = self.value else { - fatalError("Cannot access unitialized Group field.") + fatalError("Cannot access uninitialized Group field: \(self.description)") } return value } @@ -68,10 +68,11 @@ extension GroupProperty: AnyDatabaseProperty { } public func input(to input: DatabaseInput) { - self.value!.input(to: input.prefixed(by: self.prefix)) + self.value?.input(to: input.prefixed(by: self.prefix)) } public func output(from output: DatabaseOutput) throws { + if self.value == nil { self.value = .init() } try self.value!.output(from: output.prefixed(by: self.prefix)) } } @@ -80,12 +81,17 @@ extension GroupProperty: AnyDatabaseProperty { extension GroupProperty: AnyCodableProperty { public func encode(to encoder: Encoder) throws { - try self.value!.encode(to: encoder) + try self.value?.encode(to: encoder) } public func decode(from decoder: Decoder) throws { - self.value = try .init(from: decoder) + let container = try decoder.singleValueContainer() + + guard !container.decodeNil() else { return } + self.value = .some(try container.decode(Value.self)) } + + public var skipPropertyEncoding: Bool { self.value == nil } } @@ -146,7 +152,7 @@ extension GroupPropertyPath: FluentKit.Property // MARK: + Queryable extension GroupPropertyPath: AnyQueryableProperty - where Property: AnyQueryableProperty + where Property: QueryableProperty { public var path: [FieldKey] { let subPath = self.property.path @@ -164,3 +170,29 @@ extension GroupPropertyPath: QueryableProperty } } +// MARK: + QueryAddressable + +extension GroupPropertyPath: AnyQueryAddressableProperty + where Property: AnyQueryAddressableProperty +{ + public var anyQueryableProperty: AnyQueryableProperty { + self.property.anyQueryableProperty + } + + public var queryablePath: [FieldKey] { + let subPath = self.property.queryablePath + return [ + .prefix(.prefix(self.key, .string("_")), subPath[0]) + ] + subPath[1...] + } +} + +extension GroupPropertyPath: QueryAddressableProperty + where Property: QueryAddressableProperty +{ + public typealias QueryablePropertyType = Property.QueryablePropertyType + + public var queryableProperty: QueryablePropertyType { + self.property.queryableProperty + } +} diff --git a/Sources/FluentKit/Properties/ID.swift b/Sources/FluentKit/Properties/ID.swift index 5e710c3a..2f02bbaa 100644 --- a/Sources/FluentKit/Properties/ID.swift +++ b/Sources/FluentKit/Properties/ID.swift @@ -1,3 +1,5 @@ +import Foundation + extension Model { public typealias ID = IDProperty where Value: Codable @@ -58,27 +60,32 @@ public final class IDProperty /// Initializes an `ID` property with the key `.id` and type `UUID`. /// - /// If the property's type is not `UUID` or the key is not `.id`, the initializer will - /// fatal error. This allows Fluent to natively support databases like MongoDB. - /// /// Use the `.init(custom:generatedBy:)` initializer to specify a custom ID key or type. - public convenience init(key: FieldKey = .id) { - guard Value.self is UUID.Type else { - // Ensure the default @ID type is using UUID which - // is the only identifier type supported by all drivers. - fatalError("@ID requires UUID, use @ID(custom:) for \(Value.self).") - } - guard key == .id else { - // Ensure the default @ID is using the special .id key - // which is the only identifier key supported by all drivers. - // - // Additional identifying fields can be added using @Field - // with a unique constraint. - fatalError("@ID requires .id key, use @ID(custom:) for key '\(key)'.") - } + public convenience init() where Value == UUID { self.init(custom: .id, generatedBy: .random) } + + /// Helper type for compatibility initializer syntax. Do not use this type directly. + public enum _DefaultIDFieldKey: ExpressibleByStringLiteral { + case id + + @available(*, deprecated, message: "The `@ID(key: \"id\")` syntax is deprecated. Use `@ID` or `@ID()` instead.") + public init(stringLiteral value: String) { + guard value == "id" else { + fatalError("@ID() may not specify a key; use @ID(custom:) for '\(value)'.") + } + self = .id + } + } + + /// Compatibility syntax for initializing an `ID` property. + /// + /// This syntax is no longer recommended; use `@ID` instead. + public convenience init(key _: _DefaultIDFieldKey) where Value == UUID { + self.init() + } + /// Create an `ID` property with a specific key, value type, and optional value generator. public init(custom key: FieldKey, generatedBy generator: Generator? = nil) { self.field = .init(key: key) self.generator = generator ?? .default(for: Value.self) @@ -91,7 +98,7 @@ public final class IDProperty switch self.inputValue { case .none, .null: break - case .bind(let value) where value.isNil: + case .bind(let value) where (value as? AnyOptionalType).map({ $0.wrappedValue == nil }) ?? false: break default: return @@ -142,6 +149,17 @@ extension IDProperty: AnyQueryableProperty { extension IDProperty: QueryableProperty { } +// MARK: Query-addressable + +extension IDProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self } + public var queryablePath: [FieldKey] { self.path } +} + +extension IDProperty: QueryAddressableProperty { + public var queryableProperty: IDProperty { self } +} + // MARK: Database extension IDProperty: AnyDatabaseProperty { @@ -176,19 +194,8 @@ extension IDProperty: AnyCodableProperty { extension IDProperty: AnyID { } -protocol AnyID { +protocol AnyID: AnyObject { func generate() var exists: Bool { get set } var cachedOutput: DatabaseOutput? { get set } } - - -private extension Encodable { - var isNil: Bool { - if let optional = self as? AnyOptionalType { - return optional.wrappedValue == nil - } else { - return false - } - } -} diff --git a/Sources/FluentKit/Properties/OptionalBoolean.swift b/Sources/FluentKit/Properties/OptionalBoolean.swift new file mode 100644 index 00000000..d779b6fb --- /dev/null +++ b/Sources/FluentKit/Properties/OptionalBoolean.swift @@ -0,0 +1,145 @@ +extension Fields { + public typealias OptionalBoolean = OptionalBooleanProperty + where Format: BooleanPropertyFormat +} + +/// A Fluent model property which represents an optional boolean (true/false) value. +/// +/// By default, `Bool` properties are stored in a database using the storage format +/// defined by the database driver, which corresponds to using the `.bool` data type +/// on the appropriate field in a migration. This property wrapper allows specifying +/// an alternative storage format - such the strings "true" and "false" - which is +/// automatically translated to and from a Swift `Bool` when loading and saving the +/// owning model. This is expected to be most useful when working with existing +/// database schemas. +/// +/// Example: +/// +/// final class MyModel: Model { +/// let schema = "my_models" +/// +/// @ID(key: .id) var id: UUID? +/// +/// // When not `nil`, this field will be stored using the database's native boolean format. +/// @OptionalField(key: "rawEnabled") var rawEnabled: Bool? +/// +/// // When not `nil`, this field will be stored as a string, either "true" or "false". +/// @OptionalBoolean(key: "enabled", format: .trueFalse) var enabled: Bool? +/// +/// init() {} +/// } +/// +/// struct MyModelMigration: AsyncMigration { +/// func prepare(on database: Database) async throws -> Void { +/// try await database.schema(MyModel.schema) +/// .id() +/// .field("rawEnabled", .bool) +/// .field("enabled", .string) +/// .create() +/// } +/// +/// func revert(on database: Database) async throws -> Void { try await database.schema(MyModel.schema).delete() } +/// } +/// +/// - Note: See also ``BooleanProperty`` and ``BooleanPropertyFormat``. +@propertyWrapper +public final class OptionalBooleanProperty + where Model: FluentKit.Fields, Format: BooleanPropertyFormat +{ + @OptionalFieldProperty + public var field: Format.Value? + public let format: Format + + public var projectedValue: OptionalBooleanProperty { self } + + public var wrappedValue: Bool? { + get { + switch self.value { + case .none, .some(.none): return nil + case .some(.some(let value)): return value + } + } + set { self.value = .some(newValue) } + } + + public init(key: FieldKey, format: Format) { + self._field = .init(key: key) + self.format = format + } +} + +extension OptionalBooleanProperty where Format == DefaultBooleanPropertyFormat { + public convenience init(key: FieldKey) { + self.init(key: key, format: .default) + } +} + +/// This is a workaround for Swift 5.4's inability to correctly infer the format type +/// using the `Self` constraints on the various static properties. +extension OptionalBooleanProperty { + public convenience init(key: FieldKey, format factory: BooleanPropertyFormatFactory) { + self.init(key: key, format: factory.format) + } +} + +extension OptionalBooleanProperty: AnyProperty {} + +extension OptionalBooleanProperty: Property { + public var value: Bool?? { + get { + switch self.$field.value { + case .some(.some(let value)): return .some(self.format.parse(value)) + case .some(.none): return .some(.none) + case .none: return .none + } + } + set { + switch newValue { + case .some(.some(let newValue)): self.$field.value = .some(.some(self.format.serialize(newValue))) + case .some(.none): self.$field.value = .some(.none) + case .none: self.$field.value = .none + } + } + } +} + +extension OptionalBooleanProperty: AnyQueryableProperty { + public var path: [FieldKey] { self.$field.path } +} + +extension OptionalBooleanProperty: QueryableProperty { + public static func queryValue(_ value: Bool?) -> DatabaseQuery.Value { + value.map { .bind(Format.init().serialize($0)) } ?? .null + } +} + +extension OptionalBooleanProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self } + public var queryablePath: [FieldKey] { self.path } +} + +extension OptionalBooleanProperty: QueryAddressableProperty { + public var queryableProperty: OptionalBooleanProperty { self } +} + +extension OptionalBooleanProperty: AnyDatabaseProperty { + public var keys: [FieldKey] { self.$field.keys } + public func input(to input: DatabaseInput) { self.$field.input(to: input) } + public func output(from output: DatabaseOutput) throws { try self.$field.output(from: output) } +} + +extension OptionalBooleanProperty: AnyCodableProperty { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } + + public func decode(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self.value = nil + } else { + self.value = try container.decode(Value.self) + } + } +} diff --git a/Sources/FluentKit/Properties/OptionalChild.swift b/Sources/FluentKit/Properties/OptionalChild.swift index 62823a7d..c9c2ee5a 100644 --- a/Sources/FluentKit/Properties/OptionalChild.swift +++ b/Sources/FluentKit/Properties/OptionalChild.swift @@ -1,3 +1,5 @@ +import NIOCore + extension Model { public typealias OptionalChild = OptionalChildProperty where To: FluentKit.Model @@ -9,22 +11,23 @@ extension Model { public final class OptionalChildProperty where From: Model, To: Model { - public enum Key { - case required(KeyPath>) - case optional(KeyPath>) - } + public typealias Key = RelationParentKey public let parentKey: Key var idValue: From.IDValue? public var value: To?? - public init(for parent: KeyPath>) { - self.parentKey = .required(parent) + public convenience init(for parent: KeyPath>) { + self.init(for: .required(parent)) } - public init(for optionalParent: KeyPath>) { - self.parentKey = .optional(optionalParent) + public convenience init(for optionalParent: KeyPath>) { + self.init(for: .optional(optionalParent)) + } + + private init(for parentKey: Key) { + self.parentKey = parentKey } public var wrappedValue: To? { @@ -35,7 +38,7 @@ public final class OptionalChildProperty return value } set { - fatalError("Child relation is get-only.") + fatalError("Child relation \(self.name) is get-only.") } } @@ -44,12 +47,13 @@ public final class OptionalChildProperty } public var fromId: From.IDValue? { - return self.idValue + get { return self.idValue } + set { self.idValue = newValue } } public func query(on database: Database) -> QueryBuilder { guard let id = self.idValue else { - fatalError("Cannot query child relation from unsaved model.") + fatalError("Cannot query child relation \(self.name) from unsaved model.") } let builder = To.query(on: database) switch self.parentKey { @@ -63,7 +67,7 @@ public final class OptionalChildProperty public func create(_ to: To, on database: Database) -> EventLoopFuture { guard let id = self.idValue else { - fatalError("Cannot save child to unsaved model.") + fatalError("Cannot save child in \(self.name) to unsaved model in.") } switch self.parentKey { case .required(let keyPath): @@ -122,6 +126,10 @@ extension OptionalChildProperty: AnyCodableProperty { public func decode(from decoder: Decoder) throws { // don't decode } + + public var skipPropertyEncoding: Bool { + self.value == nil // Avoids leaving an empty JSON object lying around in some cases. + } } // MARK: Relation @@ -138,27 +146,26 @@ extension OptionalChildProperty: Relation { } } -extension OptionalChildProperty.Key: CustomStringConvertible { - public var description: String { - switch self { - case .optional(let keyPath): - return To.path(for: keyPath.appending(path: \.$id)).description - case .required(let keyPath): - return To.path(for: keyPath.appending(path: \.$id)).description - } - } -} - // MARK: Eager Loadable extension OptionalChildProperty: EagerLoadable { + public static func eagerLoad( + _ relationKey: KeyPath>, + to builder: Builder + ) + where Builder : EagerLoadBuilder, From == Builder.Model + { + self.eagerLoad(relationKey, withDeleted: false, to: builder) + } + public static func eagerLoad( _ relationKey: KeyPath>, + withDeleted: Bool, to builder: Builder ) where Builder: EagerLoadBuilder, Builder.Model == From { - let loader = OptionalChildEagerLoader(relationKey: relationKey) + let loader = OptionalChildEagerLoader(relationKey: relationKey, withDeleted: withDeleted) builder.add(loader: loader) } @@ -182,6 +189,7 @@ private struct OptionalChildEagerLoader: EagerLoader where From: Model, To: Model { let relationKey: KeyPath> + let withDeleted: Bool func run(models: [From], on database: Database) -> EventLoopFuture { let ids = models.compactMap { $0.id! } @@ -194,6 +202,9 @@ private struct OptionalChildEagerLoader: EagerLoader case .required(let required): builder.filter(required.appending(path: \.$id) ~~ Set(ids)) } + if (self.withDeleted) { + builder.withDeleted() + } return builder.all().map { for model in models { let id = model[keyPath: self.relationKey].idValue! diff --git a/Sources/FluentKit/Properties/OptionalField.swift b/Sources/FluentKit/Properties/OptionalField.swift index 1f3ad0f5..044e84b1 100644 --- a/Sources/FluentKit/Properties/OptionalField.swift +++ b/Sources/FluentKit/Properties/OptionalField.swift @@ -47,7 +47,7 @@ extension OptionalFieldProperty: Property { case .default: fatalError("Cannot access default field for '\(Model.self).\(key)' before it is initialized or fetched") case .null: - return nil + return .some(.none) default: fatalError("Unexpected input value type for '\(Model.self).\(key)': \(value)") } @@ -58,9 +58,10 @@ extension OptionalFieldProperty: Property { } } set { - if let value = newValue { - self.inputValue = value.flatMap { .bind($0) } ?? .null + self.inputValue = value + .flatMap { .bind($0) } + ?? .null } else { self.inputValue = nil } @@ -78,6 +79,17 @@ extension OptionalFieldProperty: AnyQueryableProperty { extension OptionalFieldProperty: QueryableProperty { } +// MARK: Query-addressable + +extension OptionalFieldProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self } + public var queryablePath: [FieldKey] { self.path } +} + +extension OptionalFieldProperty: QueryAddressableProperty { + public var queryableProperty: OptionalFieldProperty { self } +} + // MARK: Database extension OptionalFieldProperty: AnyDatabaseProperty { @@ -86,7 +98,9 @@ extension OptionalFieldProperty: AnyDatabaseProperty { } public func input(to input: DatabaseInput) { - if let inputValue = self.inputValue { + if input.wantsUnmodifiedKeys { + input.set(self.inputValue ?? self.outputValue.map { $0.map { .bind($0) } ?? .null } ?? .default, at: self.key) + } else if let inputValue = self.inputValue { input.set(inputValue, at: self.key) } } diff --git a/Sources/FluentKit/Properties/OptionalParent.swift b/Sources/FluentKit/Properties/OptionalParent.swift index b31826cf..af21ce09 100644 --- a/Sources/FluentKit/Properties/OptionalParent.swift +++ b/Sources/FluentKit/Properties/OptionalParent.swift @@ -1,3 +1,5 @@ +import NIOCore + extension Model { public typealias OptionalParent = OptionalParentProperty where To: Model @@ -17,7 +19,7 @@ public final class OptionalParentProperty self.value ?? nil } set { - fatalError("OptionalParent relation is get-only.") + fatalError("OptionalParent relation \(self.name) is get-only.") } } @@ -28,6 +30,10 @@ public final class OptionalParentProperty public var value: To?? public init(key: FieldKey) { + guard !(To.IDValue.self is Fields.Type) else { + fatalError("Can not use @OptionalParent to target a model with composite ID; use @CompositeOptionalParent instead.") + } + self._id = .init(key: key) } @@ -71,6 +77,17 @@ extension OptionalParentProperty: Property { public typealias Value = To? } +// MARK: Query-addressable + +extension OptionalParentProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self.$id.anyQueryableProperty } + public var queryablePath: [FieldKey] { self.$id.queryablePath } +} + +extension OptionalParentProperty: QueryAddressableProperty { + public var queryableProperty: OptionalFieldProperty { self.$id.queryableProperty } +} + // MARK: Database extension OptionalParentProperty: AnyDatabaseProperty { @@ -92,7 +109,7 @@ extension OptionalParentProperty: AnyDatabaseProperty { extension OptionalParentProperty: AnyCodableProperty { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() - if let parent = self.value { + if case .some(.some(let parent)) = self.value { // require truly non-nil so we don't mis-encode when value has been manually cleared try container.encode(parent) } else { try container.encode([ @@ -102,21 +119,32 @@ extension OptionalParentProperty: AnyCodableProperty { } public func decode(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: ModelCodingKey.self) - try self.$id.decode(from: container.superDecoder(forKey: .string("id"))) + let container = try decoder.container(keyedBy: SomeCodingKey.self) + try self.$id.decode(from: container.superDecoder(forKey: .init(stringValue: "id"))) } } // MARK: Eager Loadable extension OptionalParentProperty: EagerLoadable { + public static func eagerLoad( + _ relationKey: KeyPath>, + to builder: Builder + ) + where Builder : EagerLoadBuilder, From == Builder.Model + { + self.eagerLoad(relationKey, withDeleted: false, to: builder) + } + public static func eagerLoad( _ relationKey: KeyPath>, + withDeleted: Bool, to builder: Builder ) where Builder: EagerLoadBuilder, Builder.Model == From { - let loader = OptionalParentEagerLoader(relationKey: relationKey) + let loader = OptionalParentEagerLoader(relationKey: relationKey, withDeleted: withDeleted) builder.add(loader: loader) } @@ -136,25 +164,33 @@ extension OptionalParentProperty: EagerLoadable { } private struct OptionalParentEagerLoader: EagerLoader - where From: Model, To: Model + where From: FluentKit.Model, To: FluentKit.Model { - let relationKey: KeyPath> + let relationKey: KeyPath> + let withDeleted: Bool func run(models: [From], on database: Database) -> EventLoopFuture { - let ids = models.compactMap { - $0[keyPath: self.relationKey].id + var sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) + let nilParentModels = sets.removeValue(forKey: nil) ?? [] + + let builder = To.query(on: database).filter(\._$id ~~ Set(sets.keys.compactMap { $0 })) + if (self.withDeleted) { + builder.withDeleted() } - - return To.query(on: database) - .filter(\._$id ~~ Set(ids)) - .all() - .map - { - for model in models { - model[keyPath: self.relationKey].value = .some($0.filter { - $0.id == model[keyPath: self.relationKey].id - }.first) + return builder.all().flatMapThrowing { + let parents = Dictionary(uniqueKeysWithValues: $0.map { ($0.id!, $0) }) + + for (parentId, models) in sets { + guard let parent = parents[parentId!] else { + database.logger.debug( + "Missing parent model in eager-load lookup results.", + metadata: ["parent": .string("\(To.self)"), "id": .string("\(parentId!)")] + ) + throw FluentError.missingParentError(keyPath: self.relationKey, id: parentId!) + } + models.forEach { $0[keyPath: self.relationKey].value = .some(.some(parent)) } } + nilParentModels.forEach { $0[keyPath: self.relationKey].value = .some(.none) } } } } diff --git a/Sources/FluentKit/Properties/Parent.swift b/Sources/FluentKit/Properties/Parent.swift index bce9c8f7..9c9922fe 100644 --- a/Sources/FluentKit/Properties/Parent.swift +++ b/Sources/FluentKit/Properties/Parent.swift @@ -1,3 +1,5 @@ +import NIOCore + extension Model { public typealias Parent = ParentProperty where To: FluentKit.Model @@ -15,11 +17,11 @@ public final class ParentProperty public var wrappedValue: To { get { guard let value = self.value else { - fatalError("Parent relation not eager loaded, use $ prefix to access: \(name)") + fatalError("Parent relation not eager loaded, use $ prefix to access: \(self.name)") } return value } - set { fatalError("use $ prefix to access") } + set { fatalError("use $ prefix to access \(self.name)") } } public var projectedValue: ParentProperty { @@ -29,6 +31,10 @@ public final class ParentProperty public var value: To? public init(key: FieldKey) { + guard !(To.IDValue.self is Fields.Type) else { + fatalError("Can not use @Parent to target a model with composite ID; use @CompositeParent instead.") + } + self._id = .init(key: key) } @@ -67,6 +73,17 @@ extension ParentProperty: Property { public typealias Value = To } +// MARK: Query-addressable + +extension ParentProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self.$id.anyQueryableProperty } + public var queryablePath: [FieldKey] { self.$id.queryablePath } +} + +extension ParentProperty: QueryAddressableProperty { + public var queryableProperty: FieldProperty { self.$id.queryableProperty } +} + // MARK: Database extension ParentProperty: AnyDatabaseProperty { @@ -96,21 +113,31 @@ extension ParentProperty: AnyCodableProperty { } public func decode(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: ModelCodingKey.self) - try self.$id.decode(from: container.superDecoder(forKey: .string("id"))) + let container = try decoder.container(keyedBy: SomeCodingKey.self) + try self.$id.decode(from: container.superDecoder(forKey: .init(stringValue: "id"))) } } // MARK: Eager Loadable extension ParentProperty: EagerLoadable { + public static func eagerLoad( + _ relationKey: KeyPath>, + to builder: Builder + ) + where Builder : EagerLoadBuilder, From == Builder.Model + { + self.eagerLoad(relationKey, withDeleted: false, to: builder) + } + public static func eagerLoad( _ relationKey: KeyPath>, + withDeleted: Bool, to builder: Builder ) where Builder: EagerLoadBuilder, Builder.Model == From { - let loader = ParentEagerLoader(relationKey: relationKey) + let loader = ParentEagerLoader(relationKey: relationKey, withDeleted: withDeleted) builder.add(loader: loader) } @@ -131,28 +158,29 @@ extension ParentProperty: EagerLoadable { } private struct ParentEagerLoader: EagerLoader - where From: Model, To: Model + where From: FluentKit.Model, To: FluentKit.Model { - let relationKey: KeyPath> + let relationKey: KeyPath> + let withDeleted: Bool func run(models: [From], on database: Database) -> EventLoopFuture { - let ids = models.map { - $0[keyPath: self.relationKey].id + let sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) + let builder = To.query(on: database).filter(\._$id ~~ Set(sets.keys)) + if (self.withDeleted) { + builder.withDeleted() } - - return To.query(on: database) - .filter(\._$id ~~ Set(ids)) - .all() - .flatMapThrowing - { - for model in models { - guard let parent = $0.filter({ - $0.id == model[keyPath: self.relationKey].id - }).first else { - database.logger.debug("No parent '\(To.self)' with id '\(model[keyPath: self.relationKey].id)' was found in eager-load results.") - throw FluentError.missingParent + return builder.all().flatMapThrowing { + let parents = Dictionary(uniqueKeysWithValues: $0.map { ($0.id!, $0) }) + + for (parentId, models) in sets { + guard let parent = parents[parentId] else { + database.logger.debug( + "Missing parent model in eager-load lookup results.", + metadata: ["parent": .string("\(To.self)"), "id": .string("\(parentId)")] + ) + throw FluentError.missingParentError(keyPath: self.relationKey, id: parentId) } - model[keyPath: self.relationKey].value = parent + models.forEach { $0[keyPath: self.relationKey].value = parent } } } } diff --git a/Sources/FluentKit/Properties/Property.swift b/Sources/FluentKit/Properties/Property.swift index 9b9cf6e4..31bbb81e 100644 --- a/Sources/FluentKit/Properties/Property.swift +++ b/Sources/FluentKit/Properties/Property.swift @@ -1,45 +1,174 @@ +/// The type-erased form of ``Property`` (see below). ``AnyProperty`` is used to +/// access a model's set of Fluent properties in a fully generic fashion (with a +/// little help from runtime reflection). It is generally not meaningful to conform +/// to this protocol without also at least conforming to ``Property``. public protocol AnyProperty: AnyObject { static var anyValueType: Any.Type { get } var anyValue: Any? { get } } +/// A property wrapper type conforms to this protocol to participate in Fluent's +/// system for interfacing between the various properties of a model and the +/// representations of those properties in a database. All properties whose +/// wrappers conform to this protocol appear in Fluent's list of the data items +/// which exist on a given model - whether those items contain actual data, +/// such as a property representing a field in a database table, or are means to +/// access other data, such a list of associated models on the far side of a +/// many-to-many relation. public protocol Property: AnyProperty { associatedtype Model: Fields associatedtype Value: Codable var value: Value? { get set } } +/// ``AnyProperty``'s requirements are implemented in terms of ``Property``'s +/// requirements - they're the same requirements; ``Property`` is just more +/// specific about the types. extension AnyProperty where Self: Property { + /// The type-erased value of a property is the property's value. public var anyValue: Any? { self.value } - + + /// The type-erased type of a property's value is the type of the property's value. public static var anyValueType: Any.Type { Value.self } } +/// Marks a property as having "database" capability - in other words, the property +/// receives output from the results of read queries, provides input to write queries, +/// and/or represents one or more model fields. +/// +/// - Note: Most "database" properties participate in all three aspects (is/has fields, +/// provides input, receives output), but certain properties only participate in +/// receiving output (most notably the non-parent relation property types). Those +/// properties only behave in this manner because the ability to look up the needed +/// information on demand was not available in Swift until after the implementation was +/// effectively complete. They should not be considered actual "database" properties. public protocol AnyDatabaseProperty: AnyProperty { var keys: [FieldKey] { get } func input(to input: DatabaseInput) func output(from output: DatabaseOutput) throws } +/// Marks a property as participating in the ``Fields`` protocol's (defaulted) +/// implementation of `Decodable` and `Encodable`. This allows the property +/// to encode and decode to and from representations other than storage in a +/// database, and to act as a container if it contains any additional properties +/// which also wish to participate. Just about every property type is codable. +/// +/// > Warning: The various relation property types sometimes behave somewhat oddly +/// when encoded and/or decoded. +/// +/// > TODO: When corresponding parent and child properties on their respective models +/// refer to each other, such as due to both relations being eager-loaded, both +/// encoding and decoding will crash due to infinite recursion. At some point, look +/// into a way to at least error out rather than crashing. public protocol AnyCodableProperty: AnyProperty { + /// Encode the property's data to an external representation. func encode(to encoder: Encoder) throws + + /// Decode an external representation and replace the property's current data with the result. func decode(from decoder: Decoder) throws + + /// Return `true` to skip encoding of this property. Defaults to `false` unless explicitly + /// implemented. + /// + /// This is used by ``Fields`` to work around limitations of `Codable` in an efficient manner. + /// You probably don't need to bother with it. + var skipPropertyEncoding: Bool { get } +} + +extension AnyCodableProperty { + /// Default implementation of ``AnyCodableProperty/skipPropertyEncoding-87r6k``. + public var skipPropertyEncoding: Bool { false } } +/// The type-erased form of ``QueryableProperty`` (see below). ``AnyQueryableProperty`` +/// is used most often as a type-generic check for whether or not a given property +/// represents an actual database field. public protocol AnyQueryableProperty: AnyProperty { + /// Provides the database field's "path" - a nonempty list of field keys whose last + /// item provides the name the field has in the database (which need not be the same + /// as the name the corresponding model property has in Swift). A path containing + /// more than one key theoretically describes a nested structure within the database, + /// such as a field containing a complex JSON document, but at present this is not + /// fully implemented by Fluent, making a multi-key path as invalid as an empty one. var path: [FieldKey] { get } + + /// If the property's current value has been set, return a description of the + /// appropriate method for encoding that value into a database query. See + /// ``DatabaseQuery/Value`` for more details. If the value is not set, the + /// property must choose whether to request the `NULL` encoding or to return + /// no value at all (whether or not this results in an error is highly context- + /// dependent). + func queryableValue() -> DatabaseQuery.Value? } +/// Marks a property as being "queryable", meaning that it represents exactly one +/// "real" database field (i.e. the database table will contain a "physical" field +/// corresponding to the property, and it will be the only field that does so). public protocol QueryableProperty: AnyQueryableProperty, Property { + /// Requests a description of the appropriate method of encoding a value of the + /// property's wrapped type into a database query. In essence, this is the static + /// version of ``AnyQueryableProperty/queryableValue()-3uzih``, except that this + /// version will always have an input and thus can not return `nil`. + /// + /// - Warning: The existence of this method implies that any two identically-typed + /// instances of a property _must_ encode their values into queries in exactly + /// the same fashion, and Fluent does have code paths which proceed on that + /// assumption. For example, this requirement is the primary reason that a + /// ``TimestampProperty``'s format is represented as a generic type parameter + /// rather than being provided to an initializer. static func queryValue(_ value: Value) -> DatabaseQuery.Value } +extension AnyQueryableProperty where Self: QueryableProperty { + /// By default, ``QueryableProperty``s uses ``QueryableProperty/queryValue(_:)-5df0n`` + /// to provide its ``AnyQueryableProperty/queryableValue()-4tkjo``. While it is not strictly required that + /// this be the case, providing an alternative implementation risks violating the + /// "identical encoding for identical property types" rule (see + /// ``QueryableProperty/queryValue(_:)-5df0n``). + public func queryableValue() -> DatabaseQuery.Value? { + return self.value.map { Self.queryValue($0) } + } +} + extension QueryableProperty { + /// Since ``Property/Value`` conforms to `Codable`, the default encoding for + /// any ``QueryableProperty``'s value is as a query placeholder and associated parameter + /// binding (bindings are sent to a database driver encoded via `Encodable`). + /// See ``DatabaseQuery/Value`` for more details on possible alternative encodings. public static func queryValue(_ value: Value) -> DatabaseQuery.Value { .bind(value) } } + +/// The type-erased form of ``QueryAddressableProperty`` (see below). Both protocols serve to +/// bridge the gap between `AnyQueryableProperty` - which describes a property whose singular +/// `Value` directly corresponds to the value stored in the database for that property - and +/// property types whose `Value` is a derivative of or expansion upon an underlying queryable +/// property. See the discussion of ``QueryAddressableProperty`` itself for additional details. +public protocol AnyQueryAddressableProperty: AnyProperty { + var anyQueryableProperty: AnyQueryableProperty { get } + var queryablePath: [FieldKey] { get } +} + +/// Marks a property as being "query addressable", meaning that it is either itself queryable +/// (``QueryableProperty`` implies ``QueryAddressableProperty``), or it represents some other +/// single property that _is_ queryable. This allows properties whose purpose is to wrap or +/// otherwise stand in for other properties to be handled generically without the need to +/// add special case exceptions for those property types. +/// +/// `@Parent` is the canonical example of an addressable, non-queryable property. It provides +/// the related model as its value, and contains a `@Field` property holding that model's ID. +/// That underlying property means the relation can be "addressed" by a query, but the value +/// type is wrong for it to be directly queryable. Providing the underlying field when the +/// relation is "addressed" allows handling a model's property list (or, say, the property +/// list of a ``Fields`` type being used as a composite ID value) fully generically and without +/// special-casing or having to revisit the logic if additional property types come along. +public protocol QueryAddressableProperty: AnyQueryAddressableProperty, Property { + associatedtype QueryablePropertyType: QueryableProperty + var queryableProperty: QueryablePropertyType { get } +} diff --git a/Sources/FluentKit/Properties/Relation.swift b/Sources/FluentKit/Properties/Relation.swift index 449c1824..d95e489f 100644 --- a/Sources/FluentKit/Properties/Relation.swift +++ b/Sources/FluentKit/Properties/Relation.swift @@ -1,3 +1,10 @@ +import NIOCore + +/// A protocol which designates a conforming type as representing a database relation of any kind. Intended +/// for use only by FluentKit property wrappers. +/// +/// - Note: This protocol should probably require conformance to ``Property``, but adding that requirement +/// wouldn't have enough value to be worth having to hand-wave a technically semver-major change. public protocol Relation { associatedtype RelatedValue var name: String { get } @@ -6,12 +13,24 @@ public protocol Relation { } extension Relation { + /// Return the value of the relation, loading it first if necessary. + /// + /// If the value is loaded (including reloading), the value is set in the property before being returned. + /// + /// - Note: This API is strongly preferred over ``Relation/load(on:)``, even when the caller does not need + /// the returned value, in order to minimize unnecessary database traffic. + /// + /// - Parameters: + /// - reload: If `true`, load the value from the database unconditionally, overwriting any previously + /// loaded value. + /// - database: The database to use if the value needs to be loaded. + /// - Returns: The loaded value. public func get(reload: Bool = false, on database: Database) -> EventLoopFuture { if let value = self.value, !reload { return database.eventLoop.makeSucceededFuture(value) } else { return self.load(on: database).flatMapThrowing { - guard let value = self.value else { + guard let value = self.value else { // This should never actually happen, but just in case... throw FluentError.relationNotLoaded(name: self.name) } return value @@ -19,3 +38,79 @@ extension Relation { } } } + +/// A helper type used by ``ChildrenProperty`` and ``OptionalChildProperty`` to generically track the keypath +/// of the property of the child model that defines the parent-child relationship. +/// +/// This type was extracted from its original definitions as a subtype of the property types. A typealias is +/// provided on the property types to maintain public API compatibility. +public enum RelationParentKey + where From: FluentKit.Model, To: FluentKit.Model +{ + case required(KeyPath>) + case optional(KeyPath>) +} + +extension RelationParentKey: CustomStringConvertible { + public var description: String { + switch self { + case .optional(let keypath): return To.path(for: keypath.appending(path: \.$id)).description + case .required(let keypath): return To.path(for: keypath.appending(path: \.$id)).description + } + } +} + +/// A helper type used by ``CompositeChildrenProperty`` and ``CompositeOptionalChildProperty`` to generically +/// track the keypath of the property of the child model that defines the parent-child relationship. +/// +/// Unfortunately, the additional generic constraint requiring `From.IDValue` to conform to ``Fields`` for the +/// purposes of ``CompositeChildrenProperty`` etc. makes it impractical to combine this and ``RelationParentKey`` +/// in a single helper type. +/// +/// - Note: This type is public partly to allow FluentKit users to introspect model metadata, but mostly it's +/// to maintain parity with ``RelationParentKey``, which was public in its original definition. +public enum CompositeRelationParentKey + where From: FluentKit.Model, To: FluentKit.Model, From.IDValue: Fields +{ + case required(KeyPath>) + case optional(KeyPath>) + + /// Use the stored key path to retrieve the appropriate parent ID from the given child model. + internal func referencedId(in model: To) -> From.IDValue? { + switch self { + case .required(let keypath): return model[keyPath: keypath].id + case .optional(let keypath): return model[keyPath: keypath].id + } + } + + /// Use the parent property specified by the key path to filter the given query builder by each of the + /// given parent IDs in turn. An empty ID list will apply no filters. + /// + /// Callers are responsible for providing an OR-grouping builder, which produces "any child model whose + /// parent has one of these IDs" behavior (combining the filter groups with `OR` is less efficient than + /// using the `IN` operator, but `IN` doesn't work with composite values). + /// + /// See ``QueryFilterInput`` for additional implementation details. + internal func queryFilterIds(_ ids: C, in builder: QueryBuilder) -> QueryBuilder + where C: Collection, C.Element == From.IDValue + { + guard !ids.isEmpty else { return builder } + switch self { + case .required(let keypath): + let prop = To()[keyPath: keypath] + return ids.reduce(builder) { b, id in b.group(.and) { prop.id = id; prop.input(to: QueryFilterInput(builder: $0)) } } + case .optional(let keypath): + let prop = To()[keyPath: keypath] + return ids.reduce(builder) { b, id in b.group(.and) { prop.id = id; prop.input(to: QueryFilterInput(builder: $0)) } } + } + } +} + +extension CompositeRelationParentKey: CustomStringConvertible { + public var description: String { + switch self { + case .required(let keypath): return To()[keyPath: keypath].prefix.description + case .optional(let keypath): return To()[keyPath: keypath].prefix.description + } + } +} diff --git a/Sources/FluentKit/Properties/Siblings.swift b/Sources/FluentKit/Properties/Siblings.swift index 16d8f616..d00c09f3 100644 --- a/Sources/FluentKit/Properties/Siblings.swift +++ b/Sources/FluentKit/Properties/Siblings.swift @@ -1,3 +1,5 @@ +import NIOCore + extension Model { public typealias Siblings = SiblingsProperty where To: Model, Through: Model @@ -39,6 +41,10 @@ public final class SiblingsProperty from: KeyPath>, to: KeyPath> ) { + guard !(From.IDValue.self is Fields.Type), !(To.IDValue.self is Fields.Type) else { + fatalError("Can not use @Siblings with models which have composite IDs.") + } + self.from = from self.to = to self._pivots = ChildrenProperty(for: from) @@ -47,12 +53,12 @@ public final class SiblingsProperty public var wrappedValue: [To] { get { guard let value = self.value else { - fatalError("Siblings relation not eager loaded, use $ prefix to access: \(name)") + fatalError("Siblings relation not eager loaded, use $ prefix to access: \(self.name)") } return value } set { - fatalError("Siblings relation is get-only.") + fatalError("Siblings relation \(self.name) is get-only.") } } @@ -60,6 +66,11 @@ public final class SiblingsProperty return self } + public var fromId: From.IDValue? { + get { return self.idValue } + set { self.idValue = newValue } + } + // MARK: Checking state /// Check whether a specific model is already attached through a sibling relationship. @@ -69,7 +80,7 @@ public final class SiblingsProperty /// - database: The database to perform check on. public func isAttached(to: To, on database: Database) -> EventLoopFuture { guard let toID = to.id else { - fatalError("Cannot attach unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.operandModelIdRequired(property: self.name)) } return self.isAttached(toID: toID, on: database) @@ -82,14 +93,14 @@ public final class SiblingsProperty /// - database: The database to perform the check on. public func isAttached(toID: To.IDValue, on database: Database) -> EventLoopFuture { guard let fromID = self.idValue else { - fatalError("Cannot check if siblings are attached to an unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.owningModelIdRequired(property: self.name)) } return Through.query(on: database) .filter(self.from.appending(path: \.$id) == fromID) .filter(self.to.appending(path: \.$id) == toID) - .first() - .map { $0 != nil } + .count() + .map { $0 > 0 } } // MARK: Operations @@ -106,19 +117,24 @@ public final class SiblingsProperty _ edit: (Through) -> () = { _ in } ) -> EventLoopFuture { guard let fromID = self.idValue else { - fatalError("Cannot attach siblings relation to unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.owningModelIdRequired(property: self.name)) } - - return tos.map { to -> Through in + + var pivots: [Through] = [] + pivots.reserveCapacity(tos.count) + + for to in tos { guard let toID = to.id else { - fatalError("Cannot attach unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.operandModelIdRequired(property: self.name)) } let pivot = Through() pivot[keyPath: self.from].id = fromID pivot[keyPath: self.to].id = toID + pivot[keyPath: self.to].value = to edit(pivot) - return pivot - }.create(on: database) + pivots.append(pivot) + } + return pivots.create(on: database) } /// Attach a single model by creating a pivot model and specifying the attachment method. @@ -160,15 +176,16 @@ public final class SiblingsProperty _ edit: (Through) -> () = { _ in } ) -> EventLoopFuture { guard let fromID = self.idValue else { - fatalError("Cannot attach siblings relation to unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.owningModelIdRequired(property: self.name)) } guard let toID = to.id else { - fatalError("Cannot attach unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.operandModelIdRequired(property: self.name)) } let pivot = Through() pivot[keyPath: self.from].id = fromID pivot[keyPath: self.to].id = toID + pivot[keyPath: self.to].value = to edit(pivot) return pivot.save(on: database) } @@ -180,13 +197,17 @@ public final class SiblingsProperty /// - database: The database to perform the attachment on. public func detach(_ tos: [To], on database: Database) -> EventLoopFuture { guard let fromID = self.idValue else { - fatalError("Cannot detach siblings relation to unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.owningModelIdRequired(property: self.name)) } - let toIDs = tos.map { to -> To.IDValue in + + var toIDs: [To.IDValue] = [] + toIDs.reserveCapacity(tos.count) + + for to in tos { guard let toID = to.id else { - fatalError("Cannot detach unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.operandModelIdRequired(property: self.name)) } - return toID + toIDs.append(toID) } return Through.query(on: database) @@ -202,10 +223,10 @@ public final class SiblingsProperty /// - database: The database to perform the attachment on. public func detach(_ to: To, on database: Database) -> EventLoopFuture { guard let fromID = self.idValue else { - fatalError("Cannot detach siblings relation from unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.owningModelIdRequired(property: self.name)) } guard let toID = to.id else { - fatalError("Cannot detach unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.operandModelIdRequired(property: self.name)) } return Through.query(on: database) @@ -217,7 +238,7 @@ public final class SiblingsProperty /// Detach all models by deleting all pivots from this model. public func detachAll(on database: Database) -> EventLoopFuture { guard let fromID = self.idValue else { - fatalError("Cannot detach siblings relation from unsaved model.") + return database.eventLoop.makeFailedFuture(SiblingsPropertyError.owningModelIdRequired(property: self.name)) } return Through.query(on: database) @@ -230,7 +251,8 @@ public final class SiblingsProperty /// Returns a `QueryBuilder` that can be used to query the siblings. public func query(on database: Database) -> QueryBuilder { guard let fromID = self.idValue else { - fatalError("Cannot query siblings relation from unsaved model.") + // TODO: Get rid of this fatalError() like we got rid of all the others. + fatalError("Cannot query siblings relation \(self.name) from unsaved model.") } return To.query(on: database) @@ -287,6 +309,10 @@ extension SiblingsProperty: AnyCodableProperty { public func decode(from decoder: Decoder) throws { // don't decode } + + public var skipPropertyEncoding: Bool { + self.value == nil // Avoids leaving an empty JSON object lying around in some cases. + } } // MARK: Relation @@ -308,13 +334,23 @@ extension SiblingsProperty: Relation { // MARK: Eager Loadable extension SiblingsProperty: EagerLoadable { + public static func eagerLoad( + _ relationKey: KeyPath>, + to builder: Builder + ) + where Builder : EagerLoadBuilder, From == Builder.Model + { + self.eagerLoad(relationKey, withDeleted: false, to: builder) + } + public static func eagerLoad( _ relationKey: KeyPath>, + withDeleted: Bool, to builder: Builder ) where Builder: EagerLoadBuilder, Builder.Model == From { - let loader = SiblingsEagerLoader(relationKey: relationKey) + let loader = SiblingsEagerLoader(relationKey: relationKey, withDeleted: withDeleted) builder.add(loader: loader) } @@ -339,16 +375,20 @@ private struct SiblingsEagerLoader: EagerLoader where From: Model, Through: Model, To: Model { let relationKey: KeyPath> + let withDeleted: Bool func run(models: [From], on database: Database) -> EventLoopFuture { let ids = models.map { $0.id! } let from = From()[keyPath: self.relationKey].from let to = From()[keyPath: self.relationKey].to - return To.query(on: database) + let builder = To.query(on: database) .join(Through.self, on: \To._$id == to.appending(path: \.$id)) .filter(Through.self, from.appending(path: \.$id) ~~ Set(ids)) - .all() + if (self.withDeleted) { + builder.withDeleted() + } + return builder.all() .flatMapThrowing { var map: [From.IDValue: [To]] = [:] @@ -357,7 +397,8 @@ private struct SiblingsEagerLoader: EagerLoader map[fromID, default: []].append(to) } for model in models { - model[keyPath: self.relationKey].value = map[model.id!] ?? [] + guard let id = model.id else { throw FluentError.idRequired } + model[keyPath: self.relationKey].value = map[id] ?? [] } } } diff --git a/Sources/FluentKit/Properties/Timestamp.swift b/Sources/FluentKit/Properties/Timestamp.swift index 789497c3..ec7a0db6 100644 --- a/Sources/FluentKit/Properties/Timestamp.swift +++ b/Sources/FluentKit/Properties/Timestamp.swift @@ -1,3 +1,5 @@ +import Foundation + extension Model { public typealias Timestamp = TimestampProperty where Format: TimestampFormat @@ -30,7 +32,10 @@ public final class TimestampProperty public var wrappedValue: Date? { get { - self.value ?? nil + switch self.value { + case .none, .some(.none): return nil + case .some(.some(let value)): return value + } } set { self.value = .some(newValue) @@ -52,7 +57,7 @@ public final class TimestampProperty } public func touch(date: Date?) { - self.value = date + self.wrappedValue = date } } @@ -75,13 +80,23 @@ extension TimestampProperty: AnyProperty { } extension TimestampProperty: Property { public var value: Date?? { get { - self.$timestamp.value.flatMap { - .some($0.flatMap { self.format.parse($0) }) + switch self.$timestamp.value { + case .some(.some(let timestamp)): + return .some(self.format.parse(timestamp)) + case .some(.none): + return .some(.none) + case .none: + return .none } } set { - self.$timestamp.value = newValue.flatMap { - .some($0.flatMap { self.format.serialize($0) }) + switch newValue { + case .some(.some(let newValue)): + self.$timestamp.value = .some(self.format.serialize(newValue)) + case .some(.none): + self.$timestamp.value = .some(.none) + case .none: + self.$timestamp.value = .none } } } @@ -97,6 +112,17 @@ extension TimestampProperty: AnyQueryableProperty { extension TimestampProperty: QueryableProperty { } +// MARK: Query-addressable + +extension TimestampProperty: AnyQueryAddressableProperty { + public var anyQueryableProperty: AnyQueryableProperty { self } + public var queryablePath: [FieldKey] { self.path } +} + +extension TimestampProperty: QueryAddressableProperty { + public var queryableProperty: TimestampProperty { self } +} + // MARK: Database extension TimestampProperty: AnyDatabaseProperty { @@ -118,7 +144,7 @@ extension TimestampProperty: AnyDatabaseProperty { extension TimestampProperty: AnyCodableProperty { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() - try container.encode(self.value) + try container.encode(self.wrappedValue) } public func decode(from decoder: Decoder) throws { @@ -186,9 +212,10 @@ extension Schema { guard let timestamp = self.init().deletedTimestamp else { return } - let deletedAtField = DatabaseQuery.Field.path( + let deletedAtField = DatabaseQuery.Field.extendedPath( [timestamp.key], - schema: self.schemaOrAlias + schema: self.schemaOrAlias, + space: self.space ) query.filters.append(.group([ .value(deletedAtField, .equal, .null), diff --git a/Sources/FluentKit/Properties/TimestampFormat.swift b/Sources/FluentKit/Properties/TimestampFormat.swift index bdd458aa..25643280 100644 --- a/Sources/FluentKit/Properties/TimestampFormat.swift +++ b/Sources/FluentKit/Properties/TimestampFormat.swift @@ -1,6 +1,5 @@ -import class Foundation.ISO8601DateFormatter -import class Foundation.DateFormatter import class NIO.ThreadSpecificVariable +import Foundation // MARK: Format @@ -12,7 +11,7 @@ public protocol TimestampFormat { } public struct TimestampFormatFactory { - let makeFormat: () -> Format + public let makeFormat: () -> Format public init(_ makeFormat: @escaping () -> Format) { self.makeFormat = makeFormat diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Aggregate.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Aggregate.swift index 9bb39917..0eb1ed59 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Aggregate.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Aggregate.swift @@ -1,95 +1,150 @@ +import NIOCore + extension QueryBuilder { // MARK: Aggregate public func count() -> EventLoopFuture { - self.count(\._$id) + if Model().anyID is AnyQueryableProperty { + return self.count(\._$id) + } else if let fieldsIDType = Model.IDValue.self as? Fields.Type { + return self.aggregate(.count, fieldsIDType.keys.first!) + } else { + fatalError("Model '\(Model.self)' has neither @ID nor @CompositeID, this is not valid.") + } } public func count(_ key: KeyPath) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model { self.aggregate(.count, key, as: Int.self) } + public func count(_ key: KeyPath) -> EventLoopFuture + where Field: QueryableProperty, Field.Model == Model.IDValue + { + self.aggregate(.count, key, as: Int.self) + } + + // TODO: `Field.Value` is not always the correct result type for `SUM()`, try `.aggregate(.sum, key, as: ...)` for now public func sum(_ key: KeyPath) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model { self.aggregate(.sum, key) } + // TODO: `Field.Value` is not always the correct result type for `SUM()`, try `.aggregate(.sum, key, as: ...)` for now + public func sum(_ key: KeyPath) -> EventLoopFuture + where Field: QueryableProperty, Field.Model == Model.IDValue + { + self.aggregate(.sum, key) + } + + // TODO: `Field.Value` is not always the correct result type for `SUM()`, try `.aggregate(.sum, key, as: ...)` for now + public func sum(_ key: KeyPath) -> EventLoopFuture + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model + { + self.aggregate(.sum, key) + } + + // TODO: `Field.Value` is not always the correct result type for `SUM()`, try `.aggregate(.sum, key, as: ...)` for now public func sum(_ key: KeyPath) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Value: OptionalType, - Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue { self.aggregate(.sum, key) } + // TODO: `Field.Value` is not always the correct result type for `AVG()`, try `.aggregate(.average, key, as: ...)` for now public func average(_ key: KeyPath) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model { self.aggregate(.average, key) } + // TODO: `Field.Value` is not always the correct result type for `AVG()`, try `.aggregate(.average, key, as: ...)` for now + public func average(_ key: KeyPath) -> EventLoopFuture + where Field: QueryableProperty, Field.Model == Model.IDValue + { + self.aggregate(.average, key) + } + + // TODO: `Field.Value` is not always the correct result type for `AVG()`, try `.aggregate(.average, key, as: ...)` for now + public func average(_ key: KeyPath) -> EventLoopFuture + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model + { + self.aggregate(.average, key) + } + + // TODO: `Field.Value` is not always the correct result type for `AVG()`, try `.aggregate(.average, key, as: ...)` for now public func average(_ key: KeyPath) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Value: OptionalType, - Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue { self.aggregate(.average, key) } public func min(_ key: KeyPath) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model + { + self.aggregate(.minimum, key) + } + + public func min(_ key: KeyPath) -> EventLoopFuture + where Field: QueryableProperty, Field.Model == Model.IDValue { self.aggregate(.minimum, key) } public func min(_ key: KeyPath) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Value: OptionalType, - Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model { self.aggregate(.minimum, key) } + public func min(_ key: KeyPath) -> EventLoopFuture + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue + { + self.aggregate(.minimum, key) + } + + public func max(_ key: KeyPath) -> EventLoopFuture + where Field: QueryableProperty, Field.Model == Model + { + self.aggregate(.maximum, key) + } + public func max(_ key: KeyPath) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model.IDValue { self.aggregate(.maximum, key) } public func max(_ key: KeyPath) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Value: OptionalType, - Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model { self.aggregate(.maximum, key) } + public func max(_ key: KeyPath) -> EventLoopFuture + where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue + { + self.aggregate(.maximum, key) + } + + public func aggregate( + _ method: DatabaseQuery.Aggregate.Method, + _ field: KeyPath, + as: Result.Type = Result.self + ) -> EventLoopFuture + where Field: QueryableProperty, Field.Model == Model, Result: Codable + { + self.aggregate(method, Model.path(for: field), as: Result.self) + } + public func aggregate( _ method: DatabaseQuery.Aggregate.Method, _ field: KeyPath, - as type: Result.Type = Result.self + as: Result.Type = Result.self ) -> EventLoopFuture - where - Field: QueryableProperty, - Field.Model == Model, - Result: Codable + where Field: QueryableProperty, Field.Model == Model.IDValue, Result: Codable { self.aggregate(method, Model.path(for: field), as: Result.self) } @@ -98,17 +153,32 @@ extension QueryBuilder { public func aggregate( _ method: DatabaseQuery.Aggregate.Method, _ field: FieldKey, - as type: Result.Type = Result.self + as: Result.Type = Result.self ) -> EventLoopFuture where Result: Codable { - self.aggregate(method, [field]) + self.aggregate(method, [field], as: Result.self) } public func aggregate( _ method: DatabaseQuery.Aggregate.Method, _ path: [FieldKey], - as type: Result.Type = Result.self + as: Result.Type = Result.self + ) -> EventLoopFuture + where Result: Codable + { + self.aggregate( + .field( + .extendedPath(path, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), + method + ), + as: Result.self + ) + } + + public func aggregate( + _ aggregate: DatabaseQuery.Aggregate, + as: Result.Type = Result.self ) -> EventLoopFuture where Result: Codable { @@ -122,12 +192,7 @@ extension QueryBuilder { copy.query.sorts = [] // Set custom action. - copy.query.action = .aggregate( - .field( - .path(path, schema: Model.schema), - method - ) - ) + copy.query.action = .aggregate(aggregate) let promise = self.database.eventLoop.makePromise(of: Result.self) copy.run { output in diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+EagerLoad.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+EagerLoad.swift index 2a864b00..2a39a71d 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+EagerLoad.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+EagerLoad.swift @@ -27,13 +27,38 @@ extension EagerLoadBuilder { @discardableResult public func with( _ throughKey: KeyPath, - _ nested: (NestedEagerLoadBuilder) -> () - ) -> Self + _ nested: (NestedEagerLoadBuilder) throws -> () + ) rethrows -> Self where Relation: EagerLoadable, Relation.From == Model { Relation.eagerLoad(throughKey, to: self) let builder = NestedEagerLoadBuilder(builder: self, throughKey) - nested(builder) + try nested(builder) + return self + } + + @discardableResult + public func with( + _ relationKey: KeyPath, + withDeleted: Bool + ) -> Self + where Relation: EagerLoadable, Relation.From == Model + { + Relation.eagerLoad(relationKey, withDeleted: withDeleted, to: self) + return self + } + + @discardableResult + public func with( + _ throughKey: KeyPath, + withDeleted: Bool, + _ nested: (NestedEagerLoadBuilder) throws -> () + ) rethrows -> Self + where Relation: EagerLoadable, Relation.From == Model + { + Relation.eagerLoad(throughKey, withDeleted: withDeleted, to: self) + let builder = NestedEagerLoadBuilder(builder: self, throughKey) + try nested(builder) return self } } diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Filter.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Filter.swift index 9d23c665..4618dcc0 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Filter.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Filter.swift @@ -1,5 +1,24 @@ extension QueryBuilder { // MARK: Filter + + @discardableResult + internal func filter(id: Model.IDValue) -> Self { + if let fields = id as? Fields { + return self.group(.and) { fields.input(to: QueryFilterInput(builder: $0)) } + } else { + return self.filter(\Model._$id == id) + } + } + + @discardableResult + internal func filter(ids: [Model.IDValue]) -> Self { + guard let firstId = ids.first else { return self.limit(0) } + if firstId is Fields { + return self.group(.or) { q in ids.forEach { id in q.group(.and) { (id as! Fields).input(to: QueryFilterInput(builder: $0)) } } } + } else { + return self.filter(\Model._$id ~~ ids) + } + } @discardableResult public func filter( @@ -9,9 +28,10 @@ extension QueryBuilder { ) -> Self where Field: QueryableProperty, Field.Model == Model { - self.filter(.path( + self.filter(.extendedPath( Model.path(for: field), - schema: Model.schemaOrAlias + schema: Model.schemaOrAlias, + space: Model.spaceIfNotAliased ), method, Field.queryValue(value)) } @@ -24,9 +44,10 @@ extension QueryBuilder { ) -> Self where Joined: Schema, Field: QueryableProperty, Field.Model == Joined { - self.filter(.path( + self.filter(.extendedPath( Joined.path(for: field), - schema: Joined.schemaOrAlias + schema: Joined.schemaOrAlias, + space: Joined.spaceIfNotAliased ), method, Field.queryValue(value)) } @@ -64,7 +85,7 @@ extension QueryBuilder { where Value: Codable { self.filter( - .path(fieldPath, schema: Model.schema), + .extendedPath(fieldPath, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), method, .bind(value) ) @@ -86,9 +107,9 @@ extension QueryBuilder { _ rightPath: [FieldKey] ) -> Self { self.filter( - .path(leftPath, schema: Model.schema), + .extendedPath(leftPath, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), method, - .path(rightPath, schema: Model.schema) + .extendedPath(rightPath, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased) ) } diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Join+DirectRelations.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Join+DirectRelations.swift new file mode 100644 index 00000000..0c74aec8 --- /dev/null +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Join+DirectRelations.swift @@ -0,0 +1,224 @@ +extension QueryBuilder { + // MARK: Parent, children, and siblings joins + + /// This will join a foreign table based on a `@Parent` relation + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Planet.query(on: db) + /// .join(from: Planet.self, parent: \.$star) + /// .filter(Star.self, \Star.$name == "Sun") + /// + /// - Parameters: + /// - model: The `Model` to join from + /// - parent: The `ParentProperty` to join + /// - method: The method to use. The default is an inner join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + from model: From.Type, + parent: KeyPath>, + method: DatabaseQuery.Join.Method = .inner + ) -> Self { + self.join(To.self, on: parent.appending(path: \.$id) == \To._$id, method: method) + } + + /// This will join a foreign table based on a `@Parent` relation + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Planet.query(on: db) + /// .join(parent: \.$star) + /// .filter(Star.self, \Star.$name == "Sun") + /// + /// - Parameters: + /// - parent: The `ParentProperty` to join + /// - method: The method to use. The default is an inner join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + parent: KeyPath>, + method: DatabaseQuery.Join.Method = .inner + ) -> Self { + self.join(from: Model.self, parent: parent, method: method) + } + + /// This will join a foreign table based on a `@OptionalParent` relation + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Planet.query(on: db) + /// .join(from: Planet.self, parent: \.$star) + /// .filter(Star.self, \Star.$name == "Sun") + /// + /// - Parameters: + /// - model: The `Model` to join from + /// - parent: The `OptionalParentProperty` to join + /// - method: The method to use. The default is an inner join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + from model: From.Type, + parent: KeyPath>, + method: DatabaseQuery.Join.Method = .inner + ) -> Self { + self.join(To.self, on: parent.appending(path: \.$id) == \To._$id, method: method) + } + + /// This will join a foreign table based on a `@OptionalParent` relation + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Planet.query(on: db) + /// .join(parent: \.$star) + /// .filter(Star.self, \Star.$name == "Sun") + /// + /// - Parameters: + /// - parent: The `OptionalParentProperty` to join + /// - method: The method to use. The default is an inner join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + parent: KeyPath>, + method: DatabaseQuery.Join.Method = .inner + ) -> Self { + self.join(from: Model.self, parent: parent, method: method) + } + + /// This will join a foreign table based on a `@OptionalChild` relation + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Planet.query(on: db) + /// .join(child: \.$governor) + /// .filter(Governor.self, \Governor.$name == "John Doe") + /// + /// - Parameters: + /// - model: The `Model` to join from + /// - child: The `ChildProperty` to join + /// - method: The method to use. The default is an inner join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + from model: From.Type, + child: KeyPath>, + method: DatabaseQuery.Join.Method = .inner + ) -> Self { + switch From()[keyPath: child].parentKey { + case .optional(let parent): return self.join(To.self, on: \From._$id == parent.appending(path: \.$id), method: method) + case .required(let parent): return self.join(To.self, on: \From._$id == parent.appending(path: \.$id), method: method) + } + } + + /// This will join a foreign table based on a `@OptionalChild` relation + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Planet.query(on: db) + /// .join(child: \.$governor) + /// .filter(Governor.self, \Governor.$name == "John Doe") + /// + /// - Parameters: + /// - child: The `ChildProperty` to join + /// - method: The method to use. The default is an inner join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + child: KeyPath>, + method: DatabaseQuery.Join.Method = .inner + ) -> Self { + self.join(from: Model.self, child: child, method: method) + } + + /// This will join a foreign table based on a `@Children` relation + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Star.query(on: db) + /// .join(from: Star.self, children: \.$planets) + /// .filter(Planet.self, \Planet.$name == "Earth") + /// + /// - Parameters: + /// - model: The `Model` to join from + /// - children: The `ChildrenProperty` to join + /// - method: The method to use. The default is an inner join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + from model: From.Type, + children: KeyPath>, + method: DatabaseQuery.Join.Method = .inner + ) -> Self { + switch From()[keyPath: children].parentKey { + case .optional(let parent): return self.join(To.self, on: \From._$id == parent.appending(path: \.$id), method: method) + case .required(let parent): return self.join(To.self, on: \From._$id == parent.appending(path: \.$id), method: method) + } + } + + /// This will join a foreign table based on a `@Children` relation + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Star.query(on: db) + /// .join(children: \.$planets) + /// .filter(Planet.self, \Planet.$name == "Earth") + /// + /// - Parameters: + /// - children: The `ChildrenProperty` to join + /// - method: The method to use. The default is an inner join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + children: KeyPath>, + method: DatabaseQuery.Join.Method = .inner + ) -> Self { + self.join(from: Model.self, children: children, method: method) + } + + /// This will join the foreign table based on a `@Siblings`relation + /// This will result in joining two tables. The Pivot table and the wanted model table + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Star.query(on: db) + /// .join(from: Star.self, siblings: \.$tags) + /// .filter(Tag.self, \Tag.$name == "Something") + /// + /// - Parameters: + /// - model: The `Model` to join form + /// - siblings: The `SiblingsProperty` to join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + from model: From.Type, + siblings: KeyPath> + ) -> Self + where From: FluentKit.Model, To: FluentKit.Model, Through: FluentKit.Model + { + let siblings = From()[keyPath: siblings] + + return self.join(Through.self, on: siblings.from.appending(path: \.$id) == \From._$id) + .join(To.self, on: siblings.to.appending(path: \.$id) == \To._$id) + } + + /// This will join the foreign table based on a `@Siblings`relation + /// This will result in joining two tables. The Pivot table and the wanted model table + /// + /// This will not decode the joined data, but can be used in order to filter. + /// + /// Star.query(on: db) + /// .join(siblings: \.$tags) + /// .filter(Tag.self, \Tag.$name == "Something") + /// + /// - Parameters: + /// - siblings: The `SiblingsProperty` to join + /// - Returns: A new `QueryBuilder` + @discardableResult + public func join( + siblings: KeyPath> + ) -> Self + where To: FluentKit.Model, Through: FluentKit.Model + { + self.join(from: Model.self, siblings: siblings) + } +} diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Join.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Join.swift index bfa8ff4a..d8f50e01 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Join.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Join.swift @@ -1,281 +1,79 @@ extension QueryBuilder { - // MARK: Join + // MARK: - High-level joins + /// Performs a join with a condition containing a single expression not @discardableResult - public func join( + public func join( _ foreign: Foreign.Type, - on filter: JoinFilter, + on filter: ComplexJoinFilter, method: DatabaseQuery.Join.Method = .inner - ) -> Self - where Foreign: Schema, Local: Schema - { - self.join(Foreign.self, filter.foreign, to: Local.self, filter.local , method: method) + ) -> Self where Foreign: Schema { + self.join(Foreign.self, [filter.filter], method: method) } + /// Performs a join with a condition containing multiple subexpressions. @discardableResult - public func join( - _ local: Local.Type, + public func join( _ foreign: Foreign.Type, - on join: DatabaseQuery.Join + on filter: ComplexJoinFilterGroup, + method: DatabaseQuery.Join.Method = .inner ) -> Self - where Foreign: Schema, Local: Schema + where Foreign: Schema { - self.models.append(Foreign.self) - self.query.joins.append(join) - return self - } - - /// This will join a foreign table based on a `@Parent` relation - /// - /// This will not decode the joined data, but can be used in order to filter. - /// - /// Planet.query(on: db) - /// .join(from: Planet.self, parent: \.$star) - /// .filter(Star.self, \Star.$name == "Sun") - /// - /// - Parameters: - /// - model: The `Model` to join from - /// - parent: The `ParentProperty` to join - /// - method: The method to use. The default is an inner join - /// - Returns: A new `QueryBuilder` - @discardableResult - public func join( - from model: From.Type, - parent: KeyPath>, - method: DatabaseQuery.Join.Method = .inner - ) -> Self { - join(To.self, on: parent.appending(path: \.$id) == \To._$id, method: method) + self.join(Foreign.self, filter.filters.map(\.filter), method: method) } - /// This will join a foreign table based on a `@Parent` relation - /// - /// This will not decode the joined data, but can be used in order to filter. - /// - /// Planet.query(on: db) - /// .join(parent: \.$star) - /// .filter(Star.self, \Star.$name == "Sun") - /// - /// - Parameters: - /// - parent: The `ParentProperty` to join - /// - method: The method to use. The default is an inner join - /// - Returns: A new `QueryBuilder` - @discardableResult - public func join( - parent: KeyPath>, - method: DatabaseQuery.Join.Method = .inner - ) -> Self { - join(from: Model.self, parent: parent, method: method) - } - - /// This will join a foreign table based on a `@OptionalParent` relation - /// - /// This will not decode the joined data, but can be used in order to filter. - /// - /// Planet.query(on: db) - /// .join(from: Planet.self, parent: \.$star) - /// .filter(Star.self, \Star.$name == "Sun") - /// - /// - Parameters: - /// - model: The `Model` to join from - /// - parent: The `OptionalParentProperty` to join - /// - method: The method to use. The default is an inner join - /// - Returns: A new `QueryBuilder` - @discardableResult - public func join( - from model: From.Type, - parent: KeyPath>, - method: DatabaseQuery.Join.Method = .inner - ) -> Self { - join(To.self, on: parent.appending(path: \.$id) == \To._$id, method: method) - } - - /// This will join a foreign table based on a `@OptionalParent` relation - /// - /// This will not decode the joined data, but can be used in order to filter. - /// - /// Planet.query(on: db) - /// .join(parent: \.$star) - /// .filter(Star.self, \Star.$name == "Sun") - /// - /// - Parameters: - /// - parent: The `OptionalParentProperty` to join - /// - method: The method to use. The default is an inner join - /// - Returns: A new `QueryBuilder` - @discardableResult - public func join( - parent: KeyPath>, - method: DatabaseQuery.Join.Method = .inner - ) -> Self { - join(from: Model.self, parent: parent, method: method) - } + // MARK: - Fundamental join methods - /// This will join a foreign table based on a `@OptionalChild` relation + /// `.join(Foreign.self, filters, method: method)` /// - /// This will not decode the joined data, but can be used in order to filter. - /// - /// Planet.query(on: db) - /// .join(child: \.$governor) - /// .filter(Governor.self, \Governor.$name == "John Doe") - /// - /// - Parameters: - /// - model: The `Model` to join from - /// - child: The `ChildProperty` to join - /// - method: The method to use. The default is an inner join - /// - Returns: A new `QueryBuilder` - @discardableResult - public func join( - from model: From.Type, - child: KeyPath>, - method: DatabaseQuery.Join.Method = .inner - ) -> Self { - switch From()[keyPath: child].parentKey { - case .optional(let parent): return join(To.self, on: \From._$id == parent.appending(path: \.$id), method: method) - case .required(let parent): return join(To.self, on: \From._$id == parent.appending(path: \.$id), method: method) - } - } - - /// This will join a foreign table based on a `@OptionalChild` relation - /// - /// This will not decode the joined data, but can be used in order to filter. - /// - /// Planet.query(on: db) - /// .join(child: \.$governor) - /// .filter(Governor.self, \Governor.$name == "John Doe") - /// - /// - Parameters: - /// - child: The `ChildProperty` to join - /// - method: The method to use. The default is an inner join - /// - Returns: A new `QueryBuilder` - @discardableResult - public func join( - child: KeyPath>, - method: DatabaseQuery.Join.Method = .inner - ) -> Self { - join(from: Model.self, child: child, method: method) - } - - /// This will join a foreign table based on a `@Children` relation - /// - /// This will not decode the joined data, but can be used in order to filter. - /// - /// Star.query(on: db) - /// .join(from: Star.self, children: \.$planets) - /// .filter(Planet.self, \Planet.$name == "Earth") - /// - /// - Parameters: - /// - model: The `Model` to join from - /// - children: The `ChildrenProperty` to join - /// - method: The method to use. The default is an inner join - /// - Returns: A new `QueryBuilder` - @discardableResult - public func join( - from model: From.Type, - children: KeyPath>, - method: DatabaseQuery.Join.Method = .inner - ) -> Self { - switch From()[keyPath: children].parentKey { - case .optional(let parent): return join(To.self, on: \From._$id == parent.appending(path: \.$id), method: method) - case .required(let parent): return join(To.self, on: \From._$id == parent.appending(path: \.$id), method: method) - } - } - - /// This will join a foreign table based on a `@Children` relation - /// - /// This will not decode the joined data, but can be used in order to filter. - /// - /// Star.query(on: db) - /// .join(children: \.$planets) - /// .filter(Planet.self, \Planet.$name == "Earth") - /// - /// - Parameters: - /// - children: The `ChildrenProperty` to join - /// - method: The method to use. The default is an inner join - /// - Returns: A new `QueryBuilder` + /// Joins against `Foreign` with the specified method and using the given filter(s) as the join condition. @discardableResult - public func join( - children: KeyPath>, + public func join( + _ foreign: Foreign.Type, + _ filters: [DatabaseQuery.Filter], method: DatabaseQuery.Join.Method = .inner - ) -> Self { - join(from: Model.self, children: children, method: method) - } - - /// This will join the foreign table based on a `@Siblings`relation - /// This will result in joining two tables. The Pivot table and the wanted model table - /// - /// This will not decode the joined data, but can be used in order to filter. - /// - /// Star.query(on: db) - /// .join(from: Star.self, siblings: \.$tags) - /// .filter(Tag.self, \Tag.$name == "Something") - /// - /// - Parameters: - /// - model: The `Model` to join form - /// - siblings: The `SiblingsProperty` to join - /// - Returns: A new `QueryBuilder` - @discardableResult - public func join( - from model: From.Type, - siblings: KeyPath> ) -> Self - where From: FluentKit.Model, To: FluentKit.Model, Through: FluentKit.Model + where Foreign: Schema { - let siblings = From()[keyPath: siblings] - return join(Through.self, on: siblings.from.appending(path: \.$id) == \From._$id) - .join(To.self, on: siblings.to.appending(path: \.$id) == \To._$id) + self.join(Foreign.self, on: .advancedJoin(schema: Foreign.schema, space: Foreign.space, alias: Foreign.alias, method, filters: filters)) } - - /// This will join the foreign table based on a `@Siblings`relation - /// This will result in joining two tables. The Pivot table and the wanted model table + + /// `.join(Foreign.self, on: databaseJoin)` /// - /// This will not decode the joined data, but can be used in order to filter. + /// Joins against `Foreign` using the given join description. /// - /// Star.query(on: db) - /// .join(siblings: \.$tags) - /// .filter(Tag.self, \Tag.$name == "Something") + /// In debug builds, the join is checked (when possible) to verify that it corresponds correctly to the provided + /// model type; an assertion failure occurs if there is a mismatch. This check is not performed in release builds. /// - /// - Parameters: - /// - siblings: The `SiblingsProperty` to join - /// - Returns: A new `QueryBuilder` - @discardableResult - public func join( - siblings: KeyPath> - ) -> Self - where To: FluentKit.Model, Through: FluentKit.Model - { - join(from: Model.self, siblings: siblings) - } - + /// - Warning: The space, schema, and alias specified by the join description _must_ match the `space`, `schema`, + /// and `alias` properties of the provided `Foreign` type. Violation of this rule will cause runtime errors in + /// most kinds of queries, and incorrect data may be returned from queries which do run. + /// + /// - Tip: If you find that the requirements of your join are incompatible with this rule, you're probably trying + /// to do something that's too complex for Fluent's API to accomodate. The recommended solution is to bypass + /// Fluent and execute the desired query more directly, either via SQLKit when working with an SQL database, or + /// via MongoKitten if using MongoDB. @discardableResult - private func join( + public func join( _ foreign: Foreign.Type, - _ foreignField: FieldKey, - to local: Local.Type, - _ localField: FieldKey, - method: DatabaseQuery.Join.Method = .inner + on join: DatabaseQuery.Join ) -> Self - where Foreign: Schema, Local: Schema + where Foreign: Schema { - self.join(Foreign.self, [foreignField], to: Local.self, [localField], method: method) - } + #if DEBUG + switch join { + case let .join(jschema, jalias, _, _, _): + assert(jschema == Foreign.schema && jalias == Foreign.alias, "Join specification does not match provided Model type \(Foreign.self)") + case let .extendedJoin(jschema, jspace, jalias, _, _, _), let .advancedJoin(jschema, jspace, jalias, _, _): + assert(jspace == Foreign.space && jschema == Foreign.schema && jalias == Foreign.alias, "Join specification does not match provided Model type \(Foreign.self)") + case.custom(_): + break // We can't validate custom joins + } + #endif - @discardableResult - private func join( - _ foreign: Foreign.Type, - _ foreignPath: [FieldKey], - to local: Local.Type, - _ localPath: [FieldKey], - method: DatabaseQuery.Join.Method = .inner - ) -> Self - where Foreign: Schema, Local: Schema - { self.models.append(Foreign.self) - self.query.joins.append(.join( - schema: Foreign.schema, - alias: Foreign.alias, - method, - foreign: .path(foreignPath, schema: Foreign.schemaOrAlias), - local: .path(localPath, schema: Local.schemaOrAlias) - )) + self.query.joins.append(join) return self } } @@ -284,86 +82,182 @@ extension QueryBuilder { public func == ( lhs: KeyPath, rhs: KeyPath -) -> JoinFilter - where - ForeignField: QueryableProperty, - ForeignField.Model == Foreign, - LocalField: QueryableProperty, - LocalField.Model == Local, - ForeignField.Value == LocalField.Value +) -> ComplexJoinFilter where + Foreign: Schema, Local: Schema, ForeignField: QueryableProperty, LocalField: QueryableProperty, + ForeignField.Value == LocalField.Value { - .init(foreign: Foreign.path(for: rhs), local: Local.path(for: lhs)) + .init(lhs, .equal, rhs) } public func == ( lhs: KeyPath, rhs: KeyPath -) -> JoinFilter - where - ForeignField: QueryableProperty, - ForeignField.Model == Foreign, - LocalField: QueryableProperty, - LocalField.Model == Local, - ForeignField.Value == Optional +) -> ComplexJoinFilter where + Foreign: Schema, Local: Schema, ForeignField: QueryableProperty, LocalField: QueryableProperty, + ForeignField.Value == LocalField.Value? { - .init(foreign: Foreign.path(for: rhs), local: Local.path(for: lhs)) + .init(lhs, .equal, rhs) } - public func == ( lhs: KeyPath, rhs: KeyPath -) -> JoinFilter - where - ForeignField: QueryableProperty, - ForeignField.Model == Foreign, - LocalField: QueryableProperty, - LocalField.Model == Local, - Optional == LocalField.Value +) -> ComplexJoinFilter where + Foreign: Schema, Local: Schema, ForeignField: QueryableProperty, LocalField: QueryableProperty, + ForeignField.Value? == LocalField.Value { - .init(foreign: Foreign.path(for: rhs), local: Local.path(for: lhs)) + .init(lhs, .equal, rhs) } -// MARK: Foreign == Local +// MARK: Local != Foreign -public func == ( - lhs: KeyPath, rhs: KeyPath -) -> JoinFilter - where - ForeignField: QueryableProperty, - ForeignField.Model == Foreign, - LocalField: QueryableProperty, - LocalField.Model == Local, - ForeignField.Value == LocalField.Value +public func != ( + lhs: KeyPath, rhs: KeyPath +) -> ComplexJoinFilter where + Foreign: Schema, Local: Schema, ForeignField: QueryableProperty, LocalField: QueryableProperty, + ForeignField.Value == LocalField.Value { - .init(foreign: Foreign.path(for: lhs), local: Local.path(for: rhs)) + .init(lhs, .notEqual, rhs) } -public func == ( - lhs: KeyPath, rhs: KeyPath -) -> JoinFilter - where - ForeignField: QueryableProperty, - ForeignField.Model == Foreign, - LocalField: QueryableProperty, - LocalField.Model == Local, - ForeignField.Value == Optional +public func != ( + lhs: KeyPath, rhs: KeyPath +) -> ComplexJoinFilter where + Foreign: Schema, Local: Schema, ForeignField: QueryableProperty, LocalField: QueryableProperty, + ForeignField.Value == LocalField.Value? { - .init(foreign: Foreign.path(for: lhs), local: Local.path(for: rhs)) + .init(lhs, .notEqual, rhs) } - -public func == ( - lhs: KeyPath, rhs: KeyPath -) -> JoinFilter - where - ForeignField: QueryableProperty, - ForeignField.Model == Foreign, - LocalField: QueryableProperty, - LocalField.Model == Local, - Optional == LocalField.Value +public func != ( + lhs: KeyPath, rhs: KeyPath +) -> ComplexJoinFilter where + Foreign: Schema, Local: Schema, ForeignField: QueryableProperty, LocalField: QueryableProperty, + ForeignField.Value? == LocalField.Value { - .init(foreign: Foreign.path(for: lhs), local: Local.path(for: rhs)) + .init(lhs, .notEqual, rhs) +} + +// MARK: Filter && combinator + +/// `a ==/!= b && c ==/!= d` +public func && (lhs: ComplexJoinFilter, rhs: ComplexJoinFilter) -> ComplexJoinFilterGroup { + .init(filters: [lhs, rhs]) +} + +/// `a ==/!= b && c >/< 1` +public func && (lhs: ComplexJoinFilter, rhs: ModelValueFilter) -> ComplexJoinFilterGroup { + .init(filters: [lhs, .init(rhs)]) +} + +/// `c >/< 1 && a ==/!= b` +public func && (lhs: ModelValueFilter, rhs: ComplexJoinFilter) -> ComplexJoinFilterGroup { + .init(filters: [.init(lhs), rhs]) +} + +/// `(a == b && c != d) && e != f` +public func && (lhs: ComplexJoinFilterGroup, rhs: ComplexJoinFilter) -> ComplexJoinFilterGroup { + .init(filters: lhs.filters + [rhs]) +} + +/// `(a == b && c != d) && e < 1` +public func && (lhs: ComplexJoinFilterGroup, rhs: ModelValueFilter) -> ComplexJoinFilterGroup { + .init(filters: lhs.filters + [.init(rhs)]) +} + +/// `e != f && (a == b && c != d)` +public func && (lhs: ComplexJoinFilter, rhs: ComplexJoinFilterGroup) -> ComplexJoinFilterGroup { + .init(filters: [lhs] + rhs.filters) } +/// `e > 1 && (a == b && c != d)` +public func && (lhs: ModelValueFilter, rhs: ComplexJoinFilterGroup) -> ComplexJoinFilterGroup { + .init(filters: [.init(lhs)] + rhs.filters) +} + +// MARK: - Struct definitions + +/// This wrapper type allows the compiler to better constrain the overload set for global operators, reducing +/// compile times and avoiding "this expression is too complex..." errors. +public struct ComplexJoinFilter { + let filter: DatabaseQuery.Filter + + init(filter: DatabaseQuery.Filter) { + self.filter = filter + } + + init(_ filter: ModelValueFilter) { + self.init(filter: .value( + .extendedPath(filter.path, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), + filter.method, + filter.value + )) + } + + init( + _ lhs: KeyPath, _ method: DatabaseQuery.Filter.Method, _ rhs: KeyPath + ) where Left: Schema, Right: Schema, LField: QueryableProperty, RField: QueryableProperty, LField.Value == RField.Value { + self.init(filter: .field( + .extendedPath(Left.path(for: lhs), schema: Left.schemaOrAlias, space: Left.spaceIfNotAliased), + method, + .extendedPath(Right.path(for: rhs), schema: Right.schemaOrAlias, space: Right.spaceIfNotAliased) + )) + } + + init( + _ lhs: KeyPath, _ method: DatabaseQuery.Filter.Method, _ rhs: KeyPath + ) where Left: Schema, Right: Schema, LField: QueryableProperty, RField: QueryableProperty, LField.Value? == RField.Value { + self.init(filter: .field( + .extendedPath(Left.path(for: lhs), schema: Left.schemaOrAlias, space: Left.spaceIfNotAliased), + method, + .extendedPath(Right.path(for: rhs), schema: Right.schemaOrAlias, space: Right.spaceIfNotAliased) + )) + } + + init( + _ lhs: KeyPath, _ method: DatabaseQuery.Filter.Method, _ rhs: KeyPath + ) where Left: Schema, Right: Schema, LField: QueryableProperty, RField: QueryableProperty, LField.Value == RField.Value? { + self.init(filter: .field( + .extendedPath(Left.path(for: lhs), schema: Left.schemaOrAlias, space: Left.spaceIfNotAliased), + method, + .extendedPath(Right.path(for: rhs), schema: Right.schemaOrAlias, space: Right.spaceIfNotAliased) + )) + } +} + +/// This wrapper type allows the compiler to better constrain the overload set for global operators, reducing +/// compile times and avoiding "this expression is too complex..." errors. +public struct ComplexJoinFilterGroup { + let filters: [ComplexJoinFilter] +} + +// MARK: - Legacy join filter type support + +extension QueryBuilder { + @discardableResult + public func join( + _ local: Local.Type, + _ foreign: Foreign.Type, + on filter: DatabaseQuery.Join + ) -> Self + where Local: Schema, Foreign: Schema + { + self.join(Foreign.self, on: filter) + } + + @discardableResult + public func join( + _ foreign: Foreign.Type, + on filter: JoinFilter, + method: DatabaseQuery.Join.Method = .inner + ) -> Self + where Foreign: Schema, Local: Schema + { + self.join(Foreign.self, on: ComplexJoinFilter(filter: .field( + .extendedPath(filter.foreign, schema: Foreign.schemaOrAlias, space: Foreign.spaceIfNotAliased), + .equal, + .extendedPath(filter.local, schema: Local.schemaOrAlias, space: Local.spaceIfNotAliased) + )), method: method) + } +} public struct JoinFilter where Foreign: Fields, Local: Fields, Value: Codable diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Paginate.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Paginate.swift index 7b33fbc7..eb54e32b 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Paginate.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Paginate.swift @@ -1,3 +1,5 @@ +import NIOCore + extension QueryBuilder { /// Returns a single `Page` out of the complete result set according to the supplied `PageRequest`. /// @@ -9,13 +11,27 @@ extension QueryBuilder { public func paginate( _ request: PageRequest ) -> EventLoopFuture> { + page(withIndex: request.page, size: request.per) + } + + /// Returns a single `Page` out of the complete result set. + /// + /// This method will first `count()` the result set, then request a subset of the results using `range()` and `all()`. + /// + /// - Parameters: + /// - page: The index of the page. + /// - per: The size of the page. + /// - Returns: A single `Page` of the result set containing the requested items and page metadata. + public func page( + withIndex page: Int, + size per: Int) -> EventLoopFuture> { let trimmedRequest: PageRequest = { guard let pageSizeLimit = database.context.pageSizeLimit else { - return .init(page: Swift.max(request.page, 1), per: Swift.max(request.per, 1)) + return .init(page: Swift.max(page, 1), per: Swift.max(per, 1)) } return .init( - page: Swift.max(request.page, 1), - per: Swift.max(Swift.min(request.per, pageSizeLimit), 1) + page: Swift.max(page, 1), + per: Swift.max(Swift.min(per, pageSizeLimit), 1) ) }() let count = self.count() @@ -76,6 +92,12 @@ public struct PageMetadata: Codable { /// Total number of items available. public let total: Int + /// Computed total number of pages with `1` being the minimum. + public var pageCount: Int { + let count = Int((Double(self.total)/Double(self.per)).rounded(.up)) + return count < 1 ? 1 : count + } + /// Creates a new `PageMetadata` instance. /// /// - Parameters: diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Range.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Range.swift index aa5b3505..516c26a5 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Range.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Range.swift @@ -6,6 +6,7 @@ extension QueryBuilder { /// query.range(2..<5) // returns at most 3 results, offset by 2 /// /// - returns: Query builder for chaining. + @discardableResult public func range(_ range: Range) -> Self { return self.range(lower: range.lowerBound, upper: range.upperBound - 1) } @@ -15,6 +16,7 @@ extension QueryBuilder { /// query.range(...5) // returns at most 6 results /// /// - returns: Query builder for chaining. + @discardableResult public func range(_ range: PartialRangeThrough) -> Self { return self.range(upper: range.upperBound) } @@ -24,6 +26,7 @@ extension QueryBuilder { /// query.range(..<5) // returns at most 5 results /// /// - returns: Query builder for chaining. + @discardableResult public func range(_ range: PartialRangeUpTo) -> Self { return self.range(upper: range.upperBound - 1) } @@ -33,6 +36,7 @@ extension QueryBuilder { /// query.range(5...) // offsets the result by 5 /// /// - returns: Query builder for chaining. + @discardableResult public func range(_ range: PartialRangeFrom) -> Self { return self.range(lower: range.lowerBound) } @@ -42,6 +46,7 @@ extension QueryBuilder { /// query.range(2..<5) // returns at most 3 results, offset by 2 /// /// - returns: Query builder for chaining. + @discardableResult public func range(_ range: ClosedRange) -> Self { return self.range(lower: range.lowerBound, upper: range.upperBound) } @@ -52,6 +57,7 @@ extension QueryBuilder { /// - lower: Amount to offset the query by. /// - upper: `upper` - `lower` = maximum results. /// - returns: Query builder for chaining. + @discardableResult public func range(lower: Int = 0, upper: Int? = nil) -> Self { self.query.offsets.append(.count(lower)) upper.flatMap { upper in diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Set.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Set.swift index aba210f0..af710439 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Set.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Set.swift @@ -21,9 +21,31 @@ extension QueryBuilder { _ field: KeyPath, to value: Field.Value ) -> Self - where - Field: QueryableProperty, - Field.Model == Model + where Field: QueryableProperty, Field.Model == Model.IDValue + { + if self.query.input.isEmpty { + self.query.input = [.dictionary([:])] + } + + switch self.query.input[0] { + case .dictionary(var existing): + let path = Model.path(for: field) + assert(path.count == 1, "Set on nested properties is not yet supported.") + existing[path[0]] = Field.queryValue(value) + self.query.input[0] = .dictionary(existing) + default: + fatalError() + } + + return self + } + + @discardableResult + public func set( + _ field: KeyPath, + to value: Field.Value + ) -> Self + where Field: QueryableProperty, Field.Model == Model { if self.query.input.isEmpty { self.query.input = [.dictionary([:])] diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Sort.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Sort.swift index 6a3d517c..e18d863b 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Sort.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Sort.swift @@ -1,6 +1,7 @@ extension QueryBuilder { // MARK: Sort + @discardableResult public func sort( _ field: KeyPath, _ direction: DatabaseQuery.Sort.Direction = .ascending @@ -12,6 +13,17 @@ extension QueryBuilder { self.sort(Model.path(for: field), direction) } + @discardableResult + public func sort( + _ field: KeyPath>, + _ direction: DatabaseQuery.Sort.Direction = .ascending + ) -> Self + where Field: QueryableProperty + { + self.sort(Model.path(for: field), direction) + } + + @discardableResult public func sort( _ path: FieldKey, _ direction: DatabaseQuery.Sort.Direction = .ascending @@ -19,13 +31,15 @@ extension QueryBuilder { self.sort([path], direction) } + @discardableResult public func sort( _ path: [FieldKey], _ direction: DatabaseQuery.Sort.Direction = .ascending ) -> Self { - self.sort(.path(path, schema: Model.schema), direction) + self.sort(.extendedPath(path, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), direction) } + @discardableResult public func sort( _ joined: Joined.Type, _ field: KeyPath, @@ -40,6 +54,7 @@ extension QueryBuilder { self.sort(Joined.self, Joined.path(for: field), direction, alias: alias) } + @discardableResult public func sort( _ model: Joined.Type, _ path: FieldKey, @@ -51,6 +66,7 @@ extension QueryBuilder { self.sort(Joined.self, [path], direction) } + @discardableResult public func sort( _ model: Joined.Type, _ path: [FieldKey], @@ -59,9 +75,10 @@ extension QueryBuilder { ) -> Self where Joined: Schema { - self.sort(.path(path, schema: Joined.schemaOrAlias), direction) + self.sort(.extendedPath(path, schema: Joined.schemaOrAlias, space: Joined.spaceIfNotAliased), direction) } + @discardableResult public func sort( _ field: DatabaseQuery.Field, _ direction: DatabaseQuery.Sort.Direction @@ -70,6 +87,7 @@ extension QueryBuilder { return self } + @discardableResult public func sort(_ sort: DatabaseQuery.Sort) -> Self { self.query.sorts.append(sort) return self diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder.swift b/Sources/FluentKit/Query/Builder/QueryBuilder.swift index 2c71ca74..1debee6a 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder.swift @@ -14,7 +14,7 @@ public final class QueryBuilder public convenience init(database: Database) { self.init( - query: .init(schema: Model.schema), + query: .init(schema: Model.schema, space: Model.space), database: database, models: [Model.self] ) @@ -35,11 +35,13 @@ public final class QueryBuilder self.includeDeleted = includeDeleted self.shouldForceDelete = shouldForceDelete // Pass through custom ID key for database if used. - let idKey = Model()._$id.key - switch idKey { - case .id: break - default: - self.query.customIDKey = idKey + if Model().anyID is AnyQueryableProperty { + switch Model()._$id.key { + case .id: break + case let other: self.query.customIDKey = other + } + } else { + self.query.customIDKey = .string("") } } @@ -66,7 +68,7 @@ public final class QueryBuilder internal func addFields(for model: (Schema & Fields).Type, to query: inout DatabaseQuery) { query.fields += model.keys.map { path in - .path([path], schema: model.schemaOrAlias) + .extendedPath([path], schema: model.schemaOrAlias, space: model.spaceIfNotAliased) } } @@ -81,7 +83,7 @@ public final class QueryBuilder public func field(_ joined: Joined.Type, _ field: KeyPath) -> Self where Joined: Schema, Field: QueryableProperty, Field.Model == Joined { - self.query.fields.append(.path(Joined.path(for: field), schema: Joined.schema)) + self.query.fields.append(.extendedPath(Joined.path(for: field), schema: Joined.schemaOrAlias, space: Joined.spaceIfNotAliased)) return self } @@ -174,7 +176,7 @@ public final class QueryBuilder Field.Model == Model { let copy = self.copy() - copy.query.fields = [.path(Model.path(for: key), schema: Model.schema)] + copy.query.fields = [.extendedPath(Model.path(for: key), schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased)] return copy.all().map { $0.map { $0[keyPath: key].value! @@ -192,7 +194,7 @@ public final class QueryBuilder Field.Model == Joined { let copy = self.copy() - copy.query.fields = [.path(Joined.path(for: field), schema: Joined.schemaOrAlias)] + copy.query.fields = [.extendedPath(Joined.path(for: field), schema: Joined.schemaOrAlias, space: Joined.spaceIfNotAliased)] return copy.all().flatMapThrowing { try $0.map { try $0.joined(Joined.self)[keyPath: field].value! @@ -220,7 +222,7 @@ public final class QueryBuilder let done = self.run { output in onOutput(.init(catching: { let model = Model() - try model.output(from: output.schema(Model.schema)) + try model.output(from: output.qualifiedSchema(space: Model.spaceIfNotAliased, Model.schemaOrAlias)) all.append(model) return model })) diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Field.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Field.swift index 6e5f5153..d73b0c28 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Field.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Field.swift @@ -1,6 +1,7 @@ extension DatabaseQuery { public enum Field { case path([FieldKey], schema: String) + case extendedPath([FieldKey], schema: String, space: String?) case custom(Any) } } @@ -10,6 +11,8 @@ extension DatabaseQuery.Field: CustomStringConvertible { switch self { case .path(let path, let schema): return "\(schema)\(path)" + case .extendedPath(let path, let schema, let space): + return "\(space.map { "\($0)." } ?? "")\(schema)\(path)" case .custom(let custom): return "custom(\(custom))" } diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Filter.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Filter.swift index c928da9a..0e267095 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Filter.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Filter.swift @@ -25,13 +25,13 @@ extension DatabaseQuery { return .order(inverse: true, equality: true) } - /// LHS is equal to RHS + /// LHS is equal/not equal to RHS case equality(inverse: Bool) - /// LHS is greater than RHS + /// LHS is greater/less than [or equal to] RHS case order(inverse: Bool, equality: Bool) - /// LHS exists in RHS + /// LHS exists in/doesn't exist in RHS case subset(inverse: Bool) public enum Contains { @@ -40,7 +40,7 @@ extension DatabaseQuery { case anywhere } - /// RHS exists in LHS + /// RHS is [anchored] substring/isn't [anchored] substring of LHS case contains(inverse: Bool, Contains) /// Custom method @@ -65,10 +65,13 @@ extension DatabaseQuery.Filter: CustomStringConvertible { switch self { case .value(let field, let method, let value): return "\(field) \(method) \(value)" + case .field(let fieldA, let method, let fieldB): return "\(fieldA) \(method) \(fieldB)" + case .group(let filters, let relation): - return "\(relation) \(filters)" + return filters.map{ "(\($0.description))" }.joined(separator: " \(relation) ") + case .custom(let any): return "custom(\(any))" } @@ -80,22 +83,41 @@ extension DatabaseQuery.Filter.Method: CustomStringConvertible { switch self { case .equality(let inverse): return inverse ? "!=" : "=" + case .order(let inverse, let equality): if equality { return inverse ? "<=" : ">=" } else { return inverse ? "<" : ">" } + case .subset(let inverse): return inverse ? "!~~" : "~~" + case .contains(let inverse, let contains): - return (inverse ? "!" : "") + "\(contains)" + return inverse ? "!\(contains)" : "\(contains)" + case .custom(let any): return "custom(\(any))" } } } +extension DatabaseQuery.Filter.Method.Contains: CustomStringConvertible { + public var description: String { + switch self { + case .prefix: + return "startswith" + + case .suffix: + return "endswith" + + case .anywhere: + return "contains" + } + } +} + extension DatabaseQuery.Filter.Relation: CustomStringConvertible { public var description: String { switch self { diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Join.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Join.swift index 6f044811..720f20ad 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Join.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Join.swift @@ -13,6 +13,24 @@ extension DatabaseQuery { foreign: Field, local: Field ) + + case extendedJoin( + schema: String, + space: String?, + alias: String?, + Method, + foreign: Field, + local: Field + ) + + case advancedJoin( + schema: String, + space: String?, + alias: String?, + Method, + filters: [Filter] + ) + case custom(Any) } } @@ -21,17 +39,22 @@ extension DatabaseQuery.Join: CustomStringConvertible { public var description: String { switch self { case .join(let schema, let alias, let method, let foreign, let local): - let schemaString: String - if let alias = alias { - schemaString = "\(schema) as \(alias)" - } else { - schemaString = "\(schema)" - } - return "\(schemaString) \(method) on \(foreign) == \(local)" + return "\(self.schemaDescription(schema: schema, alias: alias)) \(method) on \(foreign) == \(local)" + + case .extendedJoin(let schema, let space, let alias, let method, let foreign, let local): + return "\(self.schemaDescription(space: space, schema: schema, alias: alias)) \(method) on \(foreign) == \(local)" + + case .advancedJoin(let schema, let space, let alias, let method, let filters): + return "\(self.schemaDescription(space: space, schema: schema, alias: alias)) \(method) on \(filters)" + case .custom(let custom): return "custom(\(custom))" } } + + private func schemaDescription(space: String? = nil, schema: String, alias: String?) -> String { + [space, schema].compactMap({ $0 }).joined(separator: ".") + (alias.map { " as \($0)" } ?? "") + } } extension DatabaseQuery.Join.Method: CustomStringConvertible { diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery.swift b/Sources/FluentKit/Query/Database/DatabaseQuery.swift index 6ddb7f1f..e956ae73 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery.swift @@ -1,5 +1,6 @@ public struct DatabaseQuery { public var schema: String + public var space: String? public var customIDKey: FieldKey? public var isUnique: Bool public var fields: [Field] @@ -11,8 +12,9 @@ public struct DatabaseQuery { public var limits: [Limit] public var offsets: [Offset] - init(schema: String) { + init(schema: String, space: String? = nil) { self.schema = schema + self.space = space self.isUnique = false self.fields = [] self.action = .read @@ -30,8 +32,11 @@ extension DatabaseQuery: CustomStringConvertible { var parts = [ "query", "\(self.action)", - self.schema ] + if let space = self.space { + parts.append(space) + } + parts.append(self.schema) if self.isUnique { parts.append("unique") } diff --git a/Sources/FluentKit/Query/QueryHistory.swift b/Sources/FluentKit/Query/QueryHistory.swift index 835bce0d..75a808df 100644 --- a/Sources/FluentKit/Query/QueryHistory.swift +++ b/Sources/FluentKit/Query/QueryHistory.swift @@ -1,4 +1,4 @@ -import class NIOConcurrencyHelpers.Lock +import struct NIOConcurrencyHelpers.NIOLock /// Holds the history of queries for a database public final class QueryHistory { @@ -6,7 +6,7 @@ public final class QueryHistory { public var queries: [DatabaseQuery] /// Protects - private var lock: Lock + private var lock: NIOLock /// Create a new `QueryHistory` with no existing history public init() { diff --git a/Sources/FluentKit/Schema/DatabaseSchema.swift b/Sources/FluentKit/Schema/DatabaseSchema.swift index 29f7b53d..410538cd 100644 --- a/Sources/FluentKit/Schema/DatabaseSchema.swift +++ b/Sources/FluentKit/Schema/DatabaseSchema.swift @@ -67,12 +67,14 @@ public struct DatabaseSchema { public enum FieldConstraint { public static func references( _ schema: String, + space: String? = nil, _ field: FieldKey, onDelete: ForeignKeyAction = .noAction, onUpdate: ForeignKeyAction = .noAction ) -> Self { .foreignKey( schema, + space: space, .key(field), onDelete: onDelete, onUpdate: onUpdate @@ -83,6 +85,7 @@ public struct DatabaseSchema { case identifier(auto: Bool) case foreignKey( _ schema: String, + space: String? = nil, _ field: FieldName, onDelete: ForeignKeyAction, onUpdate: ForeignKeyAction @@ -100,10 +103,12 @@ public struct DatabaseSchema { case foreignKey( _ fields: [FieldName], _ schema: String, + space: String? = nil, _ foreign: [FieldName], onDelete: ForeignKeyAction, onUpdate: ForeignKeyAction ) + case compositeIdentifier(_ fields: [FieldName]) case custom(Any) } @@ -138,10 +143,40 @@ public struct DatabaseSchema { case constraint(ConstraintAlgorithm) case name(String) case custom(Any) + + /// Deletion specifier for an explicitly-named constraint known to be a referential constraint. + /// + /// Certain old versions of certain databases (I'm looking at you, MySQL 5.7...) do not support dropping + /// a `FOREIGN KEY` constraint by name without knowing ahead of time that it is a foreign key. When an + /// unfortunate user runs into this, the options are: + /// + /// - Trap the resulting error and retry. This is exceptionally awkward to handle automatically, and can + /// lead to retry loops if multiple deletions are specified in a single operation. + /// - Force the user to issue a raw SQL query instead. This is obviously undesirable. + /// - Force an upgrade of the underlying database. No one should be using MySQL 5.7 anymore, but Fluent + /// recognizes that this isn't always under the user's control. + /// - Require the user to specify the deletion with ``constraint(_:)``, providing the complete, accurate, + /// and current definition of the foreign key. This is information the user may not even know, and + /// certainly should not be forced to repeat here. + /// - Provide a means for the user to specify that a given constraint to be deleted by name is known to be + /// a foreign key. For databases which _don't_ suffer from this particular syntactical issue (so, almost + /// everything), this is exactly the same as specifying ``name(_:)``. + /// + /// In short, this is the marginal best choice from a list of really bad choices - an ugly, backhanded + /// workaround for MySQL 5.7 users. + /// + /// > Note: A static method is provided rather than a new `enum` case because adding new cases to a public + /// > enum without library evolution enabled (which only the stdlib can do) is a source compatibility break + /// > and requires a `semver-major` version bump. This rule is often ignored, but ignoring it doesn't make + /// > the problem moot. + public static func namedForeignKey(_ name: String) -> ConstraintDelete { + self.custom(_ForeignKeyByNameExtension(name: name)) + } } public var action: Action public var schema: String + public var space: String? public var createFields: [FieldDefinition] public var updateFields: [FieldUpdate] public var deleteFields: [FieldName] @@ -149,9 +184,10 @@ public struct DatabaseSchema { public var deleteConstraints: [ConstraintDelete] public var exclusiveCreate: Bool - public init(schema: String) { + public init(schema: String, space: String? = nil) { self.action = .create self.schema = schema + self.space = space self.createFields = [] self.updateFields = [] self.deleteFields = [] @@ -160,3 +196,15 @@ public struct DatabaseSchema { self.exclusiveCreate = true } } + +extension DatabaseSchema.ConstraintDelete { + /// For internal use only. + /// + /// Do not use this type directly; it's only public because FluentSQL needs to be able to get at it. + /// The use of `@_spi` will be replaced with the `package` modifier once a suitable minimum version + /// of Swift becomes required. + @_spi(FluentSQLSPI) + public/*package*/ struct _ForeignKeyByNameExtension { + public/*package*/ let name: String + } +} diff --git a/Sources/FluentKit/Schema/SchemaBuilder.swift b/Sources/FluentKit/Schema/SchemaBuilder.swift index 34318e8f..06f9fe35 100644 --- a/Sources/FluentKit/Schema/SchemaBuilder.swift +++ b/Sources/FluentKit/Schema/SchemaBuilder.swift @@ -1,22 +1,26 @@ extension Database { - public func schema(_ schema: String) -> SchemaBuilder { - return .init(database: self, schema: schema) + public func schema(_ schema: String, space: String? = nil) -> SchemaBuilder { + return .init(database: self, schema: schema, space: space) } } +import NIOCore + public final class SchemaBuilder { let database: Database public var schema: DatabaseSchema - init(database: Database, schema: String) { + init(database: Database, schema: String, space: String? = nil) { self.database = database - self.schema = .init(schema: schema) + self.schema = .init(schema: schema, space: space) } + @discardableResult public func id() -> Self { self.field(.id, .uuid, .identifier(auto: false)) } + @discardableResult public func field( _ key: FieldKey, _ dataType: DatabaseSchema.DataType, @@ -29,43 +33,62 @@ public final class SchemaBuilder { )) } + @discardableResult public func field(_ field: DatabaseSchema.FieldDefinition) -> Self { self.schema.createFields.append(field) return self } + @discardableResult public func unique(on fields: FieldKey..., name: String? = nil) -> Self { self.constraint(.constraint( .unique(fields: fields.map { .key($0) }), name: name )) } + + @discardableResult + public func compositeIdentifier(over fields: FieldKey...) -> Self { + self.constraint(.constraint(.compositeIdentifier(fields.map { .key($0) }), name: "")) + } + @discardableResult public func constraint(_ constraint: DatabaseSchema.Constraint) -> Self { self.schema.createConstraints.append(constraint) return self } + @discardableResult public func deleteUnique(on fields: FieldKey...) -> Self { - self.schema.deleteConstraints.append(.constraint( - .unique(fields: fields.map { .key($0) }) - )) - return self + self.deleteConstraint(.constraint(.unique(fields: fields.map { .key($0) }))) + } + + /// Delete a FOREIGN KEY constraint with the given name. + /// + /// This method allows correctly handling referential constraints with custom names when using MySQL 5.7 + /// without being forced to also know the full definition of the constraint at the time of deletion. See + /// ``DatabaseSchema/ConstraintDelete/namedForeignKey(_:)`` for a more complete discussion. + @discardableResult + public func deleteForeignKey(name: String) -> Self { + self.deleteConstraint(.namedForeignKey(name)) } + @discardableResult public func deleteConstraint(name: String) -> Self { - self.schema.deleteConstraints.append(.name(name)) - return self + self.deleteConstraint(.name(name)) } + @discardableResult public func deleteConstraint(_ constraint: DatabaseSchema.ConstraintDelete) -> Self { self.schema.deleteConstraints.append(constraint) return self } + @discardableResult public func foreignKey( _ field: FieldKey, references foreignSchema: String, + inSpace foreignSpace: String? = nil, _ foreignField: FieldKey, onDelete: DatabaseSchema.ForeignKeyAction = .noAction, onUpdate: DatabaseSchema.ForeignKeyAction = .noAction, @@ -75,6 +98,7 @@ public final class SchemaBuilder { .foreignKey( [.key(field)], foreignSchema, + space: foreignSpace, [.key(foreignField)], onDelete: onDelete, onUpdate: onUpdate @@ -84,6 +108,31 @@ public final class SchemaBuilder { return self } + @discardableResult + public func foreignKey( + _ fields: [FieldKey], + references foreignSchema: String, + inSpace foreignSpace: String? = nil, + _ foreignFields: [FieldKey], + onDelete: DatabaseSchema.ForeignKeyAction = .noAction, + onUpdate: DatabaseSchema.ForeignKeyAction = .noAction, + name: String? = nil + ) -> Self { + self.schema.createConstraints.append(.constraint( + .foreignKey( + fields.map { .key($0) }, + foreignSchema, + space: foreignSpace, + foreignFields.map { .key($0) }, + onDelete: onDelete, + onUpdate: onUpdate + ), + name: name + )) + return self + } + + @discardableResult public func updateField( _ key: FieldKey, _ dataType: DatabaseSchema.DataType @@ -94,20 +143,24 @@ public final class SchemaBuilder { )) } + @discardableResult public func updateField(_ field: DatabaseSchema.FieldUpdate) -> Self { self.schema.updateFields.append(field) return self } + @discardableResult public func deleteField(_ name: FieldKey) -> Self { - return self.deleteField(.key(name)) + self.deleteField(.key(name)) } + @discardableResult public func deleteField(_ name: DatabaseSchema.FieldName) -> Self { self.schema.deleteFields.append(name) return self } + @discardableResult public func ignoreExisting() -> Self { self.schema.exclusiveCreate = false return self diff --git a/Sources/FluentKit/Utilities/RandomGeneratable.swift b/Sources/FluentKit/Utilities/RandomGeneratable.swift index fb1b3569..fe63ad5f 100644 --- a/Sources/FluentKit/Utilities/RandomGeneratable.swift +++ b/Sources/FluentKit/Utilities/RandomGeneratable.swift @@ -1,3 +1,5 @@ +import Foundation + public protocol RandomGeneratable { static func generateRandom() -> Self } diff --git a/Sources/FluentKit/Utilities/SomeCodingKey.swift b/Sources/FluentKit/Utilities/SomeCodingKey.swift new file mode 100644 index 00000000..3fb4183d --- /dev/null +++ b/Sources/FluentKit/Utilities/SomeCodingKey.swift @@ -0,0 +1,24 @@ +/// An implementation of `CodingKey` intended to represent arbitrary string and integer coding keys. +/// +/// This structure is effectively an inverse complement of the `CodingKeyRepresentable` protocol. +/// +/// "_The standard library's version of a protocol whose requirements are trapped in an infinitely +/// recursive personal identity crisis._" - Unknown +public struct SomeCodingKey: CodingKey, Hashable { + public let stringValue: String + public let intValue: Int? + + public init(stringValue: String) { + self.stringValue = stringValue + self.intValue = Int(stringValue) + } + + public init(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } + + public var description: String { + "SomeCodingKey(\"\(self.stringValue)\"\(self.intValue.map { ", int: \($0)" } ?? ""))" + } +} diff --git a/Sources/FluentSQL/DatabaseQuery+SQL.swift b/Sources/FluentSQL/DatabaseQuery+SQL.swift index a9b7369d..da68ffe0 100644 --- a/Sources/FluentSQL/DatabaseQuery+SQL.swift +++ b/Sources/FluentSQL/DatabaseQuery+SQL.swift @@ -1,3 +1,6 @@ +import FluentKit +import SQLKit + extension DatabaseQuery.Value { public static func sql(raw: String) -> Self { .sql(SQLRaw(raw)) @@ -68,6 +71,20 @@ extension DatabaseQuery.Filter { } } +extension DatabaseQuery.Join { + public static func sql(raw: String) -> Self { + .sql(SQLRaw(raw)) + } + + public static func sql(embed: SQLQueryString) -> Self { + .sql(embed) + } + + public static func sql(_ expression: SQLExpression) -> Self { + .custom(expression) + } +} + extension DatabaseQuery.Sort { public static func sql(raw: String) -> Self { .sql(SQLRaw(raw)) diff --git a/Sources/FluentSQL/DatabaseSchema+SQL.swift b/Sources/FluentSQL/DatabaseSchema+SQL.swift index 9daa64b9..5469981e 100644 --- a/Sources/FluentSQL/DatabaseSchema+SQL.swift +++ b/Sources/FluentSQL/DatabaseSchema+SQL.swift @@ -1,3 +1,6 @@ +import FluentKit +import SQLKit + extension DatabaseSchema.DataType { public static func sql(raw: String) -> Self { .sql(SQLRaw(raw)) diff --git a/Sources/FluentSQL/Docs.docc/index.md b/Sources/FluentSQL/Docs.docc/index.md new file mode 100644 index 00000000..ed17331f --- /dev/null +++ b/Sources/FluentSQL/Docs.docc/index.md @@ -0,0 +1,3 @@ +# ``FluentSQL`` + +FluentSQL is a package to conform Fluent to SQLKit to allow queries to be mapped from Fluent to SQLKit. \ No newline at end of file diff --git a/Sources/FluentSQL/Exports.swift b/Sources/FluentSQL/Exports.swift index 3725b75a..220e0555 100644 --- a/Sources/FluentSQL/Exports.swift +++ b/Sources/FluentSQL/Exports.swift @@ -1,46 +1,11 @@ -@_exported import FluentKit -@_exported import SQLKit +#if swift(>=5.8) -public struct SQLList: SQLExpression { - public var items: [SQLExpression] - public var separator: SQLExpression +@_documentation(visibility: internal) @_exported import FluentKit +@_documentation(visibility: internal) @_exported import SQLKit - public init(items: [SQLExpression], separator: SQLExpression) { - self.items = items - self.separator = separator - } +#else - public func serialize(to serializer: inout SQLSerializer) { - var first = true - for el in self.items { - if !first { - serializer.write(" ") - self.separator.serialize(to: &serializer) - serializer.write(" ") - } - first = false - el.serialize(to: &serializer) - } - } -} +@_exported import FluentKit +@_exported import SQLKit -/// Wraps a non-generic `Encodable` type for passing to a method that requires -/// a strong type. -/// -/// let encodable: Encodable ... -/// let data = try JSONEncoder().encode(EncodableWrapper(encodable)) -/// -struct EncodableWrapper: Encodable { - /// Wrapped `Encodable` type. - public let encodable: Encodable - - /// Creates a new `EncoderWrapper`. - public init(_ encodable: Encodable) { - self.encodable = encodable - } - - /// `Encodable` conformance. - public func encode(to encoder: Encoder) throws { - try self.encodable.encode(to: encoder) - } -} +#endif diff --git a/Sources/FluentSQL/SQLDatabase+Model+Concurrency.swift b/Sources/FluentSQL/SQLDatabase+Model+Concurrency.swift index bd9f3697..5e26acb6 100644 --- a/Sources/FluentSQL/SQLDatabase+Model+Concurrency.swift +++ b/Sources/FluentSQL/SQLDatabase+Model+Concurrency.swift @@ -1,8 +1,7 @@ -#if compiler(>=5.5) && canImport(_Concurrency) import NIOCore import SQLKit +import FluentKit -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) extension SQLQueryFetcher { public func first(decoding model: Model.Type) async throws -> Model? where Model: FluentKit.Model @@ -22,5 +21,3 @@ extension SQLQueryFetcher { }.get() } } - -#endif diff --git a/Sources/FluentSQL/SQLDatabase+Model.swift b/Sources/FluentSQL/SQLDatabase+Model.swift index 0fa6928e..1f3f47cd 100644 --- a/Sources/FluentSQL/SQLDatabase+Model.swift +++ b/Sources/FluentSQL/SQLDatabase+Model.swift @@ -1,4 +1,5 @@ import SQLKit +import FluentKit extension SQLQueryFetcher { public func first(decoding model: Model.Type) -> EventLoopFuture diff --git a/Sources/FluentSQL/SQLJSONColumnPath.swift b/Sources/FluentSQL/SQLJSONColumnPath.swift index d11724d0..dd8a5dfc 100644 --- a/Sources/FluentSQL/SQLJSONColumnPath.swift +++ b/Sources/FluentSQL/SQLJSONColumnPath.swift @@ -1,28 +1,27 @@ +import SQLKit +import FluentKit + +/// A thin deprecated wrapper around ``SQLKit/SQLNestedSubpathExpression``. +@available(*, deprecated, message: "Replaced by `SQLNestedSubpathExpression` in SQLKit") public struct SQLJSONColumnPath: SQLExpression { - public var column: String - public var path: [String] + private var realExpression: SQLNestedSubpathExpression + + public var column: String { + get { (self.realExpression.column as? SQLIdentifier)?.string ?? "" } + set { self.realExpression.column = SQLIdentifier(newValue) } + } + + public var path: [String] { + get { self.realExpression.path } + set { self.realExpression.path = newValue } + } public init(column: String, path: [String]) { - self.column = column - self.path = path + self.realExpression = .init(column: column, path: path) } public func serialize(to serializer: inout SQLSerializer) { - switch serializer.dialect.name { - case "postgresql": - switch path.count { - case 1: - serializer.write("\(column)->>'\(path[0])'") - case 2...: - let inner = path[0..") - serializer.write("\(column)->\(inner)->>'\(path.last!)'") - default: - fatalError() - } - default: - let path = self.path.joined(separator: ".") - serializer.write("JSON_EXTRACT(\(column), '$.\(path)')") - } + self.realExpression.serialize(to: &serializer) } } @@ -32,6 +31,6 @@ extension DatabaseQuery.Field { } public static func sql(json column: String, _ path: [String]) -> Self { - .sql(SQLJSONColumnPath(column: column, path: path)) + .sql(SQLNestedSubpathExpression(column: column, path: path)) } } diff --git a/Sources/FluentSQL/SQLList+Deprecated.swift b/Sources/FluentSQL/SQLList+Deprecated.swift new file mode 100644 index 00000000..b5bd3a6a --- /dev/null +++ b/Sources/FluentSQL/SQLList+Deprecated.swift @@ -0,0 +1,43 @@ +import SQLKit + +/// This file provides a few extensions to SQLKit's ``SQLList`` which have the effect of mimicking +/// the public API which was previously provided by a nearly-identical type of the same name in +/// this module. The slightly differing behavior of the Fluent version had a tendency to cause +/// confusion when both `FluentSQL` and `SQLKit` were imported in the same context; as such, the +/// Fluent version was removed. To avoid breaking API that has been publicly available for a long +/// time (no matter how incorrectly so), these deprecated extensions make the semantics of the removed +/// Fluent implementation available. Whether the original or alternate serialization behavior is used +/// is based on which initializer is used. The original SQLKit initializer, ``init(_:separator:)`` (or +/// ``init(_:)``, taking the default value for the separator), gives the original and intended behavior +/// (see ``SQLKit/SQLList`` for further details). The convenience intializer, ``init(items:separator:)``, +/// enables the deprecated alternate behavior, which adds a space character before and after the separator. +/// +/// Examples: +/// +/// Expressions: [1, 2, 3, 4, 5] +/// Separator: "AND" +/// Original serialization: 1AND2AND3AND4AND5 +/// Alternate serialization: 1 AND 2 AND 3 AND 4 AND 5 +/// +/// Expressions: [1, 2, 3, 4, 5] +/// Separator: ", " +/// Original serialization: 1, 2, 3, 4, 5 +/// Alternate serialization: 1 , 2 , 3 , 4 , 5 +/// +/// - Warning: These extensions are not recommended, as it was never intended for this behavior to be +/// public. Convert code using these extensions to invoke the original ``SQLKit/SQLList`` directly. +extension SQLKit.SQLList { + @available(*, deprecated, message: "Use `expressions` instead.") + public var items: [SQLExpression] { + get { self.expressions } + set { self.expressions = newValue } + } + + @available(*, deprecated, message: "Use `init(_:separator:)` and include whitespace in the separator as needed instead.") + public init(items: [SQLExpression], separator: SQLExpression) { + self.init(items, separator: " \(separator) " as SQLQueryString) + } +} + +@available(*, deprecated, message: "Import `SQLList` from the SQLKit module instead.") +public typealias SQLList = SQLKit.SQLList diff --git a/Sources/FluentSQL/SQLQualifiedTable.swift b/Sources/FluentSQL/SQLQualifiedTable.swift new file mode 100644 index 00000000..1d1993aa --- /dev/null +++ b/Sources/FluentSQL/SQLQualifiedTable.swift @@ -0,0 +1,23 @@ +import SQLKit + +public struct SQLQualifiedTable: SQLExpression { + public var table: SQLExpression + public var space: SQLExpression? + + public init(_ table: String, space: String? = nil) { + self.init(SQLIdentifier(table), space: space.flatMap(SQLIdentifier.init(_:))) + } + + public init(_ table: SQLExpression, space: SQLExpression? = nil) { + self.table = table + self.space = space + } + + public func serialize(to serializer: inout SQLSerializer) { + if let space = self.space { + space.serialize(to: &serializer) + serializer.write(".") + } + self.table.serialize(to: &serializer) + } +} diff --git a/Sources/FluentSQL/SQLQueryConverter.swift b/Sources/FluentSQL/SQLQueryConverter.swift index 0017130e..0c2249bc 100644 --- a/Sources/FluentSQL/SQLQueryConverter.swift +++ b/Sources/FluentSQL/SQLQueryConverter.swift @@ -1,3 +1,6 @@ +import FluentKit +import SQLKit + public struct SQLQueryConverter { let delegate: SQLConverterDelegate public init(delegate: SQLConverterDelegate) { @@ -20,22 +23,25 @@ public struct SQLQueryConverter { // MARK: Private private func delete(_ query: DatabaseQuery) -> SQLExpression { - var delete = SQLDelete(table: SQLIdentifier(query.schema)) + var delete = SQLDelete(table: SQLQualifiedTable(query.schema, space: query.space)) delete.predicate = self.filters(query.filters) return delete } private func update(_ query: DatabaseQuery) -> SQLExpression { - var update = SQLUpdate(table: SQLIdentifier(query.schema)) - guard case .dictionary(let values) = query.input.first! else { - fatalError() + var update = SQLUpdate(table: SQLQualifiedTable(query.schema, space: query.space)) + guard case .dictionary(let values) = query.input.first else { + fatalError("Missing query input generating update query") } - values.forEach { (key, value) in - update.values.append(SQLBinaryExpression( - left: SQLColumn(self.key(key)), - op: SQLBinaryOperator.equal, - right: self.value(value) - )) + update.values = query.fields.compactMap { field -> SQLExpression? in + let key: FieldKey + switch field { + case let .path(path, schema) where schema == query.schema: key = path[0] + case let .extendedPath(path, schema, space) where schema == query.schema && space == query.space: key = path[0] + default: return nil + } + guard let value = values[key] else { return nil } + return SQLColumnAssignment(setting: SQLColumn(self.key(key)), to: self.value(value)) } update.predicate = self.filters(query.filters) return update @@ -43,38 +49,11 @@ public struct SQLQueryConverter { private func select(_ query: DatabaseQuery) -> SQLExpression { var select = SQLSelect() - select.tables.append(SQLIdentifier(query.schema)) + select.tables.append(SQLQualifiedTable(query.schema, space: query.space)) switch query.action { case .read: select.isDistinct = query.isUnique - select.columns = query.fields.map { field in - switch field { - case .custom(let any): - return custom(any) - case .path(let path, let schema): - let field: SQLExpression - let key: FieldKey - - // determine field type based on count - switch path.count { - case 1: - key = path[0] - field = SQLColumn(self.key(key), table: schema) - case 2...: - key = path[0] - field = self.delegate.nestedFieldExpression( - self.key(key), - path[1...].map(self.key) - ) - default: - fatalError("Field path must not be empty.") - } - return SQLAlias( - field, - as: SQLIdentifier(schema + "_" + self.key(key)) - ) - } - } + select.columns = query.fields.map { field in self.field(field, aliased: true) } case .aggregate(let aggregate): select.columns = [self.aggregate(aggregate, isUnique: query.isUnique)] default: break @@ -102,25 +81,38 @@ public struct SQLQueryConverter { } private func insert(_ query: DatabaseQuery) -> SQLExpression { - var insert = SQLInsert(table: SQLIdentifier(query.schema)) - guard case .dictionary(let first) = query.input.first! else { + var insert = SQLInsert(table: SQLQualifiedTable(query.schema, space: query.space)) + + // 1. Load the first set of inputs to the query, used as a basis to validate uniformity of all inputs. + guard let firstInput = query.input.first, case let .dictionary(firstValues) = firstInput else { fatalError("Unexpected query input: \(query.input)") } - let keys: [FieldKey] = Array(first.keys) - insert.columns = keys.map { key in - SQLColumn(self.key(key)) - } - insert.values = query.input.map { value in - guard case .dictionary(let nested) = value else { - fatalError("Unexpected query input: \(value)") - } - return keys.map { key in - guard let value = nested[key] else { - fatalError("Non-uniform query input: \(query.input)") - } - return self.value(value) - } + + // 2. Translate the list of fields from the query, which are given in a meaningful, deterministic order, into + // column designators. + let keys = query.fields.compactMap { field -> FieldKey? in switch field { + case let .path(path, schema) where schema == query.schema: return path[0] + case let .extendedPath(path, schema, space) where schema == query.schema && space == query.space: return path[0] + default: return nil + } } + + // 3. Filter the list of columns so that only those actually provided are specified to the insert query, since + // often a query will insert only some of a model's fields while still listing all of them. + let usedKeys = keys.filter { firstValues.keys.contains($0) } + + // 4. Validate each set of inputs, making sure it provides exactly the keys as the first, and convert the sets + // to their underlying SQL representations. + let dictionaries = query.input.map { input -> [FieldKey: SQLExpression] in + guard case let .dictionary(value) = input else { fatalError("Unexpected query input: \(input)") } + guard Set(value.keys).symmetricDifference(usedKeys).isEmpty else { fatalError("Non-uniform query input: \(query.input)") } + return value.mapValues(self.value(_:)) } + + // 5. Provide the list of columns and the sets of inserted values to the actual query, always specifying in the + // same order as the original field list. + insert.columns = usedKeys.map { SQLColumn(self.key($0)) } + insert.values = dictionaries.map { values in usedKeys.compactMap { values[$0] } } + return insert } @@ -129,9 +121,9 @@ public struct SQLQueryConverter { return nil } - return SQLList( - items: filters.map(self.filter), - separator: SQLBinaryOperator.and + return SQLKit.SQLList( + filters.map(self.filter), + separator: " \(SQLBinaryOperator.and) " as SQLQueryString ) } @@ -146,12 +138,9 @@ public struct SQLQueryConverter { private func direction(_ direction: DatabaseQuery.Sort.Direction) -> SQLExpression { switch direction { - case .ascending: - return SQLRaw("ASC") - case .descending: - return SQLRaw("DESC") - case .custom(let any): - return custom(any) + case .ascending: return SQLDirection.ascending + case .descending: return SQLDirection.descending + case .custom(let any): return custom(any) } } @@ -159,25 +148,30 @@ public struct SQLQueryConverter { switch join { case .custom(let any): return custom(any) + case .join(let schema, let alias, let method, let foreign, let local): - let table: SQLExpression - if let alias = alias { - table = SQLAlias(SQLIdentifier(schema), as: SQLIdentifier(alias)) - } else { - table = SQLIdentifier(schema) - } - return SQLJoin( - method: self.joinMethod(method), - table: table, - expression: SQLBinaryExpression( - left: self.field(local), - op: SQLBinaryOperator.equal, - right: self.field(foreign) - ) - ) + return self.joinCondition(schema: schema, alias: alias, method: method, filters: [.field(foreign, .equal, local)]) + + case .extendedJoin(let schema, let space, let alias, let method, let foreign, let local): + return self.joinCondition(space: space, schema: schema, alias: alias, method: method, filters: [.field(foreign, .equal, local)]) + + case .advancedJoin(let schema, let space, let alias, let method, let filters): + return self.joinCondition(space: space, schema: schema, alias: alias, method: method, filters: filters) } } + private func joinCondition( + space: String? = nil, schema: String, + alias: String?, + method: DatabaseQuery.Join.Method, + filters: [DatabaseQuery.Filter] + ) -> SQLExpression { + let table: SQLExpression = alias.map { SQLAlias(SQLQualifiedTable(schema, space: space), as: SQLIdentifier($0)) } ?? + SQLQualifiedTable(schema, space: space) + + return SQLJoin(method: self.joinMethod(method), table: table, expression: self.filters(filters) ?? SQLLiteral.boolean(true)) + } + private func joinMethod(_ method: DatabaseQuery.Join.Method) -> SQLExpression { switch method { case .inner: return SQLJoinMethod.inner @@ -186,26 +180,37 @@ public struct SQLQueryConverter { return custom(any) } } - - private func field(_ field: DatabaseQuery.Field) -> SQLExpression { + + private func field(_ field: DatabaseQuery.Field, aliased: Bool = false) -> SQLExpression { switch field { case .custom(let any): return custom(any) case .path(let path, let schema): - switch path.count { - case 1: - return SQLColumn(self.key(path[0]), table: schema) - case 2...: - return self.delegate.nestedFieldExpression( - self.key(path[0]), - path[1...].map(self.key) - ) - default: - fatalError("Field path must not be empty.") - } + return self.fieldPath(path, schema: schema, aliased: aliased) + case .extendedPath(let path, let schema, let space): + return self.fieldPath(path, space: space, schema: schema, aliased: aliased) } } - + + private func fieldPath(_ path: [FieldKey], space: String? = nil, schema: String, aliased: Bool) -> SQLExpression { + let field: SQLExpression + + switch path.count { + case 1: + field = SQLColumn(SQLIdentifier(self.key(path[0])), table: SQLQualifiedTable(schema, space: space)) + case 2...: + field = self.delegate.nestedFieldExpression(self.key(path[0]), path[1...].map(self.key)) + default: + fatalError("Field path must not be empty.") + } + + if aliased { + return SQLAlias(field, as: [space, schema, self.key(path[0])].compactMap({ $0 }).joined(separator: "_")) + } else { + return field + } + } + private func aggregate(_ aggregate: DatabaseQuery.Aggregate, isUnique: Bool) -> SQLExpression { switch aggregate { case .custom(let any): @@ -293,9 +298,9 @@ public struct SQLQueryConverter { return custom(any) case .group(let filters, let relation): // OR OR - let expression = SQLList( - items: filters.map(self.filter), - separator: self.relation(relation) + let expression = SQLKit.SQLList( + filters.map(self.filter), + separator: " \(self.relation(relation)) " as SQLQueryString ) // ( ) return SQLGroupExpression(expression) @@ -325,7 +330,7 @@ public struct SQLQueryConverter { case .null: return SQLLiteral.null case .array(let values): - return SQLGroupExpression(SQLList(items: values.map(self.value), separator: SQLRaw(","))) + return SQLGroupExpression(SQLKit.SQLList(values.map(self.value), separator: SQLRaw(","))) case .dictionary(let dictionary): return SQLBind(EncodableDatabaseInput(input: dictionary)) case .default: @@ -369,27 +374,17 @@ public struct SQLQueryConverter { } } - private func key(_ key: FieldKey) -> String { - switch key { - case .id: - return "id" - case .string(let name): - return name - case .aggregate: - return key.description - case .prefix(let prefix, let key): - return self.key(prefix) + self.key(key) - } - } + @inline(__always) + private func key(_ key: FieldKey) -> String { key.description } } private struct EncodableDatabaseInput: Encodable { let input: [FieldKey: DatabaseQuery.Value] func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: DatabaseKey.self) + var container = encoder.container(keyedBy: FluentKit.SomeCodingKey.self) for (key, value) in self.input { - try container.encode(EncodableDatabaseValue(value: value), forKey: DatabaseKey(key.description)) + try container.encode(EncodableDatabaseValue(value: value), forKey: FluentKit.SomeCodingKey(stringValue: key.description)) } } } @@ -411,64 +406,16 @@ private struct EncodableDatabaseValue: Encodable { } } -private struct DatabaseKey: CodingKey { - var stringValue: String - var intValue: Int? { - nil - } - - init(_ string: String) { - self.stringValue = string - } - - init?(stringValue: String) { - self.init(stringValue) - } - - init?(intValue: Int) { - return nil - } -} - -extension Encodable { - var isNil: Bool { - if let optional = self as? AnyOptionalType { - return optional.wrappedValue == nil - } else { - return false - } - } -} - extension DatabaseQuery.Value { var isNull: Bool { switch self { case .null: return true case .bind(let bind): - return bind.isNil + guard let optional = bind as? AnyOptionalType, case .none = optional.wrappedValue else { return false } + return true default: return false } } } - -private struct StringCodingKey: CodingKey { - public var stringValue: String - - public var intValue: Int? { - return Int(self.stringValue) - } - - public init(_ string: String) { - self.stringValue = string - } - - public init(stringValue: String) { - self.stringValue = stringValue - } - - public init(intValue: Int) { - self.stringValue = intValue.description - } -} diff --git a/Sources/FluentSQL/SQLSchemaConverter.swift b/Sources/FluentSQL/SQLSchemaConverter.swift index ff971385..8535c5f3 100644 --- a/Sources/FluentSQL/SQLSchemaConverter.swift +++ b/Sources/FluentSQL/SQLSchemaConverter.swift @@ -1,3 +1,6 @@ +import SQLKit +@_spi(FluentSQLSPI) import FluentKit + public protocol SQLConverterDelegate { func customDataType(_ dataType: DatabaseSchema.DataType) -> SQLExpression? func nestedFieldExpression(_ column: String, _ path: [String]) -> SQLExpression @@ -5,6 +8,10 @@ public protocol SQLConverterDelegate { } extension SQLConverterDelegate { + public func nestedFieldExpression(_ column: String, _ path: [String]) -> SQLExpression { + SQLNestedSubpathExpression(column: column, path: path) + } + public func beforeConvert(_ schema: DatabaseSchema) -> DatabaseSchema { schema } @@ -31,7 +38,7 @@ public struct SQLSchemaConverter { // MARK: Private private func update(_ schema: DatabaseSchema) -> SQLExpression { - var update = SQLAlterTable(name: self.name(schema.schema)) + var update = SQLAlterTable(name: self.name(schema.schema, space: schema.space)) update.addColumns = schema.createFields.map(self.fieldDefinition) update.dropColumns = schema.deleteFields.map(self.fieldName) update.modifyColumns = schema.updateFields.map(self.fieldUpdate) @@ -45,12 +52,12 @@ public struct SQLSchemaConverter { } private func delete(_ schema: DatabaseSchema) -> SQLExpression { - let delete = SQLDropTable(table: self.name(schema.schema)) + let delete = SQLDropTable(table: self.name(schema.schema, space: schema.space)) return delete } private func create(_ schema: DatabaseSchema) -> SQLExpression { - var create = SQLCreateTable(name: self.name(schema.schema)) + var create = SQLCreateTable(name: self.name(schema.schema, space: schema.space)) create.columns = schema.createFields.map(self.fieldDefinition) create.tableConstraints = schema.createConstraints.map { self.constraint($0, table: schema.schema) @@ -61,8 +68,8 @@ public struct SQLSchemaConverter { return create } - private func name(_ string: String) -> SQLExpression { - return SQLIdentifier(string) + private func name(_ string: String, space: String? = nil) -> SQLExpression { + return SQLQualifiedTable(string, space: space) } private func constraint(_ constraint: DatabaseSchema.Constraint, table: String) -> SQLExpression { @@ -75,9 +82,9 @@ public struct SQLSchemaConverter { algorithm: SQLTableConstraintAlgorithm.unique(columns: fields.map(self.fieldName)), name: SQLIdentifier(name) ) - case .foreignKey(let local, let schema, let foreign, let onDelete, let onUpdate): + case .foreignKey(let local, let schema, let space, let foreign, let onDelete, let onUpdate): let reference = SQLForeignKey( - table: self.name(schema), + table: self.name(schema, space: space), columns: foreign.map(self.fieldName), onDelete: self.foreignKeyAction(onDelete), onUpdate: self.foreignKeyAction(onUpdate) @@ -89,6 +96,8 @@ public struct SQLSchemaConverter { ), name: SQLIdentifier(name) ) + case .compositeIdentifier(let fields): + return SQLConstraint(algorithm: SQLTableConstraintAlgorithm.primaryKey(columns: fields.map(self.fieldName)), name: nil) case .custom(let any): return SQLConstraint(algorithm: any as! SQLExpression, name: customName.map(SQLIdentifier.init(_:))) } @@ -101,10 +110,13 @@ public struct SQLSchemaConverter { switch constraint { case .constraint(let algorithm): let name = self.constraintIdentifier(algorithm, table: table) - return SQLDropConstraint(name: SQLIdentifier(name)) + return SQLDropTypedConstraint(name: SQLIdentifier(name), algorithm: algorithm) case .name(let name): - return SQLDropConstraint(name: SQLIdentifier(name)) + return SQLDropTypedConstraint(name: SQLIdentifier(name), algorithm: .custom("")) case .custom(let any): + if let fkeyExt = any as? DatabaseSchema.ConstraintDelete._ForeignKeyByNameExtension { + return SQLDropTypedConstraint(name: SQLIdentifier(fkeyExt.name), algorithm: .foreignKey([], "", [], onDelete: .noAction, onUpdate: .noAction)) + } return custom(any) } } @@ -114,7 +126,7 @@ public struct SQLSchemaConverter { let prefix: String switch algorithm { - case .foreignKey(let localFields, _, let foreignFields, _, _): + case .foreignKey(let localFields, _, _, let foreignFields, _, _): prefix = "fk" fieldNames = localFields + foreignFields case .unique(let fields): @@ -240,9 +252,9 @@ public struct SQLSchemaConverter { return SQLColumnConstraintAlgorithm.notNull case .identifier(let auto): return SQLColumnConstraintAlgorithm.primaryKey(autoIncrement: auto) - case .foreignKey(let schema, let field, let onDelete, let onUpdate): + case .foreignKey(let schema, let space, let field, let onDelete, let onUpdate): return SQLColumnConstraintAlgorithm.references( - SQLIdentifier(schema), + self.name(schema, space: space), self.fieldName(field), onDelete: self.foreignKeyAction(onDelete), onUpdate: self.foreignKeyAction(onUpdate) @@ -266,9 +278,47 @@ public struct SQLSchemaConverter { } } -/// SQL drop constraint expression. +/// SQL drop constraint expression with awareness of foreign keys (for MySQL's broken sake). +/// +/// - Warning: This is only public for the benefit of `FluentBenchmarks`. DO NOT USE THIS TYPE! +public struct SQLDropTypedConstraint: SQLExpression { + public let name: SQLExpression + public let algorithm: DatabaseSchema.ConstraintAlgorithm + + public init(name: SQLExpression, algorithm: DatabaseSchema.ConstraintAlgorithm) { + self.name = name + self.algorithm = algorithm + } + + public func serialize(to serializer: inout SQLSerializer) { + serializer.statement { + if $0.dialect.name == "mysql" { // TODO: Add an SQLDialect setting for this branch + // MySQL 5.7 does not support the type-generic "DROP CONSTRAINT" syntax. + switch algorithm { + case .foreignKey(_, _, _, _, _, _): + $0.append("FOREIGN KEY") + $0.append($0.dialect.normalizeSQLConstraint(identifier: self.name)) + case .unique(_): + $0.append("KEY") + $0.append($0.dialect.normalizeSQLConstraint(identifier: self.name)) + // Ignore `.compositeIdentifier()`, that gets too complicated between databases + default: + // Ideally we'd detect MySQL 8.0 and use `CONSTRAINT` here... + $0.append("KEY") + $0.append($0.dialect.normalizeSQLConstraint(identifier: self.name)) + } + } else { + $0.append("CONSTRAINT") + $0.append($0.dialect.normalizeSQLConstraint(identifier: self.name)) + } + } + } +} + +/// Obsolete form of SQL drop constraint expression. /// /// `CONSTRAINT/KEY ` +@available(*, deprecated, message: "Use SQLDropTypedConstraint instead") public struct SQLDropConstraint: SQLExpression { public var name: SQLExpression diff --git a/Sources/FluentSQL/Utilities.swift b/Sources/FluentSQL/Utilities.swift index 32d5f778..93e52cc3 100644 --- a/Sources/FluentSQL/Utilities.swift +++ b/Sources/FluentSQL/Utilities.swift @@ -1,3 +1,5 @@ +import SQLKit + func custom(_ any: Any) -> SQLExpression { if let sql = any as? SQLExpression { return sql diff --git a/Sources/XCTFluent/Docs.docc/index.md b/Sources/XCTFluent/Docs.docc/index.md new file mode 100644 index 00000000..74be1f44 --- /dev/null +++ b/Sources/XCTFluent/Docs.docc/index.md @@ -0,0 +1,3 @@ +# ``XCTFluent`` + +XCTFluent provides XCTest extensions to make it easy to write tests that use Vapor's FluentKit library. diff --git a/Sources/XCTFluent/DummyDatabase.swift b/Sources/XCTFluent/DummyDatabase.swift index 5acefc7c..5e4be52e 100644 --- a/Sources/XCTFluent/DummyDatabase.swift +++ b/Sources/XCTFluent/DummyDatabase.swift @@ -1,6 +1,7 @@ import FluentKit import Foundation import NIOEmbedded +import NIOCore public struct DummyDatabase: Database { public var context: DatabaseContext @@ -96,6 +97,8 @@ public struct DummyRow: DatabaseOutput { { if T.self is UUID.Type { return UUID() as! T + } else if T.self is Int.Type, key == .aggregate { + return 1 as! T } else { return try T(from: DummyDecoder()) } diff --git a/Sources/XCTFluent/TestDatabase.swift b/Sources/XCTFluent/TestDatabase.swift index 72c4da19..a430658c 100644 --- a/Sources/XCTFluent/TestDatabase.swift +++ b/Sources/XCTFluent/TestDatabase.swift @@ -1,5 +1,7 @@ import FluentKit import NIOEmbedded +import Logging +import NIOCore /// Lets you mock the row results for each query. /// diff --git a/Tests/FluentKitTests/AsyncTests/AsyncFilterQueryTests.swift b/Tests/FluentKitTests/AsyncTests/AsyncFilterQueryTests.swift index cf3cd471..008c33cd 100644 --- a/Tests/FluentKitTests/AsyncTests/AsyncFilterQueryTests.swift +++ b/Tests/FluentKitTests/AsyncTests/AsyncFilterQueryTests.swift @@ -1,19 +1,21 @@ -#if compiler(>=5.5) && canImport(_Concurrency) -#if !os(Linux) -@testable import FluentKit -@testable import FluentBenchmark +import FluentKit +import FluentBenchmark import XCTest import Foundation import FluentSQL -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) final class AsyncFilterQueryTests: XCTestCase { + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + // MARK: Enum func test_enumEquals() async throws { let db = DummyDatabaseForTestSQLSerializer() _ = try await Task.query(on: db).filter(\.$status == .done).all() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."status" = 'done'"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" = 'done'"#) db.reset() } @@ -21,7 +23,7 @@ final class AsyncFilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try await Task.query(on: db).filter(\.$status != .done).all() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."status" <> 'done'"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" <> 'done'"#) db.reset() } @@ -29,7 +31,7 @@ final class AsyncFilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try await Task.query(on: db).filter(\.$status ~~ [.done, .notDone]).all() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."status" IN ('done' , 'notDone')"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" IN ('done','notDone')"#) db.reset() } @@ -37,7 +39,40 @@ final class AsyncFilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try await Task.query(on: db).filter(\.$status !~ [.done, .notDone]).all() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."status" NOT IN ('done' , 'notDone')"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" NOT IN ('done','notDone')"#) + db.reset() + } + + // MARK: OptionalEnum + func test_optionalEnumEquals() async throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try await Task.query(on: db).filter(\.$optionalStatus == .done).all() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" = 'done'"#) + db.reset() + } + + func test_optionalEnumNotEquals() async throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try await Task.query(on: db).filter(\.$optionalStatus != .done).all() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" <> 'done'"#) + db.reset() + } + + func test_optionalEnumIn() async throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try await Task.query(on: db).filter(\.$optionalStatus ~~ [.done, .notDone]).all() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" IN ('done','notDone')"#) + db.reset() + } + + func test_optionalEnumNotIn() async throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try await Task.query(on: db).filter(\.$optionalStatus !~ [.done, .notDone]).all() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" NOT IN ('done','notDone')"#) db.reset() } @@ -46,7 +81,7 @@ final class AsyncFilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try await Task.query(on: db).filter(\.$description == "hello").all() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."description" = $1"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" = $1"#) db.reset() } @@ -54,7 +89,7 @@ final class AsyncFilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try await Task.query(on: db).filter(\.$description != "hello").all() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."description" <> $1"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" <> $1"#) db.reset() } @@ -62,7 +97,7 @@ final class AsyncFilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try await Task.query(on: db).filter(\.$description ~~ ["hello"]).all() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."description" IN ($1)"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" IN ($1)"#) db.reset() } @@ -70,9 +105,7 @@ final class AsyncFilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try await Task.query(on: db).filter(\.$description !~ ["hello"]).all() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."description" NOT IN ($1)"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" NOT IN ($1)"#) db.reset() } } -#endif -#endif diff --git a/Tests/FluentKitTests/AsyncTests/AsyncFluentKitTests.swift b/Tests/FluentKitTests/AsyncTests/AsyncFluentKitTests.swift index ac8f5a19..5214134e 100644 --- a/Tests/FluentKitTests/AsyncTests/AsyncFluentKitTests.swift +++ b/Tests/FluentKitTests/AsyncTests/AsyncFluentKitTests.swift @@ -1,14 +1,16 @@ -#if compiler(>=5.5) && canImport(_Concurrency) -#if !os(Linux) -@testable import FluentKit -@testable import FluentBenchmark +import FluentKit +import FluentBenchmark import XCTest import Foundation import FluentSQL import XCTFluent -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) final class AsyncFluentKitTests: XCTestCase { + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + func testGalaxyPlanetSorts() async throws { let db = DummyDatabaseForTestSQLSerializer() _ = try await Planet.query(on: db).sort(\.$name, .descending).all() @@ -51,6 +53,24 @@ final class AsyncFluentKitTests: XCTestCase { db.reset() } + func testGroupSorts() async throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try await User.query(on: db).sort(\.$pet.$name).all { _ in } + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"ORDER BY "users"."pet_name" ASC"#), true) + db.reset() + + _ = try await User.query(on: db).sort(\.$pet.$toy.$name, .descending).all { _ in } + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"ORDER BY "users"."pet_toy_name" DESC"#), true) + db.reset() + + _ = try await User.query(on: db).sort(\.$pet.$toy.$foo.$bar, .ascending).all { _ in } + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"ORDER BY "users"."pet_toy_foo_bar" ASC"#), true) + db.reset() + } + func testJoins() async throws { let db = DummyDatabaseForTestSQLSerializer() _ = try await Planet.query(on: db).join(child: \Planet.$governor).all() @@ -70,7 +90,7 @@ final class AsyncFluentKitTests: XCTestCase { _ = try await Planet.query(on: db).join(siblings: \Planet.$tags).all() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"INNER JOIN "planet+tag" ON "planets"."id" = "planet+tag"."planet_id""#), true) + XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"INNER JOIN "planet+tag" ON "planet+tag"."planet_id" = "planets"."id""#), true) XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"INNER JOIN "tags" ON "planet+tag"."tag_id" = "tags"."id""#), true) db.reset() } @@ -80,7 +100,7 @@ final class AsyncFluentKitTests: XCTestCase { _ = try await Planet.query(on: db).all(\.$name) XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "planets"."name" AS "planets_name" FROM "planets""#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "planets"."name" AS "planets_name" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() } @@ -89,7 +109,7 @@ final class AsyncFluentKitTests: XCTestCase { _ = try await Planet.query(on: db).unique().all(\.$name) XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT DISTINCT "planets"."name" AS "planets_name" FROM "planets""#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT DISTINCT "planets"."name" AS "planets_name" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() _ = try await Planet.query(on: db).unique().all() @@ -99,12 +119,12 @@ final class AsyncFluentKitTests: XCTestCase { _ = try await Planet.query(on: db).unique().count(\.$name) XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT COUNT(DISTINCT("planets"."name")) AS "aggregate" FROM "planets""#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT COUNT(DISTINCT("planets"."name")) AS "aggregate" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() _ = try await Planet.query(on: db).unique().sum(\.$id) XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT SUM(DISTINCT("planets"."id")) AS "aggregate" FROM "planets""#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT SUM(DISTINCT("planets"."id")) AS "aggregate" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() } @@ -243,7 +263,7 @@ final class AsyncFluentKitTests: XCTestCase { XCTAssertEqual(db.sqlSerializers.count, 0) } - func testPlanel2FilterPlaceholder1() async throws { + func testPlanet2FilterPlaceholder1() async throws { let db = DummyDatabaseForTestSQLSerializer() _ = try await Planet2 .query(on: db) @@ -257,7 +277,7 @@ final class AsyncFluentKitTests: XCTestCase { db.reset() } - func testPlanel2FilterPlaceholder2() async throws { + func testPlanet2FilterPlaceholder2() async throws { let db = DummyDatabaseForTestSQLSerializer() _ = try await Planet2 .query(on: db) @@ -271,7 +291,7 @@ final class AsyncFluentKitTests: XCTestCase { db.reset() } - func testPlanel2FilterPlaceholder3() async throws { + func testPlanet2FilterPlaceholder3() async throws { let db = DummyDatabaseForTestSQLSerializer() _ = try await Planet2 .query(on: db) @@ -287,7 +307,7 @@ final class AsyncFluentKitTests: XCTestCase { db.reset() } - func testPlanel2FilterPlaceholder4() async throws { + func testPlanet2FilterPlaceholder4() async throws { let db = DummyDatabaseForTestSQLSerializer() _ = try await Planet2 .query(on: db) @@ -304,7 +324,7 @@ final class AsyncFluentKitTests: XCTestCase { } func testDatabaseGeneratedIDOverride() async throws { - final class Foo: Model { + final class DGOFoo: Model { static let schema = "foos" @ID(custom: .id) var id: Int? init() { } @@ -330,7 +350,7 @@ final class AsyncFluentKitTests: XCTestCase { TestOutput(["id": 0]) ] } - let foo = Foo(id: 1) + let foo = DGOFoo(id: 1) try await foo.create(on: test.db) XCTAssertEqual(foo.id, 1) } @@ -348,5 +368,3 @@ final class AsyncFluentKitTests: XCTestCase { .paginate(pageRequest2) } } -#endif -#endif diff --git a/Tests/FluentKitTests/AsyncTests/AsyncQueryBuilderTests.swift b/Tests/FluentKitTests/AsyncTests/AsyncQueryBuilderTests.swift index ea855a15..2b5d7600 100644 --- a/Tests/FluentKitTests/AsyncTests/AsyncQueryBuilderTests.swift +++ b/Tests/FluentKitTests/AsyncTests/AsyncQueryBuilderTests.swift @@ -1,13 +1,15 @@ -#if compiler(>=5.5) && canImport(_Concurrency) -#if !os(Linux) -@testable import FluentKit -@testable import FluentBenchmark +import FluentKit +import FluentBenchmark import XCTest import Foundation import XCTFluent -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) final class AsyncQueryBuilderTests: XCTestCase { + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + func testFirstEmptyResult() async throws { let test = ArrayTestDatabase() test.append([]) @@ -137,6 +139,114 @@ final class AsyncQueryBuilderTests: XCTestCase { XCTAssertEqual(retrievedPlanets.items.count, pageSizeLimit, "Page size limit should be respected.") XCTAssertEqual(retrievedPlanets.items.first?.name, "c", "Page size limit should determine offset") } + + func testPaginationInvalidLowerPageBoundary() async throws { + + // Given - a dataset with 5 items + let starId = UUID() + let rows = [ + TestOutput(["id": UUID(), "name": "a", "star_id": starId]), + TestOutput(["id": UUID(), "name": "b", "star_id": starId]), + TestOutput(["id": UUID(), "name": "c", "star_id": starId]), + TestOutput(["id": UUID(), "name": "d", "star_id": starId]), + TestOutput(["id": UUID(), "name": "e", "star_id": starId]), + ] + + let test = CallbackTestDatabase { query in + XCTAssertEqual(query.schema, "planets") + let result: [TestOutput] + if + let limit = query.limits.first, + case let DatabaseQuery.Limit.count(limitValue) = limit, + let offset = query.offsets.first, + case let DatabaseQuery.Offset.count(offsetValue) = offset + { + result = [TestOutput](rows[min(offsetValue, rows.count - 1).. "planets"."name" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1) AND ("stars"."deleted_at" IS NULL OR "stars"."deleted_at" > $2)"#) + } } -#endif -#endif diff --git a/Tests/FluentKitTests/CompositeIDTests.swift b/Tests/FluentKitTests/CompositeIDTests.swift new file mode 100644 index 00000000..53a0aa05 --- /dev/null +++ b/Tests/FluentKitTests/CompositeIDTests.swift @@ -0,0 +1,491 @@ +import FluentKit +import FluentBenchmark +import XCTest +import Foundation +import FluentSQL +import XCTFluent +import NIOCore + +extension Collection { + func xctAt(_ idx: Self.Index, file: StaticString = #fileID, line: UInt = #line) throws -> Self.Element { + let idx = try XCTUnwrap(self.indices.first { $0 == idx }, file: (file), line: line) + return self[idx] + } +} + +final class CompositeIDTests: XCTestCase { + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + + func testCompositeModelCRUD() throws { + let db = DummyDatabaseForTestSQLSerializer() + let model = CompositePlanetTag( + planetID: .init(uuidString: "00000000-0000-0000-0000-000000000000")!, + tagID: .init(uuidString: "11111111-1111-1111-1111-111111111111")! + ) + + model.notation = "composition" + _ = try model.create(on: db).wait() + XCTAssertTrue(model.$id.exists) + + Thread.sleep(forTimeInterval: 0.1) + + model.notation = "revision" + XCTAssertTrue(model.hasChanges) + try model.update(on: db).wait() + XCTAssertNotEqual(model.createdAt, model.updatedAt) + XCTAssertFalse(model.hasChanges) + + model.$id.$planet.id = .init(uuidString: "22222222-2222-2222-2222-222222222222")! + XCTAssertTrue(model.hasChanges) + try model.update(on: db).wait() + XCTAssertNotEqual(model.createdAt, model.updatedAt) + XCTAssertFalse(model.hasChanges) + + try model.delete(force: false, on: db).wait() + try model.restore(on: db).wait() + try model.delete(force: true, on: db).wait() + + XCTAssertEqual(db.sqlSerializers.count, 6) + XCTAssertEqual(try db.sqlSerializers.xctAt(0).sql, #"INSERT INTO "composite+planet+tag" ("planet_id", "tag_id", "notation", "createdAt", "updatedAt", "deletedAt") VALUES ($1, $2, $3, $4, $5, DEFAULT)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(1).sql, #"UPDATE "composite+planet+tag" SET "notation" = $1, "updatedAt" = $2 WHERE ("composite+planet+tag"."planet_id" = $3 AND "composite+planet+tag"."tag_id" = $4) AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $5)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(2).sql, #"UPDATE "composite+planet+tag" SET "planet_id" = $1, "updatedAt" = $2 WHERE ("composite+planet+tag"."planet_id" = $3 AND "composite+planet+tag"."tag_id" = $4) AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $5)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(3).sql, #"UPDATE "composite+planet+tag" SET "updatedAt" = $1, "deletedAt" = $2 WHERE ("composite+planet+tag"."planet_id" = $3 AND "composite+planet+tag"."tag_id" = $4) AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $5)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(4).sql, #"UPDATE "composite+planet+tag" SET "updatedAt" = $1, "deletedAt" = NULL WHERE ("composite+planet+tag"."planet_id" = $2 AND "composite+planet+tag"."tag_id" = $3)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(5).sql, #"DELETE FROM "composite+planet+tag" WHERE ("composite+planet+tag"."planet_id" = $1 AND "composite+planet+tag"."tag_id" = $2)"#) + } + + func testCompositeModelQuery() throws { + let db = DummyDatabaseForTestSQLSerializer() + let model = CompositePlanetTag( + planetID: .init(uuidString: "00000000-0000-0000-0000-000000000000")!, + tagID: .init(uuidString: "11111111-1111-1111-1111-111111111111")! + ) + + _ = try CompositePlanetTag.find(.init(planetID: model.id!.$planet.id, tagID: model.id!.$tag.id), on: db).wait() + + _ = try CompositePlanetTag.query(on: db).filter(\.$id.$planet.$id == model.id!.$planet.id).filter(\.$id.$tag.$id == model.id!.$tag.id).all().wait() + + _ = try CompositePlanetTag.query(on: db).filter(\.$id.$planet.$id == model.id!.$planet.id).all().wait() + + _ = try CompositePlanetTag.query(on: db).filter(\.$id.$tag.$id == model.id!.$tag.id).withDeleted().all().wait() + + XCTAssertEqual(db.sqlSerializers.count, 4) + XCTAssertEqual(try db.sqlSerializers.xctAt(0).sql, #"SELECT "composite+planet+tag"."planet_id" AS "composite+planet+tag_planet_id", "composite+planet+tag"."tag_id" AS "composite+planet+tag_tag_id", "composite+planet+tag"."notation" AS "composite+planet+tag_notation", "composite+planet+tag"."createdAt" AS "composite+planet+tag_createdAt", "composite+planet+tag"."updatedAt" AS "composite+planet+tag_updatedAt", "composite+planet+tag"."deletedAt" AS "composite+planet+tag_deletedAt" FROM "composite+planet+tag" WHERE ("composite+planet+tag"."planet_id" = $1 AND "composite+planet+tag"."tag_id" = $2) AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $3) LIMIT 1"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(1).sql, #"SELECT "composite+planet+tag"."planet_id" AS "composite+planet+tag_planet_id", "composite+planet+tag"."tag_id" AS "composite+planet+tag_tag_id", "composite+planet+tag"."notation" AS "composite+planet+tag_notation", "composite+planet+tag"."createdAt" AS "composite+planet+tag_createdAt", "composite+planet+tag"."updatedAt" AS "composite+planet+tag_updatedAt", "composite+planet+tag"."deletedAt" AS "composite+planet+tag_deletedAt" FROM "composite+planet+tag" WHERE "composite+planet+tag"."planet_id" = $1 AND "composite+planet+tag"."tag_id" = $2 AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $3)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(2).sql, #"SELECT "composite+planet+tag"."planet_id" AS "composite+planet+tag_planet_id", "composite+planet+tag"."tag_id" AS "composite+planet+tag_tag_id", "composite+planet+tag"."notation" AS "composite+planet+tag_notation", "composite+planet+tag"."createdAt" AS "composite+planet+tag_createdAt", "composite+planet+tag"."updatedAt" AS "composite+planet+tag_updatedAt", "composite+planet+tag"."deletedAt" AS "composite+planet+tag_deletedAt" FROM "composite+planet+tag" WHERE "composite+planet+tag"."planet_id" = $1 AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $2)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(3).sql, #"SELECT "composite+planet+tag"."planet_id" AS "composite+planet+tag_planet_id", "composite+planet+tag"."tag_id" AS "composite+planet+tag_tag_id", "composite+planet+tag"."notation" AS "composite+planet+tag_notation", "composite+planet+tag"."createdAt" AS "composite+planet+tag_createdAt", "composite+planet+tag"."updatedAt" AS "composite+planet+tag_updatedAt", "composite+planet+tag"."deletedAt" AS "composite+planet+tag_deletedAt" FROM "composite+planet+tag" WHERE "composite+planet+tag"."tag_id" = $1"#) + } + + func testCompositeIDMigration() throws { + let db = DummyDatabaseForTestSQLSerializer() + try CompositePlanetTagMigration().prepare(on: db).wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"CREATE TABLE "composite+planet+tag"("planet_id" UUID NOT NULL REFERENCES "planets" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, "tag_id" UUID NOT NULL REFERENCES "tags" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, "notation" TEXT NOT NULL, "createdAt" TIMESTAMPTZ, "updatedAt" TIMESTAMPTZ, "deletedAt" TIMESTAMPTZ, PRIMARY KEY ("planet_id", "tag_id"))"#) + } + + func testCompositeIDRelations() throws { + let db = DummyDatabaseForTestSQLSerializer() + let model = CompositePlanetTag( + planetID: .init(uuidString: "00000000-0000-0000-0000-000000000000")!, + tagID: .init(uuidString: "11111111-1111-1111-1111-111111111111")! + ) + let planet = PlanetUsingCompositePivot(id: model.$id.$planet.id, name: "Planet", starId: .init(uuidString: "22222222-2222-2222-2222-222222222222")!) + planet.$planetTags.fromId = planet.id! + planet.$tags.fromId = planet.id! + let tag = Tag(id: .init(uuidString: "33333333-3333-3333-3333-333333333333")!, name: "Tag") + + _ = try model.$id.$planet.get(on: db).wait() + _ = try planet.$planetTags.get(on: db).wait() + _ = try planet.$tags.get(on: db).wait() + + try planet.$planetTags.create(model, on: db).wait() + try planet.$tags.attach(tag, method: .always, on: db).wait() + try planet.$tags.attach(tag, method: .ifNotExists, on: db).wait() + _ = try planet.$tags.isAttached(to: tag, on: db).wait() + try planet.$tags.detach(tag, on: db).wait() + try planet.$tags.detachAll(on: db).wait() + + XCTAssertEqual(db.sqlSerializers.count, 9) + XCTAssertEqual(try db.sqlSerializers.xctAt(0).sql, #"SELECT "planets"."id" AS "planets_id", "planets"."name" AS "planets_name", "planets"."star_id" AS "planets_star_id" FROM "planets" WHERE "planets"."id" = $1 LIMIT 1"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(1).sql, #"SELECT "composite+planet+tag"."planet_id" AS "composite+planet+tag_planet_id", "composite+planet+tag"."tag_id" AS "composite+planet+tag_tag_id", "composite+planet+tag"."notation" AS "composite+planet+tag_notation", "composite+planet+tag"."createdAt" AS "composite+planet+tag_createdAt", "composite+planet+tag"."updatedAt" AS "composite+planet+tag_updatedAt", "composite+planet+tag"."deletedAt" AS "composite+planet+tag_deletedAt" FROM "composite+planet+tag" WHERE "composite+planet+tag"."planet_id" = $1 AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $2)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(2).sql, #"SELECT "tags"."id" AS "tags_id", "tags"."name" AS "tags_name", "composite+planet+tag"."planet_id" AS "composite+planet+tag_planet_id", "composite+planet+tag"."tag_id" AS "composite+planet+tag_tag_id", "composite+planet+tag"."notation" AS "composite+planet+tag_notation", "composite+planet+tag"."createdAt" AS "composite+planet+tag_createdAt", "composite+planet+tag"."updatedAt" AS "composite+planet+tag_updatedAt", "composite+planet+tag"."deletedAt" AS "composite+planet+tag_deletedAt" FROM "tags" INNER JOIN "composite+planet+tag" ON "tags"."id" = "composite+planet+tag"."tag_id" WHERE "composite+planet+tag"."planet_id" = $1 AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $2)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(3).sql, #"INSERT INTO "composite+planet+tag" ("planet_id", "tag_id", "notation", "createdAt", "updatedAt", "deletedAt") VALUES ($1, $2, DEFAULT, $3, $4, DEFAULT)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(4).sql, #"INSERT INTO "composite+planet+tag" ("planet_id", "tag_id", "notation", "createdAt", "updatedAt", "deletedAt") VALUES ($1, $2, DEFAULT, $3, $4, DEFAULT)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(5).sql, #"SELECT COUNT("composite+planet+tag"."planet_id") AS "aggregate" FROM "composite+planet+tag" WHERE "composite+planet+tag"."planet_id" = $1 AND "composite+planet+tag"."tag_id" = $2 AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $3)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(6).sql, #"SELECT COUNT("composite+planet+tag"."planet_id") AS "aggregate" FROM "composite+planet+tag" WHERE "composite+planet+tag"."planet_id" = $1 AND "composite+planet+tag"."tag_id" = $2 AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $3)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(7).sql, #"UPDATE "composite+planet+tag" SET "updatedAt" = $1, "deletedAt" = $2 WHERE "composite+planet+tag"."planet_id" = $3 AND "composite+planet+tag"."tag_id" = $4 AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $5)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(8).sql, #"UPDATE "composite+planet+tag" SET "updatedAt" = $1, "deletedAt" = $2 WHERE "composite+planet+tag"."planet_id" = $3 AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $4)"#) + } + + func testSiblingAttachWithAsyncEditClosures() async throws { + let db = DummyDatabaseForTestSQLSerializer() + + let model = CompositePlanetTag( + planetID: .init(uuidString: "00000000-0000-0000-0000-000000000000")!, + tagID: .init(uuidString: "11111111-1111-1111-1111-111111111111")! + ) + let planet = PlanetUsingCompositePivot(id: model.$id.$planet.id, name: "Planet", starId: .init(uuidString: "22222222-2222-2222-2222-222222222222")!) + planet.$planetTags.fromId = planet.id! + planet.$tags.fromId = planet.id! + let tag = Tag(id: .init(uuidString: "33333333-3333-3333-3333-333333333333")!, name: "Tag") + + try await planet.$planetTags.create(model, on: db) + try await planet.$tags.attach([tag], on: db) { pivot in + _ = try await Planet.query(on: db).all() // just to make there be something async happening + pivot.notation = "notation" + } + try await planet.$tags.attach(tag, on: db) { pivot in + _ = try await Planet.query(on: db).all() // just to make there be something async happening + pivot.notation = "notation" + } + try await planet.$tags.attach(tag, method: .ifNotExists, on: db) { pivot in + _ = try await Planet.query(on: db).all() // just to make there be something async happening + pivot.notation = "notation" + } + + XCTAssertEqual(db.sqlSerializers.count, 6) + XCTAssertEqual(try db.sqlSerializers.xctAt(0).sql, #"INSERT INTO "composite+planet+tag" ("planet_id", "tag_id", "notation", "createdAt", "updatedAt", "deletedAt") VALUES ($1, $2, DEFAULT, $3, $4, DEFAULT)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(1).sql, #"SELECT "planets"."id" AS "planets_id", "planets"."name" AS "planets_name", "planets"."star_id" AS "planets_star_id", "planets"."possible_star_id" AS "planets_possible_star_id", "planets"."deleted_at" AS "planets_deleted_at" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(2).sql, #"INSERT INTO "composite+planet+tag" ("planet_id", "tag_id", "notation", "createdAt", "updatedAt", "deletedAt") VALUES ($1, $2, $3, $4, $5, DEFAULT)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(3).sql, #"SELECT "planets"."id" AS "planets_id", "planets"."name" AS "planets_name", "planets"."star_id" AS "planets_star_id", "planets"."possible_star_id" AS "planets_possible_star_id", "planets"."deleted_at" AS "planets_deleted_at" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(4).sql, #"INSERT INTO "composite+planet+tag" ("planet_id", "tag_id", "notation", "createdAt", "updatedAt", "deletedAt") VALUES ($1, $2, $3, $4, $5, DEFAULT)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(5).sql, #"SELECT COUNT("composite+planet+tag"."planet_id") AS "aggregate" FROM "composite+planet+tag" WHERE "composite+planet+tag"."planet_id" = $1 AND "composite+planet+tag"."tag_id" = $2 AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $3)"#) + + planet.$tags.fromId = nil + XCTAssertThrowsError(try planet.$tags.attach(tag, on: db).wait()) { + guard case .owningModelIdRequired(_) = $0 as? SiblingsPropertyError else { + return XCTFail("Expected SiblingsPropertyError.owningModelIdRequired, but got \(String(reflecting: $0))") + } + } + } + + func testCompositeIDFilterByID() throws { + let db = DummyDatabaseForTestSQLSerializer() + let planetId = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + tagId = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + + _ = try CompositePlanetTag.query(on: db).filter(\.$id == CompositePlanetTag.IDValue.init(planetID: planetId, tagID: tagId)).all().wait() + _ = try CompositePlanetTag.query(on: db).filter(\.$id != CompositePlanetTag.IDValue.init(planetID: planetId, tagID: tagId)).all().wait() + + XCTAssertEqual(db.sqlSerializers.count, 2) + XCTAssertEqual(try db.sqlSerializers.xctAt(0).sql, #"SELECT "composite+planet+tag"."planet_id" AS "composite+planet+tag_planet_id", "composite+planet+tag"."tag_id" AS "composite+planet+tag_tag_id", "composite+planet+tag"."notation" AS "composite+planet+tag_notation", "composite+planet+tag"."createdAt" AS "composite+planet+tag_createdAt", "composite+planet+tag"."updatedAt" AS "composite+planet+tag_updatedAt", "composite+planet+tag"."deletedAt" AS "composite+planet+tag_deletedAt" FROM "composite+planet+tag" WHERE ("composite+planet+tag"."planet_id" = $1 AND "composite+planet+tag"."tag_id" = $2) AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $3)"#) + XCTAssertEqual(try db.sqlSerializers.xctAt(1).sql, #"SELECT "composite+planet+tag"."planet_id" AS "composite+planet+tag_planet_id", "composite+planet+tag"."tag_id" AS "composite+planet+tag_tag_id", "composite+planet+tag"."notation" AS "composite+planet+tag_notation", "composite+planet+tag"."createdAt" AS "composite+planet+tag_createdAt", "composite+planet+tag"."updatedAt" AS "composite+planet+tag_updatedAt", "composite+planet+tag"."deletedAt" AS "composite+planet+tag_deletedAt" FROM "composite+planet+tag" WHERE ("composite+planet+tag"."planet_id" <> $1 OR "composite+planet+tag"."tag_id" <> $2) AND ("composite+planet+tag"."deletedAt" IS NULL OR "composite+planet+tag"."deletedAt" > $3)"#) + } + + func testCompositeParentAndChildQuerying() throws { + let db = DummyDatabaseForTestSQLSerializer() + let systemId = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + + _ = try CompositeMoon(name: "", planetSolarSystemId: systemId, planetNormalizedOrdinal: 1).$orbiting.query(on: db).all().wait() + _ = try CompositeMoon(name: "", planetSolarSystemId: systemId, planetNormalizedOrdinal: 1, progenitorId: .init(solarSystemId: systemId, normalizedOrdinal: 2)).$progenitor.query(on: db).all().wait() + _ = try CompositeMoon(name: "", planetSolarSystemId: systemId, planetNormalizedOrdinal: 1, progenitorId: nil).$progenitor.query(on: db).all().wait() + _ = try CompositeMoon(name: "", planetSolarSystemId: systemId, planetNormalizedOrdinal: 1, progenitorId: nil, planetoidId: .init(solarSystemId: systemId, normalizedOrdinal: 3)).$planetoid.query(on: db).all().wait() + _ = try CompositeMoon(name: "", planetSolarSystemId: systemId, planetNormalizedOrdinal: 1, progenitorId: nil, planetoidId: nil).$planetoid.query(on: db).all().wait() + _ = try CompositePlanet(name: "", solarSystemId: systemId, normalizedOrdinal: 1).$moons.query(on: db).all().wait() + _ = try CompositePlanet(name: "", solarSystemId: systemId, normalizedOrdinal: 2).$moonsMade.query(on: db).all().wait() + _ = try CompositePlanet(name: "", solarSystemId: systemId, normalizedOrdinal: 3).$fragment.query(on: db).all().wait() + + let allPlanetFields = #""composite+planet"."system_id" AS "composite+planet_system_id", "composite+planet"."nrm_ord" AS "composite+planet_nrm_ord", "composite+planet"."name" AS "composite+planet_name" FROM "composite+planet""# + let allMoonFields = #""composite+moon"."id" AS "composite+moon_id", "composite+moon"."name" AS "composite+moon_name", "composite+moon"."planet_system_id" AS "composite+moon_planet_system_id", "composite+moon"."planet_nrm_ord" AS "composite+moon_planet_nrm_ord", "composite+moon"."progenitorSystem_id" AS "composite+moon_progenitorSystem_id", "composite+moon"."progenitorNrm_ord" AS "composite+moon_progenitorNrm_ord", "composite+moon"."planetoid_system_id" AS "composite+moon_planetoid_system_id", "composite+moon"."planetoid_nrm_ord" AS "composite+moon_planetoid_nrm_ord" FROM "composite+moon""# + + let expectedQueries: [(String, [Encodable])] = [ + (#"SELECT \#(allPlanetFields) WHERE ("composite+planet"."system_id" = $1 AND "composite+planet"."nrm_ord" = $2)"#, [systemId, 1]), + (#"SELECT \#(allPlanetFields) WHERE ("composite+planet"."system_id" = $1 AND "composite+planet"."nrm_ord" = $2)"#, [systemId, 2]), + (#"SELECT \#(allPlanetFields) WHERE ("composite+planet"."system_id" IS NULL AND "composite+planet"."nrm_ord" IS NULL)"#, []), + (#"SELECT \#(allPlanetFields) WHERE ("composite+planet"."system_id" = $1 AND "composite+planet"."nrm_ord" = $2)"#, [systemId, 3]), + (#"SELECT \#(allPlanetFields) WHERE ("composite+planet"."system_id" IS NULL AND "composite+planet"."nrm_ord" IS NULL)"#, []), + (#"SELECT \#(allMoonFields) WHERE ("composite+moon"."planet_system_id" = $1 AND "composite+moon"."planet_nrm_ord" = $2)"#, [systemId, 1]), + (#"SELECT \#(allMoonFields) WHERE ("composite+moon"."progenitorSystem_id" = $1 AND "composite+moon"."progenitorNrm_ord" = $2)"#, [systemId, 2]), + (#"SELECT \#(allMoonFields) WHERE ("composite+moon"."planetoid_system_id" = $1 AND "composite+moon"."planetoid_nrm_ord" = $2)"#, [systemId, 3]), + ] + XCTAssertEqual(db.sqlSerializers.count, expectedQueries.count) + for ((query, binds), serializer) in zip(expectedQueries, db.sqlSerializers) { + XCTAssertEqual(serializer.sql, query) + XCTAssertEqual(serializer.binds.count, binds.count) + for (lBind, rBind) in zip(binds, serializer.binds) { + XCTAssertEqual("\(lBind)", "\(rBind)") + } + } + } + + func testCompositeParentChildMutating() throws { + let db = DummyDatabaseForTestSQLSerializer() + let sysId = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, sys2Id = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + + let planet1 = CompositePlanet(name: "A", solarSystemId: sysId, normalizedOrdinal: 1) + let moon1 = CompositeMoon(name: "B", planetSolarSystemId: sysId, planetNormalizedOrdinal: 1) + let moon2 = CompositeMoon(name: "C", planetSolarSystemId: sysId, planetNormalizedOrdinal: 1, progenitorId: .init(solarSystemId: sysId, normalizedOrdinal: 2)) + let moon3 = CompositeMoon(name: "D", planetSolarSystemId: sysId, planetNormalizedOrdinal: 1) + let moon4 = CompositeMoon(name: "E", planetSolarSystemId: sysId, planetNormalizedOrdinal: 1, planetoidId: .init(solarSystemId: sysId, normalizedOrdinal: 3)) + + try planet1.create(on: db).wait() + try [moon1, moon2, moon3, moon4].forEach { try $0.create(on: db).wait() } + + planet1.name = "AA" + try planet1.update(on: db).wait() + + moon1.$orbiting.id.$solarSystem.id = sys2Id + moon1.$orbiting.id.normalizedOrdinal = 2 + moon2.$progenitor.id = nil + moon3.$planetoid.id = .init(solarSystemId: sys2Id, normalizedOrdinal: 3) + moon4.$planetoid.id = nil + try [moon1, moon2, moon3, moon4].forEach { try $0.update(on: db).wait() } + + let moonCols = #""id", "name", "planet_system_id", "planet_nrm_ord", "progenitorSystem_id", "progenitorNrm_ord", "planetoid_system_id", "planetoid_nrm_ord""# + let fourVals = "$1, $2, $3, $4, NULL, NULL, NULL, NULL" + let sixVals1 = "$1, $2, $3, $4, $5, $6, NULL, NULL", sixVals2 = "$1, $2, $3, $4, NULL, NULL, $5, $6" + let expectedQueries: [(String, [Encodable], UInt)] = [ + (#"INSERT INTO "composite+planet" ("system_id", "nrm_ord", "name") VALUES ($1, $2, $3)"#, [sysId, 1, "A"], #line), + (#"INSERT INTO "composite+moon" (\#(moonCols)) VALUES (\#(fourVals))"#, [moon1.id!, "B", sysId, 1], #line), + (#"INSERT INTO "composite+moon" (\#(moonCols)) VALUES (\#(sixVals1))"#, [moon2.id!, "C", sysId, 1, sysId, 2], #line), + (#"INSERT INTO "composite+moon" (\#(moonCols)) VALUES (\#(fourVals))"#, [moon3.id!, "D", sysId, 1], #line), + (#"INSERT INTO "composite+moon" (\#(moonCols)) VALUES (\#(sixVals2))"#, [moon4.id!, "E", sysId, 1, sysId, 3], #line), + (#"UPDATE "composite+planet" SET "name" = $1 WHERE ("composite+planet"."system_id" = $2 AND "composite+planet"."nrm_ord" = $3)"#, ["AA", sysId, 1], #line), + (#"UPDATE "composite+moon" SET "planet_system_id" = $1, "planet_nrm_ord" = $2 WHERE "composite+moon"."id" = $3"#, [sys2Id, 2, moon1.id!], #line), + (#"UPDATE "composite+moon" SET "progenitorSystem_id" = NULL, "progenitorNrm_ord" = NULL WHERE "composite+moon"."id" = $1"#, [moon2.id!], #line), + (#"UPDATE "composite+moon" SET "planetoid_system_id" = $1, "planetoid_nrm_ord" = $2 WHERE "composite+moon"."id" = $3"#, [sys2Id, 3, moon3.id!], #line), + (#"UPDATE "composite+moon" SET "planetoid_system_id" = NULL, "planetoid_nrm_ord" = NULL WHERE "composite+moon"."id" = $1"#, [moon4.id!], #line), + ] + + XCTAssertEqual(db.sqlSerializers.count, expectedQueries.count) + for ((query, binds, line), serializer) in zip(expectedQueries, db.sqlSerializers) { + XCTAssertEqual(serializer.sql, query, file: #filePath, line: line) + XCTAssertEqual(serializer.binds.count, binds.count, file: #filePath, line: line) + for (lBind, rBind) in zip(binds, serializer.binds) { + XCTAssertEqual("\(lBind)", "\(rBind)", file: #filePath, line: line) + } + } + } + + func testCompositeParentChildEncoding() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + func jsonString(_ value: E) throws -> String { try String(decoding: encoder.encode(value), as: UTF8.self) } + + let sysId = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, moonId = UUID(), moonJId = #""\#(moonId.uuidString)""# + let planet = CompositePlanet(name: "A", solarSystemId: sysId, normalizedOrdinal: 1) + let moon = CompositeMoon(id: moonId, name: "B", planetSolarSystemId: sysId, planetNormalizedOrdinal: 1) + + let sysJId = #"{"normalizedOrdinal":1,"solarSystem":{"id":"\#(sysId.uuidString)"}}"# + let moonJDat = #"{"id":\#(moonJId),"name":"B","orbiting":{"id":\#(sysJId)},"planetoid":{"id":\#(sysJId)},"progenitor":{"id":\#(sysJId)}}"# + + // Unloaded children properties + XCTAssertEqual(try jsonString(planet), #"{"id":\#(sysJId),"name":"A"}"#) + // Unset optional parent properties + XCTAssertEqual(try jsonString(moon), #"{"id":\#(moonJId),"name":"B","orbiting":{"id":\#(sysJId)},"planetoid":{"id":null},"progenitor":{"id":null}}"#) + + // OptionalChild loaded as NULL, Children properties loaded empty + (planet.$moons.value, planet.$moonsMade.value, planet.$fragment.value) = ([], [], .some(.none)) + XCTAssertEqual(try jsonString(planet), #"{"fragment":null,"id":\#(sysJId),"moons":[],"moonsMade":[],"name":"A"}"#) + + // Parent unloaded, OptionalParent set with ID and unset/explicit null value respectively + moon.$orbiting.value = nil + (moon.$progenitor.id, moon.$progenitor.value) = (planet.id, .none) + (moon.$planetoid.id, moon.$planetoid.value) = (planet.id, .some(.none)) + XCTAssertEqual(try jsonString(moon), moonJDat) + + // Children properties loaded with value(s) + (planet.$moons.value, planet.$moonsMade.value, planet.$fragment.value) = ([moon], [moon], .some(.some(moon))) + XCTAssertEqual(try jsonString(planet), #"{"fragment":\#(moonJDat),"id":\#(sysJId),"moons":[\#(moonJDat)],"moonsMade":[\#(moonJDat)],"name":"A"}"#) + + // Parent properties set with IDs and values + (moon.$orbiting.value, planet.$moons.value) = (planet, nil) + (moon.$progenitor.value, planet.$moonsMade.value) = (.some(.some(planet)), nil) + (moon.$planetoid.value, planet.$fragment.value) = (.some(.some(planet)), .none) + XCTAssertEqual(try jsonString(moon), #"{"id":\#(moonJId),"name":"B","orbiting":{"id":\#(sysJId),"name":"A"},"planetoid":{"id":\#(sysJId),"name":"A"},"progenitor":{"id":\#(sysJId),"name":"A"}}"#) + } + + func testCompositeParentChildDecoding() throws { + let decoder = JSONDecoder() + func unjsonString(_ json: String, as: D.Type = D.self) throws -> D { try decoder.decode(D.self, from: json.data(using: .utf8)!) } + + let sysId = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, moonId = UUID(), sysJId = #""\#(sysId.uuidString)""#, moonJId = #""\#(moonId.uuidString)""# + + let planet1 = try unjsonString(#"{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}},"name":"A"}"#, as: CompositePlanet.self) + XCTAssertEqual(planet1.$id.$solarSystem.id, sysId) + XCTAssertEqual(planet1.id!.normalizedOrdinal, 1) + XCTAssertEqual(planet1.name, "A") + XCTAssertNil(planet1.$moons.fromId) + XCTAssertNil(planet1.$moons.value) + XCTAssertNil(planet1.$moonsMade.fromId) + XCTAssertNil(planet1.$moonsMade.value) + XCTAssertNil(planet1.$fragment.fromId) + XCTAssertNilNil(planet1.$fragment.value) + + let moon1 = try unjsonString(#"{"id":\#(moonJId),"name":"B","orbiting":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}}},"planetoid":{"id":null},"progenitor":{"id":null}}"#, as: CompositeMoon.self) + XCTAssertEqual(moon1.id, moonId) + XCTAssertEqual(moon1.name, "B") + XCTAssertEqual(moon1.$orbiting.id, planet1.id!) + XCTAssertNil(moon1.$orbiting.value) + XCTAssertNil(moon1.$progenitor.id) + XCTAssertNilNil(moon1.$progenitor.value) + XCTAssertNil(moon1.$planetoid.id) + XCTAssertNilNil(moon1.$planetoid.value) + let moon1_1 = try unjsonString(#"{"id":\#(moonJId),"name":"B","orbiting":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}},"name":"A"},"planetoid":{"id":null},"progenitor":{"id":null}}"#, as: CompositeMoon.self) + XCTAssertNil(moon1_1.$orbiting.value) + + let moon2 = try unjsonString(#"{"id":\#(moonJId),"name":"B","orbiting":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}}},"planetoid":{"id":null},"progenitor":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}}}}"#, as: CompositeMoon.self) + XCTAssertEqual(moon2.id, moonId) + XCTAssertEqual(moon2.name, "B") + XCTAssertEqual(moon2.$orbiting.id, planet1.id!) + XCTAssertNil(moon2.$orbiting.value) + XCTAssertEqual(moon2.$progenitor.id, planet1.id!) + XCTAssertNilNil(moon2.$progenitor.value) + XCTAssertNil(moon2.$planetoid.id) + XCTAssertNilNil(moon2.$planetoid.value) + let moon2_1 = try unjsonString(#"{"id":\#(moonJId),"name":"B","orbiting":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}}},"planetoid":{"id":null},"progenitor":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}},"name":"A"}}"#, as: CompositeMoon.self) + XCTAssertNilNil(moon2_1.$progenitor.value) + + let moon3 = try unjsonString(#"{"id":\#(moonJId),"name":"B","orbiting":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}}},"planetoid":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}}},"progenitor":{"id":null}}"#, as: CompositeMoon.self) + XCTAssertEqual(moon3.id, moonId) + XCTAssertEqual(moon3.name, "B") + XCTAssertEqual(moon3.$orbiting.id, planet1.id!) + XCTAssertNil(moon3.$orbiting.value) + XCTAssertNil(moon3.$progenitor.id) + XCTAssertNilNil(moon3.$progenitor.value) + XCTAssertEqual(moon3.$planetoid.id, planet1.id!) + XCTAssertNilNil(moon3.$planetoid.value) + let moon3_1 = try unjsonString(#"{"id":\#(moonJId),"name":"B","orbiting":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}}},"planetoid":{"id":{"normalizedOrdinal":1,"solarSystem":{"id":\#(sysJId)}},"name":"A"},"progenitor":{"id":null}}"#, as: CompositeMoon.self) + XCTAssertNilNil(moon3_1.$planetoid.value) + } +} + +fileprivate func XCTAssertNilNil(_ expression: @autoclosure () throws -> Optional>, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + func messageIfGiven() -> String { let m = message(); guard !m.isEmpty else { return m }; return " - \(m)" } + switch Result(catching: { try expression() }) { + case .success(.none): return + case .success(.some(.none)): return XCTFail("XCTAssertNilNil failed: \".some(nil)\"\(messageIfGiven())", file: file, line: line) + case .success(.some(.some(let value))): return XCTFail("XCTAssertNilNil failed: \".some(.some(\(value)))\"\(messageIfGiven())", file: file, line: line) + case .failure(let error): return XCTFail("XCTAssertNilNil failed: threw error \"\(error)\"\(messageIfGiven())", file: file, line: line) + } +} + +final class PlanetUsingCompositePivot: Model { + static let schema = Planet.schema + + @ID(key: .id) var id: UUID? + @Field(key: "name") var name: String + @Parent(key: "star_id") var star: Star + @Children(for: \.$id.$planet) var planetTags: [CompositePlanetTag] + @Siblings(through: CompositePlanetTag.self, from: \.$id.$planet, to: \.$id.$tag) var tags: [Tag] + + init() {} + init(id: IDValue? = nil, name: String) { (self.id, self.name) = (id, name) } + init(id: IDValue? = nil, name: String, starId: UUID) { + self.id = id + self.name = name + self.$star.id = starId + } +} + +final class CompositePlanetTag: Model { + static let schema = "composite+planet+tag" + + final class IDValue: Fields, Hashable { + @Parent(key: "planet_id") var planet: PlanetUsingCompositePivot + @Parent(key: "tag_id") var tag: Tag + + init() {} + init(planetID: PlanetUsingCompositePivot.IDValue, tagID: Tag.IDValue) { (self.$planet.id, self.$tag.id) = (planetID, tagID) } + static func == (lhs: IDValue, rhs: IDValue) -> Bool { lhs.$planet.id == rhs.$planet.id && lhs.$tag.id == rhs.$tag.id } + func hash(into hasher: inout Hasher) { hasher.combine(self.$planet.id); hasher.combine(self.$tag.id) } + } + + @CompositeID var id: IDValue? + @Field(key: "notation") var notation: String + @Timestamp(key: "createdAt", on: .create) var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) var deletedAt: Date? + + init() {} + init(planetID: PlanetUsingCompositePivot.IDValue, tagID: Tag.IDValue) { self.id = .init(planetID: planetID, tagID: tagID) } +} + +struct CompositePlanetTagMigration: Migration { + init() {} + + func prepare(on database: Database) -> EventLoopFuture { + database.schema(CompositePlanetTag.schema) + .field("planet_id", .uuid, .required, .references(PlanetUsingCompositePivot.schema, "id")) + .field("tag_id", .uuid, .required, .references(Tag.schema, "id")) + .field("notation", .string, .required) + .field("createdAt", .datetime) + .field("updatedAt", .datetime) + .field("deletedAt", .datetime) + .compositeIdentifier(over: "planet_id", "tag_id") + .create() + } + + func revert(on database: Database) -> EventLoopFuture { + database.schema(CompositePlanetTag.schema).delete() + } +} + +final class SolarSystem: Model { + static let schema = "solar_system" + + @ID(key: .id) var id: UUID? + @Field(key: "name") var name: String + @Children(for: \.$id.$solarSystem) var planets: [CompositePlanet] + + init() {} + init(id: IDValue? = nil, name: String) { + if let id = id { self.id = id } + self.name = name + self.$planets.fromId = self.id + } +} + +final class CompositePlanet: Model { + static let schema = "composite+planet" + + // Note for the curious: "normalized ordinal" means "how many orbits from the center if a unique value was chosen for every planet despite overlapping or shared orbits" + final class IDValue: Fields, Hashable { + @Parent(key: "system_id") var solarSystem: SolarSystem + @Field(key: "nrm_ord") var normalizedOrdinal: Int + + init() {} + init(solarSystemId: SolarSystem.IDValue, normalizedOrdinal: Int) { + (self.$solarSystem.id, self.normalizedOrdinal) = (solarSystemId, normalizedOrdinal) + } + static func ==(lhs: IDValue, rhs: IDValue) -> Bool { lhs.$solarSystem.id == rhs.$solarSystem.id && lhs.normalizedOrdinal == rhs.normalizedOrdinal } + func hash(into hasher: inout Hasher) { hasher.combine(self.$solarSystem.id); hasher.combine(self.normalizedOrdinal) } + } + + @CompositeID var id: IDValue? + @Field(key: "name") var name: String + @CompositeChildren(for: \.$orbiting) var moons: [CompositeMoon] + @CompositeChildren(for: \.$progenitor) var moonsMade: [CompositeMoon] + @CompositeOptionalChild(for: \.$planetoid) var fragment: CompositeMoon? + + init() {} + init(name: String, solarSystemId: SolarSystem.IDValue, normalizedOrdinal: Int) { + self.id = .init(solarSystemId: solarSystemId, normalizedOrdinal: normalizedOrdinal) + self.name = name + self.$moons.fromId = self.id + self.$moonsMade.fromId = self.id + self.$fragment.fromId = self.id + } +} + +final class CompositeMoon: Model { + static let schema = "composite+moon" + + @ID(key: .id) var id: UUID? + @Field(key: "name") var name: String + @CompositeParent(prefix: "planet") var orbiting: CompositePlanet + @CompositeOptionalParent(prefix: "progenitor", strategy: .camelCase) var progenitor: CompositePlanet? + @CompositeOptionalParent(prefix: "planetoid") var planetoid: CompositePlanet? + + init() {} + init(id: UUID? = nil, name: String, planetSolarSystemId: SolarSystem.IDValue, planetNormalizedOrdinal: Int, progenitorId: CompositePlanet.IDValue? = nil, planetoidId: CompositePlanet.IDValue? = nil) { + if let id = id { self.id = id } + self.name = name + self.$orbiting.id = .init(solarSystemId: planetSolarSystemId, normalizedOrdinal: planetNormalizedOrdinal) + if let progenitorId = progenitorId { self.$progenitor.id = progenitorId } + if let planetoidId = planetoidId { self.$planetoid.id = planetoidId } + } +} diff --git a/Tests/FluentKitTests/DummyDatabaseForTestSQLSerializer.swift b/Tests/FluentKitTests/DummyDatabaseForTestSQLSerializer.swift index 5a708c90..123e8c73 100644 --- a/Tests/FluentKitTests/DummyDatabaseForTestSQLSerializer.swift +++ b/Tests/FluentKitTests/DummyDatabaseForTestSQLSerializer.swift @@ -1,4 +1,4 @@ -@testable import FluentKit +import FluentKit import FluentSQL import NIOEmbedded import SQLKit @@ -122,6 +122,14 @@ struct DummyDatabaseDialect: SQLDialect { var autoIncrementClause: SQLExpression { return SQLRaw("GENERATED BY DEFAULT AS IDENTITY") } + + var sharedSelectLockExpression: SQLExpression? { + SQLRaw("FOR SHARE") + } + + var exclusiveSelectLockExpression: SQLExpression? { + SQLRaw("FOR UPDATE") + } } // Copy from PostgresConverterDelegate diff --git a/Tests/FluentKitTests/FilterQueryTests.swift b/Tests/FluentKitTests/FilterQueryTests.swift index 176a7cba..5b01cd74 100644 --- a/Tests/FluentKitTests/FilterQueryTests.swift +++ b/Tests/FluentKitTests/FilterQueryTests.swift @@ -5,19 +5,24 @@ // Created by Mathew Polzin on 3/8/20. // -@testable import FluentKit -@testable import FluentBenchmark +import FluentKit +import FluentBenchmark import XCTest import Foundation import FluentSQL final class FilterQueryTests: XCTestCase { + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + // MARK: Enum func test_enumEquals() throws { let db = DummyDatabaseForTestSQLSerializer() _ = try Task.query(on: db).filter(\.$status == .done).all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."status" = 'done'"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" = 'done'"#) db.reset() } @@ -25,7 +30,7 @@ final class FilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try Task.query(on: db).filter(\.$status != .done).all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."status" <> 'done'"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" <> 'done'"#) db.reset() } @@ -33,7 +38,7 @@ final class FilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try Task.query(on: db).filter(\.$status ~~ [.done, .notDone]).all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."status" IN ('done' , 'notDone')"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" IN ('done','notDone')"#) db.reset() } @@ -41,7 +46,40 @@ final class FilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try Task.query(on: db).filter(\.$status !~ [.done, .notDone]).all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."status" NOT IN ('done' , 'notDone')"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" NOT IN ('done','notDone')"#) + db.reset() + } + + // MARK: OptionalEnum + func test_optionalEnumEquals() throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try Task.query(on: db).filter(\.$optionalStatus == .done).all().wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" = 'done'"#) + db.reset() + } + + func test_optionalEnumNotEquals() throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try Task.query(on: db).filter(\.$optionalStatus != .done).all().wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" <> 'done'"#) + db.reset() + } + + func test_optionalEnumIn() throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try Task.query(on: db).filter(\.$optionalStatus ~~ [.done, .notDone]).all().wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" IN ('done','notDone')"#) + db.reset() + } + + func test_optionalEnumNotIn() throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try Task.query(on: db).filter(\.$optionalStatus !~ [.done, .notDone]).all().wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" NOT IN ('done','notDone')"#) db.reset() } @@ -50,7 +88,7 @@ final class FilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try Task.query(on: db).filter(\.$description == "hello").all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."description" = $1"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" = $1"#) db.reset() } @@ -58,7 +96,7 @@ final class FilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try Task.query(on: db).filter(\.$description != "hello").all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."description" <> $1"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" <> $1"#) db.reset() } @@ -66,7 +104,7 @@ final class FilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try Task.query(on: db).filter(\.$description ~~ ["hello"]).all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."description" IN ($1)"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" IN ($1)"#) db.reset() } @@ -74,7 +112,7 @@ final class FilterQueryTests: XCTestCase { let db = DummyDatabaseForTestSQLSerializer() _ = try Task.query(on: db).filter(\.$description !~ ["hello"]).all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status" FROM "tasks" WHERE "tasks"."description" NOT IN ($1)"#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" NOT IN ($1)"#) db.reset() } } @@ -94,12 +132,9 @@ final class Task: Model { @Enum(key: "status") var status: Diggity + + @OptionalEnum(key: "optional_status") + var optionalStatus: Diggity? init() {} - - init(id: Int, status: Diggity, description: String) { - self.id = id - self.status = status - self.description = description - } } diff --git a/Tests/FluentKitTests/FluentKitTests.swift b/Tests/FluentKitTests/FluentKitTests.swift index 44d46770..0e540109 100644 --- a/Tests/FluentKitTests/FluentKitTests.swift +++ b/Tests/FluentKitTests/FluentKitTests.swift @@ -1,11 +1,51 @@ -@testable import FluentKit -@testable import FluentBenchmark +import Logging +import FluentKit +import FluentBenchmark import XCTest import Foundation import FluentSQL import XCTFluent +import SQLKit final class FluentKitTests: XCTestCase { + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + + /// This test is a deliberate code smell put in place to prevent an even worse one from + /// causing problems without at least some warning. Specifically, the output of + /// ``AnyModel//description`` is rather precise when it comes to labeling the input and + /// output dictionaries when they are present. Non-trivial effort was made to produce this + /// exact textual format. While it is never correct to rely on the output of a + /// ``description`` method (aside special cases like ``LosslessStringConvertible`` types), + /// this has been public API for ages; [Hyrum's Law](https://www.hyrumslaw.com) thus applies. + /// Since no part of Fluent or any of its drivers currently relies, or ever will rely, on + /// the format in question, it is desirable to enforce that it should never change, just in + /// case someone actually is relying on it for some hopefully very good reason. + func testAnyModelDescriptionFormatHasNotChanged() throws { + final class Foo: Model { + static let schema = "foos" + @ID(key: .id) var id: UUID? + @Field(key: "name") var name: String + @Field(key: "num") var num: Int + init() {} + } + let model = Foo() + let modelEmptyDesc = model.description + (model.name, model.num) = ("Test", 42) + let modelInputDesc = model.description + try model.save(on: DummyDatabaseForTestSQLSerializer()).wait() + let modelOutputDesc = model.description + model.num += 1 + let modelBothDesc = model.description + + XCTAssertEqual(modelEmptyDesc, "Foo(:)") + XCTAssertEqual(modelInputDesc, "Foo(input: [name: \"Test\", num: 42])") + XCTAssertEqual(modelOutputDesc, "Foo(output: [num: 42, name: \"Test\", id: \(model.id!)])") + XCTAssertEqual(modelBothDesc, "Foo(output: [num: 42, name: \"Test\", id: \(model.id!)], input: [num: 43])") + } + func testMigrationLogNames() throws { XCTAssertEqual(MigrationLog.path(for: \.$id), [.id]) XCTAssertEqual(MigrationLog.path(for: \.$name), ["name"]) @@ -65,6 +105,24 @@ final class FluentKitTests: XCTestCase { db.reset() } + func testGroupSorts() throws { + let db = DummyDatabaseForTestSQLSerializer() + _ = try User.query(on: db).sort(\.$pet.$name).all { _ in }.wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"ORDER BY "users"."pet_name" ASC"#), true) + db.reset() + + _ = try User.query(on: db).sort(\.$pet.$toy.$name, .descending).all { _ in }.wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"ORDER BY "users"."pet_toy_name" DESC"#), true) + db.reset() + + _ = try User.query(on: db).sort(\.$pet.$toy.$foo.$bar, .ascending).all { _ in }.wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"ORDER BY "users"."pet_toy_foo_bar" ASC"#), true) + db.reset() + } + func testJoins() throws { let db = DummyDatabaseForTestSQLSerializer() _ = try Planet.query(on: db).join(child: \Planet.$governor).all().wait() @@ -89,7 +147,7 @@ final class FluentKitTests: XCTestCase { _ = try Planet.query(on: db).join(siblings: \Planet.$tags).all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"INNER JOIN "planet+tag" ON "planets"."id" = "planet+tag"."planet_id""#), true) + XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"INNER JOIN "planet+tag" ON "planet+tag"."planet_id" = "planets"."id""#), true, db.sqlSerializers.first?.sql ?? "") XCTAssertEqual(db.sqlSerializers.first?.sql.contains(#"INNER JOIN "tags" ON "planet+tag"."tag_id" = "tags"."id""#), true) db.reset() } @@ -99,7 +157,7 @@ final class FluentKitTests: XCTestCase { _ = try Planet.query(on: db).all(\.$name).wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "planets"."name" AS "planets_name" FROM "planets""#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "planets"."name" AS "planets_name" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() } @@ -108,7 +166,7 @@ final class FluentKitTests: XCTestCase { _ = try Planet.query(on: db).unique().all(\.$name).wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT DISTINCT "planets"."name" AS "planets_name" FROM "planets""#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT DISTINCT "planets"."name" AS "planets_name" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() _ = try Planet.query(on: db).unique().all().wait() @@ -118,12 +176,12 @@ final class FluentKitTests: XCTestCase { _ = try? Planet.query(on: db).unique().count(\.$name).wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT COUNT(DISTINCT("planets"."name")) AS "aggregate" FROM "planets""#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT COUNT(DISTINCT("planets"."name")) AS "aggregate" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() _ = try? Planet.query(on: db).unique().sum(\.$id).wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT SUM(DISTINCT("planets"."id")) AS "aggregate" FROM "planets""#) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT SUM(DISTINCT("planets"."id")) AS "aggregate" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() } @@ -233,6 +291,20 @@ final class FluentKitTests: XCTestCase { .wait() XCTAssertEqual(db.sqlSerializers.count, 1) XCTAssertEqual(db.sqlSerializers.first?.sql, #"CREATE TABLE "planets"("galaxy_id" BIGINT, CONSTRAINT "fk:planets.galaxy_id+planets.id" FOREIGN KEY ("galaxy_id") REFERENCES "galaxies" ("id") ON DELETE RESTRICT ON UPDATE CASCADE)"#) + db.reset() + + try db.schema("planets") + .field("galaxy_id", .int64) + .field("galaxy_name", .string) + .foreignKey( + ["galaxy_id", "galaxy_name"], + references: "galaxies", ["id", "name"], + onUpdate: .cascade + ) + .create() + .wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"CREATE TABLE "planets"("galaxy_id" BIGINT, "galaxy_name" TEXT, CONSTRAINT "fk:planets.galaxy_id+planets.galaxy_name+planets.id+planets.name" FOREIGN KEY ("galaxy_id", "galaxy_name") REFERENCES "galaxies" ("id", "name") ON DELETE NO ACTION ON UPDATE CASCADE)"#) } func testIfNotExistsTableCreate() throws { @@ -364,7 +436,7 @@ final class FluentKitTests: XCTestCase { } } - func testPlanel2FilterPlaceholder1() throws { + func testPlanet2FilterPlaceholder1() throws { let db = DummyDatabaseForTestSQLSerializer() _ = try Planet2 .query(on: db) @@ -378,7 +450,7 @@ final class FluentKitTests: XCTestCase { db.reset() } - func testPlanel2FilterPlaceholder2() throws { + func testPlanet2FilterPlaceholder2() throws { let db = DummyDatabaseForTestSQLSerializer() _ = try Planet2 .query(on: db) @@ -392,7 +464,7 @@ final class FluentKitTests: XCTestCase { db.reset() } - func testPlanel2FilterPlaceholder3() throws { + func testPlanet2FilterPlaceholder3() throws { let db = DummyDatabaseForTestSQLSerializer() _ = try Planet2 .query(on: db) @@ -408,7 +480,7 @@ final class FluentKitTests: XCTestCase { db.reset() } - func testPlanel2FilterPlaceholder4() throws { + func testPlanet2FilterPlaceholder4() throws { let db = DummyDatabaseForTestSQLSerializer() _ = try Planet2 .query(on: db) @@ -426,7 +498,7 @@ final class FluentKitTests: XCTestCase { func testLoggerOverride() throws { let db: Database = DummyDatabaseForTestSQLSerializer() - XCTAssertEqual(db.logger.logLevel, .info) + XCTAssertEqual(db.logger.logLevel, env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .info) var logger = db.logger logger.logLevel = .critical let new = db.logging(to: logger) @@ -437,7 +509,7 @@ final class FluentKitTests: XCTestCase { enum Bar: String, Codable, Equatable { case baz } - final class Foo: Model { + final class EFoo: Model { static let schema = "foos" @ID var id: UUID? @Enum(key: "bar") var bar: Bar @@ -445,13 +517,13 @@ final class FluentKitTests: XCTestCase { } do { - let foo = try JSONDecoder().decode(Foo.self, from: Data(""" + let foo = try JSONDecoder().decode(EFoo.self, from: Data(""" {"bar": "baz"} """.utf8)) XCTAssertEqual(foo.bar, .baz) } do { - _ = try JSONDecoder().decode(Foo.self, from: Data(""" + _ = try JSONDecoder().decode(EFoo.self, from: Data(""" {"bar": "qux"} """.utf8)) XCTFail("should not have passed") @@ -465,7 +537,7 @@ final class FluentKitTests: XCTestCase { enum Bar: String, Codable, Equatable { case baz } - final class Foo: Model { + final class OEFoo: Model { static let schema = "foos" @ID var id: UUID? @OptionalEnum(key: "bar") var bar: Bar? @@ -473,13 +545,13 @@ final class FluentKitTests: XCTestCase { } do { - let foo = try JSONDecoder().decode(Foo.self, from: Data(""" + let foo = try JSONDecoder().decode(OEFoo.self, from: Data(""" {"bar": "baz"} """.utf8)) XCTAssertEqual(foo.bar, .baz) } do { - _ = try JSONDecoder().decode(Foo.self, from: Data(""" + _ = try JSONDecoder().decode(OEFoo.self, from: Data(""" {"bar": "qux"} """.utf8)) XCTFail("should not have passed") @@ -488,9 +560,106 @@ final class FluentKitTests: XCTestCase { XCTAssertEqual(context.codingPath.map(\.stringValue), ["bar"]) } } + + func testOptionalParentCoding() throws { + let db = DummyDatabaseForTestSQLSerializer() + let prefoo = PreFoo(boo: true); try prefoo.create(on: db).wait() + let foo1 = AtFoo(preFoo: prefoo); try foo1.create(on: db).wait() + let foo2 = AtFoo(preFoo: nil); try foo2.create(on: db).wait() + prefoo.$foos.fromId = prefoo.id//; prefoo.$foos.value = [] + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes, .prettyPrinted] + + let prefooEncoded = try String(decoding: encoder.encode(prefoo), as: UTF8.self) + let foo1Encoded = try String(decoding: encoder.encode(foo1), as: UTF8.self) + let foo2Encoded = try String(decoding: encoder.encode(foo2), as: UTF8.self) + + XCTAssertEqual(prefooEncoded, """ + { + "boo" : true, + "id" : \(prefoo.id!) + } + """) + XCTAssertEqual(foo1Encoded, """ + { + "id" : \(foo1.id!), + "preFoo" : { + "boo" : true, + "id" : \(prefoo.id!) + } + } + """) + XCTAssertEqual(foo2Encoded, """ + { + "id" : \(foo2.id!), + "preFoo" : { + "id" : null + } + } + """) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let decodedPrefoo = try decoder.decode(PreFoo.self, from: prefooEncoded.data(using: .utf8)!) + let decodedFoo1 = try decoder.decode(AtFoo.self, from: foo1Encoded.data(using: .utf8)!) + let decodedFoo2 = try decoder.decode(AtFoo.self, from: foo2Encoded.data(using: .utf8)!) + + XCTAssertEqual(decodedPrefoo.id, prefoo.id) + XCTAssertEqual(decodedPrefoo.boo, prefoo.boo) + XCTAssertEqual(decodedFoo1.id, foo1.id) + XCTAssertEqual(decodedFoo1.$preFoo.id, foo1.$preFoo.id) + XCTAssert({ guard case .none = decodedFoo1.$preFoo.value else { return false }; return true }()) + XCTAssertEqual(decodedFoo2.id, foo2.id) + XCTAssertEqual(decodedFoo2.$preFoo.id, foo2.$preFoo.id) + XCTAssert({ guard case .none = decodedFoo2.$preFoo.value else { return false }; return true }()) + } + + func testGroupCoding() throws { + final class GroupedFoo: Fields { + @Field(key: "hello") + var string: String + + init() {} + } + + final class GroupFoo: Model { + static let schema = "group_foos" + + @ID(key: .id) var id: UUID? + @Group(key: "group") var group: GroupedFoo + + init() {} + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let decoder = JSONDecoder() + + let groupFoo = GroupFoo() + groupFoo.id = UUID() + groupFoo.group.string = "hi" + let encoded = try encoder.encode(groupFoo) + XCTAssertEqual(String(decoding: encoded, as: UTF8.self), #"{"group":{"string":"hi"},"id":"\#(groupFoo.id!.uuidString)"}"#) + + let missingGroupFoo = GroupFoo() + missingGroupFoo.id = UUID() + missingGroupFoo.$group.value = nil + let missingEncoded = try encoder.encode(missingGroupFoo) + XCTAssertEqual(String(decoding: missingEncoded, as: UTF8.self), #"{"id":"\#(missingGroupFoo.id!.uuidString)"}"#) + + let decoded = try decoder.decode(GroupFoo.self, from: encoded) + XCTAssertEqual(decoded.id?.uuidString, groupFoo.id?.uuidString) + XCTAssertEqual(decoded.group.string, groupFoo.group.string) + + let decodedMissing = try decoder.decode(GroupFoo.self, from: #"{"id":"\#(groupFoo.id!.uuidString)"}"#.data(using: .utf8)!) + XCTAssertEqual(decodedMissing.id?.uuidString, groupFoo.id?.uuidString) + XCTAssertNotNil(decodedMissing.$group.value) + } func testDatabaseGeneratedIDOverride() throws { - final class Foo: Model { + final class DGOFoo: Model { static let schema = "foos" @ID(custom: .id) var id: Int? init() { } @@ -499,7 +668,6 @@ final class FluentKitTests: XCTestCase { } } - let test = CallbackTestDatabase { query in switch query.input[0] { case .dictionary(let input): @@ -516,7 +684,7 @@ final class FluentKitTests: XCTestCase { TestOutput(["id": 0]) ] } - let foo = Foo(id: 1) + let foo = DGOFoo(id: 1) try foo.create(on: test.db).wait() XCTAssertEqual(foo.id, 1) } @@ -545,10 +713,86 @@ final class FluentKitTests: XCTestCase { .wait()) } + func testModelsWithSpacesSpecified() throws { + let db = DummyDatabaseForTestSQLSerializer() + try db.schema(AltPlanet.schema, space: AltPlanet.space) + .id() + .field("name", .string, .required) + .field("star_id", .uuid, .references(Star.schema, "id"), .required) + .field("possible_star_id", .uuid, .references(Star.schema, "id")) + .field("createdAt", .datetime) + .field("updatedAt", .datetime) + .field("deletedAt", .datetime, .sql(.default(SQLLiteral.null))) + .create() + .wait() + _ = try AltPlanet.query(on: db).filter(\.$name == "Earth").all().wait() + try AltPlanet(name: "Nemesis").create(on: db).wait() + let updateMe = AltPlanet(id: UUID(), name: "Vulcan") + updateMe.$id.exists = true + try updateMe.update(on: db).wait() + try AltPlanet.query(on: db).filter(\.$name != "Arret").delete(force: true).wait() + _ = try Star.query(on: db).join(AltPlanet.self, on: \AltPlanet.$star.$id == \Star.$id).fields(for: Star.self).withDeleted().first().wait() + + XCTAssertEqual(db.sqlSerializers.count, 6) + XCTAssertEqual(db.sqlSerializers.dropFirst(0).first?.sql, #"CREATE TABLE "mirror_universe"."planets"("id" UUID PRIMARY KEY, "name" TEXT NOT NULL, "star_id" UUID REFERENCES "stars" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION NOT NULL, "possible_star_id" UUID REFERENCES "stars" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, "createdAt" TIMESTAMPTZ, "updatedAt" TIMESTAMPTZ, "deletedAt" TIMESTAMPTZ DEFAULT NULL)"#) + XCTAssertEqual(db.sqlSerializers.dropFirst(1).first?.sql, #"SELECT "mirror_universe"."planets"."id" AS "mirror_universe_planets_id", "mirror_universe"."planets"."name" AS "mirror_universe_planets_name", "mirror_universe"."planets"."star_id" AS "mirror_universe_planets_star_id", "mirror_universe"."planets"."possible_star_id" AS "mirror_universe_planets_possible_star_id", "mirror_universe"."planets"."createdAt" AS "mirror_universe_planets_createdAt", "mirror_universe"."planets"."updatedAt" AS "mirror_universe_planets_updatedAt", "mirror_universe"."planets"."deletedAt" AS "mirror_universe_planets_deletedAt" FROM "mirror_universe"."planets" WHERE "mirror_universe"."planets"."name" = $1 AND ("mirror_universe"."planets"."deletedAt" IS NULL OR "mirror_universe"."planets"."deletedAt" > $2)"#) + XCTAssertEqual(db.sqlSerializers.dropFirst(2).first?.sql, #"INSERT INTO "mirror_universe"."planets" ("id", "name", "star_id", "possible_star_id", "createdAt", "updatedAt", "deletedAt") VALUES ($1, $2, DEFAULT, DEFAULT, $3, $4, DEFAULT)"#) + XCTAssertEqual(db.sqlSerializers.dropFirst(3).first?.sql, #"UPDATE "mirror_universe"."planets" SET "id" = $1, "name" = $2, "updatedAt" = $3 WHERE "mirror_universe"."planets"."id" = $4 AND ("mirror_universe"."planets"."deletedAt" IS NULL OR "mirror_universe"."planets"."deletedAt" > $5)"#) + XCTAssertEqual(db.sqlSerializers.dropFirst(4).first?.sql, #"DELETE FROM "mirror_universe"."planets" WHERE "mirror_universe"."planets"."name" <> $1"#) + XCTAssertEqual(db.sqlSerializers.dropFirst(5).first?.sql, #"SELECT "stars"."id" AS "stars_id", "stars"."name" AS "stars_name", "stars"."galaxy_id" AS "stars_galaxy_id", "stars"."deleted_at" AS "stars_deleted_at" FROM "stars" INNER JOIN "mirror_universe"."planets" ON "mirror_universe"."planets"."star_id" = "stars"."id" LIMIT 1"#) + } + + func testKeyPrefixingStrategies() throws { + XCTAssertEqual(KeyPrefixingStrategy.none.apply(prefix: "abc", to: "def").description, "abcdef") + XCTAssertEqual(KeyPrefixingStrategy.none.apply(prefix: "abc", to: .prefix("def", "ghi")).description, "abcdefghi") + XCTAssertEqual(KeyPrefixingStrategy.none.apply(prefix: .prefix("abc", "def"), to: "ghi").description, "abcdefghi") + + XCTAssertEqual(KeyPrefixingStrategy.camelCase.apply(prefix: "abc", to: "def").description, "abcDef") + XCTAssertEqual(KeyPrefixingStrategy.camelCase.apply(prefix: "abc", to: .prefix("def", "ghi")).description, "abcDefghi") + XCTAssertEqual(KeyPrefixingStrategy.camelCase.apply(prefix: .prefix("abc", "def"), to: "ghi").description, "abcdefGhi") + XCTAssertEqual(KeyPrefixingStrategy.camelCase.apply(prefix: "ABC", to: "DEF").description, "ABCDEF") + XCTAssertEqual(KeyPrefixingStrategy.camelCase.apply(prefix: "ABC", to: "").description, "ABC") + XCTAssertEqual(KeyPrefixingStrategy.camelCase.apply(prefix: "", to: "ABC").description, "ABC") + XCTAssertEqual(KeyPrefixingStrategy.camelCase.apply(prefix: "abc", to: "_def").description, "abc_def") + XCTAssertEqual(KeyPrefixingStrategy.camelCase.apply(prefix: "abc_", to: "def").description, "abc_Def") + + XCTAssertEqual(KeyPrefixingStrategy.snakeCase.apply(prefix: "abc", to: "def").description, "abc_def") + XCTAssertEqual(KeyPrefixingStrategy.snakeCase.apply(prefix: "abc", to: .prefix("def", "ghi")).description, "abc_defghi") + XCTAssertEqual(KeyPrefixingStrategy.snakeCase.apply(prefix: .prefix("abc", "def"), to: "ghi").description, "abcdef_ghi") + XCTAssertEqual(KeyPrefixingStrategy.snakeCase.apply(prefix: "abc_", to: "def").description, "abc__def") + XCTAssertEqual(KeyPrefixingStrategy.snakeCase.apply(prefix: "abc", to: "_def").description, "abc__def") + + XCTAssertEqual(KeyPrefixingStrategy.custom({ .prefix($0, .prefix("+", $1)) }).apply(prefix: "abc", to: "def").description, "abc+def") + } + + func testCreatingModelArraysWithUnsetOptionalProperties() throws { + final class Foo: Model { + static let schema = "foos" + + @ID var id: UUID? + @OptionalField(key: "opt") var opt: String? + + init() {} + init(id: UUID? = nil, opt: String? = nil) { (self.id, self.opt) = (id, opt) } + } + + let foos = [ + Foo(), + Foo(opt: nil), + Foo(opt: "foo"), + ] + let db = DummyDatabaseForTestSQLSerializer() + + try foos.create(on: db).wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"INSERT INTO "foos" ("id", "opt") VALUES ($1, DEFAULT), ($2, NULL), ($3, $4)"#) + } + func testFieldsPropertiesPerformance() throws { measure { - for _ in 1 ... 10_000 { - XCTAssertEqual(LotsOfFields().properties.count, 21) + let model = LotsOfFields() + for _ in 1 ... 5_000 { + XCTAssertEqual(model.properties.count, 21) } } } @@ -612,18 +856,18 @@ final class Toy: Fields { var type: ToyType @Group(key: "foo") - var foo: Foo + var foo: ToyFoo init() { } - init(name: String, type: ToyType, foo: Foo) { + init(name: String, type: ToyType, foo: ToyFoo) { self.name = name self.type = type self.foo = foo } } -final class Foo: Fields { +final class ToyFoo: Fields { @Field(key: "bar") var bar: Int @@ -662,6 +906,45 @@ final class Planet2: Model { } } +final class AltPlanet: Model { + public static let space: String? = "mirror_universe" + public static let schema = "planets" + + @ID(key: .id) + public var id: UUID? + + @Field(key: "name") + public var name: String + + @Parent(key: "star_id") + public var star: Star + + @OptionalParent(key: "possible_star_id") + public var possibleStar: Star? + + @Timestamp(key: "createdAt", on: .create) + public var createdAt: Date? + + @Timestamp(key: "updatedAt", on: .update) + public var updatedAt: Date? + + @Timestamp(key: "deletedAt", on: .delete) + public var deletedAt: Date? + + public init() {} + + public init(id: IDValue? = nil, name: String) { + self.id = id + self.name = name + } + + public init(id: IDValue? = nil, name: String, starId: UUID) { + self.id = id + self.name = name + self.$star.id = starId + } +} + final class LotsOfFields: Model { static let schema = "never_used" @@ -728,3 +1011,56 @@ final class LotsOfFields: Model { @Field(key: "field20") var field20: String } + +final class AtFoo: Model { + static let schema = "foos" + + @ID(custom: .id) var id: Int? + @OptionalParent(key: "pre_foo_id") var preFoo: PreFoo? + + init() {} + init(id: Int? = nil, preFoo: PreFoo?) { self.id = id; self.$preFoo.id = preFoo?.id; self.$preFoo.value = preFoo } +} + +final class PostFoo: Model { + static let schema = "postfoos" + + @ID(custom: .id) var id: Int? + + init() {} + init(id: Int? = nil) { self.id = id } +} + +final class PreFoo: Model { + static let schema = "prefoos" + + @ID(custom: .id) var id: Int? + @Field(key: "boo") var boo: Bool + + @Children(for: \AtFoo.$preFoo) var foos: [AtFoo] + @OptionalChild(for: \AtFoo.$preFoo) var afoo: AtFoo? + @Siblings(through: MidFoo.self, from: \.$id.$prefoo, to: \.$id.$postfoo) var postfoos: [PostFoo] + + init() {} + init(id: Int? = nil, boo: Bool) { self.id = id; self.boo = boo } +} + +final class MidFoo: Model { + static let schema = "midfoos" + + final class IDValue: Fields, Hashable { + @Parent(key: "prefoo_id") var prefoo: PreFoo + @Parent(key: "postfoo_id") var postfoo: PostFoo + + init() {} + init(prefooId: PreFoo.IDValue, postfooId: PostFoo.IDValue) { (self.$prefoo.id, self.$postfoo.id) = (prefooId, postfooId) } + + static func == (lhs: IDValue, rhs: IDValue) -> Bool { lhs.$prefoo.id == rhs.$prefoo.id && lhs.$postfoo.id == rhs.$postfoo.id } + func hash(into hasher: inout Hasher) { hasher.combine(self.$prefoo.id); hasher.combine(self.$postfoo.id) } + } + + @CompositeID var id: IDValue? + + init() {} + init(prefooId: PreFoo.IDValue, postfooId: PostFoo.IDValue) { self.id = .init(prefooId: prefooId, postfooId: postfooId) } +} diff --git a/Tests/FluentKitTests/OptionalEnumQueryTests.swift b/Tests/FluentKitTests/OptionalEnumQueryTests.swift index 6dd56de1..38396d9a 100644 --- a/Tests/FluentKitTests/OptionalEnumQueryTests.swift +++ b/Tests/FluentKitTests/OptionalEnumQueryTests.swift @@ -1,31 +1,41 @@ -@testable import FluentKit +import FluentKit import FluentSQL import Foundation import XCTest final class OptionalEnumQueryTests: DbQueryTestCase { + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + func testInsertNonNull() throws { _ = try Thing(id: 1, fb: .fizz).create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("fb", "id") VALUES ('fizz', $1)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, 'fizz')"#) } func testInsertNull() throws { _ = try Thing(id: 1, fb: nil).create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("id") VALUES ($1)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, NULL)"#) + } + + func testBulkUpdateDoesntOverkill() throws { + let thing = Thing(id: 1, fb: .buzz) + try thing.create(on: db).wait() + try Thing.query(on: db).filter(\.$id != thing.id!).set(\.$id, to: 99).update().wait() + assertLastQuery(db, #"UPDATE "things" SET "id" = $1 WHERE "things"."id" <> $2"#) } func testInsertAfterMutatingNullableField() throws { let thing = Thing(id: 1, fb: nil) thing.fb = .fizz _ = try thing.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("fb", "id") VALUES ('fizz', $1)"#) - - db.reset() + assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, 'fizz')"#) let thing2 = Thing(id: 1, fb: .buzz) thing2.fb = nil _ = try thing2.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("id") VALUES ($1)"#) + assertLastQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, NULL)"#) } func testSaveReplacingNonNull() throws { @@ -45,7 +55,7 @@ final class OptionalEnumQueryTests: DbQueryTestCase { } // @see https://github.com/vapor/fluent-kit/issues/444 - func SKIP_EXPECTED_FAILURE_testSaveNullReplacingNonNull() throws { + func testSaveNullReplacingNonNull() throws { let thing = Thing(id: 1, fb: .fizz) _ = try thing.create(on: db).wait() thing.fb = nil @@ -57,20 +67,24 @@ final class OptionalEnumQueryTests: DbQueryTestCase { func testBulkInsertWithoutNulls() throws { let things = [Thing(id: 1, fb: .fizz), Thing(id: 2, fb: .buzz)] _ = try things.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("fb", "id") VALUES ('fizz', $1), ('buzz', $2)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, 'fizz'), ($2, 'buzz')"#) } func testBulkInsertWithOnlyNulls() throws { let things = [Thing(id: 1, fb: nil), Thing(id: 2, fb: nil)] _ = try things.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("id") VALUES ($1), ($2)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, NULL), ($2, NULL)"#) } // @see https://github.com/vapor/fluent-kit/issues/396 - func SKIP_EXPECTED_FAILURE_testBulkInsertWithMixedNulls() throws { + func testBulkInsertWithMixedNulls() throws { let things = [Thing(id: 1, fb: nil), Thing(id: 2, fb: .fizz)] _ = try things.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("fb", "id") VALUES (NULL, $1), ('fizz', $2)"#) + assertLastQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, NULL), ($2, 'fizz')"#) + + let things2 = [Thing(id: 3, fb: .fizz), Thing(id: 4, fb: nil)] + _ = try things2.create(on: db).wait() + assertLastQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, 'fizz'), ($2, NULL)"#) } } diff --git a/Tests/FluentKitTests/OptionalFieldQueryTests.swift b/Tests/FluentKitTests/OptionalFieldQueryTests.swift index dfab13c8..cd8c399c 100644 --- a/Tests/FluentKitTests/OptionalFieldQueryTests.swift +++ b/Tests/FluentKitTests/OptionalFieldQueryTests.swift @@ -1,32 +1,36 @@ -@testable import FluentKit +import FluentKit import FluentSQL import Foundation import XCTest final class OptionalFieldQueryTests: DbQueryTestCase { - + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + func testInsertNonNull() throws { _ = try Thing(id: 1, name: "Jared").create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("name", "id") VALUES ($1, $2)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2)"#) } func testInsertNull() throws { _ = try Thing(id: 1, name: nil).create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("name", "id") VALUES (NULL, $1)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, NULL)"#) } func testInsertAfterMutatingNullableField() throws { let thing = Thing(id: 1, name: nil) thing.name = "Jared" _ = try thing.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("name", "id") VALUES ($1, $2)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2)"#) db.reset() let thing2 = Thing(id: 1, name: "Jared") thing2.name = nil _ = try thing2.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("name", "id") VALUES (NULL, $1)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, NULL)"#) } func testSaveReplacingNonNull() throws { @@ -56,19 +60,19 @@ final class OptionalFieldQueryTests: DbQueryTestCase { func testBulkInsertWithoutNulls() throws { let things = [Thing(id: 1, name: "Jared"), Thing(id: 2, name: "Bob")] _ = try things.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("name", "id") VALUES ($1, $2), ($3, $4)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2), ($3, $4)"#) } func testBulkInsertWithOnlyNulls() throws { let things = [Thing(id: 1, name: nil), Thing(id: 2, name: nil)] _ = try things.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("name", "id") VALUES (NULL, $1), (NULL, $2)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, NULL), ($2, NULL)"#) } func testBulkInsertWithMixedNulls() throws { let things = [Thing(id: 1, name: "Jared"), Thing(id: 2, name: nil)] _ = try things.create(on: db).wait() - assertQuery(db, #"INSERT INTO "things" ("name", "id") VALUES ($1, $2), (NULL, $3)"#) + assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2), ($3, NULL)"#) } } diff --git a/Tests/FluentKitTests/QueryBuilderTests.swift b/Tests/FluentKitTests/QueryBuilderTests.swift index c3964ec3..238a8cf1 100644 --- a/Tests/FluentKitTests/QueryBuilderTests.swift +++ b/Tests/FluentKitTests/QueryBuilderTests.swift @@ -1,10 +1,15 @@ -@testable import FluentKit -@testable import FluentBenchmark +import FluentKit +import FluentBenchmark import XCTest import Foundation import XCTFluent final class QueryBuilderTests: XCTestCase { + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + func testFirstEmptyResult() throws { let test = ArrayTestDatabase() test.append([]) @@ -155,6 +160,10 @@ final class QueryBuilderTests: XCTestCase { case .path(let path, let schema): XCTAssertEqual(path, ["name"]) XCTAssertEqual(schema, "stars") + case .extendedPath(let path, let schema, let space): + XCTAssertEqual(path, ["name"]) + XCTAssertEqual(schema, "stars") + XCTAssertNil(space) default: XCTFail("\(field)") } @@ -182,8 +191,16 @@ final class QueryBuilderTests: XCTestCase { on: .custom(#"LEFT JOIN "stars" ON "stars"."id" = "planets"."id" AND "stars"."name" = 'Sun'"#)) .all().wait() XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "planets"."id" AS "planets_id", "planets"."name" AS "planets_name", "planets"."star_id" AS "planets_star_id", "planets"."possible_star_id" AS "planets_possible_star_id", "stars"."id" AS "stars_id", "stars"."name" AS "stars_name", "stars"."galaxy_id" AS "stars_galaxy_id" FROM "planets" LEFT JOIN "stars" ON "stars"."id" = "planets"."id" AND "stars"."name" = 'Sun'"#) - print(db.sqlSerializers.first!.sql) - db.reset() + XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "planets"."id" AS "planets_id", "planets"."name" AS "planets_name", "planets"."star_id" AS "planets_star_id", "planets"."possible_star_id" AS "planets_possible_star_id", "planets"."deleted_at" AS "planets_deleted_at", "stars"."id" AS "stars_id", "stars"."name" AS "stars_name", "stars"."galaxy_id" AS "stars_galaxy_id", "stars"."deleted_at" AS "stars_deleted_at" FROM "planets" LEFT JOIN "stars" ON "stars"."id" = "planets"."id" AND "stars"."name" = 'Sun' WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1) AND ("stars"."deleted_at" IS NULL OR "stars"."deleted_at" > $2)"#) + } + + func testComplexJoinOperators() throws { + let db = DummyDatabaseForTestSQLSerializer() + + _ = try Planet.query(on: db) + .join(Star.self, on: \Star.$id == \Planet.$star.$id && \Star.$name != \Planet.$name) + .all().wait() + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(try db.sqlSerializers.xctAt(0).sql, #"SELECT "planets"."id" AS "planets_id", "planets"."name" AS "planets_name", "planets"."star_id" AS "planets_star_id", "planets"."possible_star_id" AS "planets_possible_star_id", "planets"."deleted_at" AS "planets_deleted_at", "stars"."id" AS "stars_id", "stars"."name" AS "stars_name", "stars"."galaxy_id" AS "stars_galaxy_id", "stars"."deleted_at" AS "stars_deleted_at" FROM "planets" INNER JOIN "stars" ON "stars"."id" = "planets"."star_id" AND "stars"."name" <> "planets"."name" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1) AND ("stars"."deleted_at" IS NULL OR "stars"."deleted_at" > $2)"#) } } diff --git a/Tests/FluentKitTests/TestUtilities.swift b/Tests/FluentKitTests/TestUtilities.swift index 57ac90c5..0403891b 100644 --- a/Tests/FluentKitTests/TestUtilities.swift +++ b/Tests/FluentKitTests/TestUtilities.swift @@ -1,4 +1,5 @@ import XCTest +import Logging class DbQueryTestCase: XCTestCase { var db = DummyDatabaseForTestSQLSerializer() @@ -30,3 +31,16 @@ func assertLastQuery( ) { XCTAssertEqual(db.sqlSerializers.last?.sql, query, file: file, line: line) } + +func env(_ name: String) -> String? { + return ProcessInfo.processInfo.environment[name] +} + +let isLoggingConfigured: Bool = { + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .info + return handler + } + return true +}()