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 @@
-
-
+
+
+
+
+
-
+
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 ~~