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 deleted file mode 100644 index bbbd2921..00000000 --- a/.api-breakage/allowlist-branch-make-pivot-rhs-available-during-array-attach.txt +++ /dev/null @@ -1 +0,0 @@ -API breakage: constructor PlanetTag.init(id:planetID:tagID:) has been removed diff --git a/.github/.codecov.yml b/.github/.codecov.yml new file mode 100644 index 00000000..54005811 --- /dev/null +++ b/.github/.codecov.yml @@ -0,0 +1,38 @@ +codecov: + notify: + after_n_builds: 1 + wait_for_ci: false + require_ci_to_pass: false +comment: + behavior: default + layout: diff, files + require_changes: true +coverage: + status: + patch: + default: + branches: + - ^main$ + informational: true + only_pulls: false + paths: + - ^Sources.* + target: auto + project: + default: + branches: + - ^main$ + informational: true + only_pulls: false + paths: + - ^Sources.* + target: auto +github_checks: + annotations: true +ignore: +- ^Sources/XCTFluent/.* +- ^Sources/FluentBenchmarks/.* +- ^Tests/.* +- ^.build/.* +slack_app: false + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95ed7db5..5f366ea1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,60 +39,78 @@ env: jobs: - linux-integration: + linux-integration-sqlite: + if: ${{ !(github.event.pull_request.draft || false) }} + runs-on: ubuntu-latest + container: swift:5.10-jammy + steps: + - name: Check out package + uses: actions/checkout@v4 + with: { path: fluent-kit } + - name: Check out dependent + uses: actions/checkout@v4 + with: { repository: vapor/fluent-sqlite-driver, path: fluent-sqlite-driver } + - name: Use local package and run tests + run: | + swift package --package-path fluent-sqlite-driver edit fluent-kit --path fluent-kit + swift test --package-path fluent-sqlite-driver --sanitize=thread + + linux-integration-mysql: + if: ${{ !(github.event.pull_request.draft || false) }} + runs-on: ubuntu-latest + container: swift:5.10-jammy + services: + mysql-a: { image: 'mysql:8', env: { MYSQL_ALLOW_EMPTY_PASSWORD: true, MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database } } + mysql-b: { image: 'mysql:8', env: { MYSQL_ALLOW_EMPTY_PASSWORD: true, MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database } } + steps: + - name: Check out package + uses: actions/checkout@v4 + with: { path: fluent-kit } + - name: Check out dependent + uses: actions/checkout@v4 + with: { repository: vapor/fluent-mysql-driver, path: fluent-mysql-driver } + - name: Use local package and run tests + run: | + swift package --package-path fluent-mysql-driver edit fluent-kit --path fluent-kit + swift test --package-path fluent-mysql-driver --sanitize=thread + + linux-integration-psql: + if: ${{ !(github.event.pull_request.draft || false) }} + runs-on: ubuntu-latest + container: swift:5.10-jammy + services: + psql-a: { image: 'postgres:16', 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:16', 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 } } + steps: + - name: Check out package + uses: actions/checkout@v4 + with: { path: fluent-kit } + - name: Check out dependent + uses: actions/checkout@v4 + with: { repository: vapor/fluent-postgres-driver, path: fluent-postgres-driver } + - name: Use local package and run tests + run: | + swift package --package-path fluent-postgres-driver edit fluent-kit --path fluent-kit + swift test --package-path fluent-postgres-driver --sanitize=thread + + linux-integration-mongo: if: ${{ !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest container: swift:5.10-jammy services: - mysql-a: - image: mysql:8 - env: { MYSQL_ALLOW_EMPTY_PASSWORD: true, MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database } - mysql-b: - image: mysql:8 - env: { MYSQL_ALLOW_EMPTY_PASSWORD: true, MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database } - psql-a: - image: postgres:16 - 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:16 - 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:6 - mongo-b: - image: mongo:6 - strategy: - fail-fast: false - matrix: - 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' } + mongo-a: { image: 'mongo:6' } + mongo-b: { image: 'mongo:6' } steps: - name: Check out package uses: actions/checkout@v4 - with: - path: fluent-kit + with: { path: fluent-kit } - name: Check out dependent uses: actions/checkout@v4 - with: - repository: vapor/${{ matrix.dependent }} - path: ${{ matrix.dependent }} - ref: ${{ matrix.ref }} + with: { repository: vapor/fluent-mongo-driver, path: fluent-mongo-driver } - name: Use local package and run tests - env: - DEPENDENT: ${{ matrix.dependent }} run: | - swift package --package-path ${DEPENDENT} edit fluent-kit --path fluent-kit - swift test --package-path ${DEPENDENT} + swift package --package-path fluent-mongo-driver edit fluent-kit --path fluent-kit + swift test --package-path fluent-mongo-driver --sanitize=thread unit-tests: uses: vapor/ci/.github/workflows/run-unit-tests.yml@main - with: - coverage_ignores: '/Tests/|/Sources/FluentBenchmark/' diff --git a/Package.swift b/Package.swift index 3501cfa2..5a21b435 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.8 import PackageDescription let package = Package( @@ -22,29 +22,60 @@ let package = Package( .package(url: "https://github.com/vapor/async-kit.git", from: "1.17.0"), ], targets: [ - .target(name: "FluentKit", dependencies: [ - .product(name: "NIO", package: "swift-nio"), - .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"), - .target(name: "FluentSQL"), - ]), - .target(name: "FluentSQL", dependencies: [ - .target(name: "FluentKit"), - .product(name: "SQLKit", package: "sql-kit"), - ]), - .target(name: "XCTFluent", dependencies: [ - .target(name: "FluentKit"), - .product(name: "NIOEmbedded", package: "swift-nio"), - ]), - .testTarget(name: "FluentKitTests", dependencies: [ - .target(name: "FluentBenchmark"), - .target(name: "FluentSQL"), - .target(name: "XCTFluent"), - ]), + .target( + name: "FluentKit", + dependencies: [ + .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "Logging", package: "swift-log"), + .product(name: "AsyncKit", package: "async-kit"), + .product(name: "SQLKit", package: "sql-kit"), + ], + swiftSettings: swiftSettings + ), + .target( + name: "FluentBenchmark", + dependencies: [ + .target(name: "FluentKit"), + .target(name: "FluentSQL"), + ], + swiftSettings: swiftSettings + ), + .target( + name: "FluentSQL", + dependencies: [ + .product(name: "SQLKit", package: "sql-kit"), + .target(name: "FluentKit"), + ], + swiftSettings: swiftSettings + ), + .target( + name: "XCTFluent", + dependencies: [ + .product(name: "NIOEmbedded", package: "swift-nio"), + .target(name: "FluentKit"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "FluentKitTests", + dependencies: [ + .target(name: "FluentBenchmark"), + .target(name: "FluentSQL"), + .target(name: "XCTFluent"), + ], + swiftSettings: swiftSettings + ), ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("ImportObjcForwardDeclarations"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableUpcomingFeature("IsolatedDefaultValues"), + .enableUpcomingFeature("GlobalConcurrency"), + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("StrictConcurrency=complete"), +] } diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index 24bf34f1..9cf39845 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -44,16 +44,16 @@ let package = Package( .target( name: "FluentSQL", dependencies: [ - .target(name: "FluentKit"), .product(name: "SQLKit", package: "sql-kit"), + .target(name: "FluentKit"), ], swiftSettings: swiftSettings ), .target( name: "XCTFluent", dependencies: [ - .target(name: "FluentKit"), .product(name: "NIOEmbedded", package: "swift-nio"), + .target(name: "FluentKit"), ], swiftSettings: swiftSettings ), @@ -71,4 +71,12 @@ let package = Package( var swiftSettings: [SwiftSetting] { [ .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("ImportObjcForwardDeclarations"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableUpcomingFeature("IsolatedDefaultValues"), + .enableUpcomingFeature("GlobalConcurrency"), + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("StrictConcurrency=complete"), ] } diff --git a/README.md b/README.md index cc263ffd..e6abce77 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,31 @@

- FluentKit -
-
- - Documentation - - - Team Chat - - - MIT License - - - Continuous Integration - - - Test Coverage - - - Swift 5.6 - + + + + FluentKit + +
+
+Documentation +Team Chat +MIT License +Continuous Integration + +Swift 5.8+

+ +
+ +An Object-Relational Mapper (ORM) for Swift. It allows you to write type safe, database agnostic models and queries. It takes advantage of Swift's type system to provide a powerful, yet easy to use API. + +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 [Fluent documentation](https://docs.vapor.codes/fluent/overview/). diff --git a/Sources/FluentBenchmark/SolarSystem/GalacticJurisdiction.swift b/Sources/FluentBenchmark/SolarSystem/GalacticJurisdiction.swift index d172cf8b..94421edb 100644 --- a/Sources/FluentBenchmark/SolarSystem/GalacticJurisdiction.swift +++ b/Sources/FluentBenchmark/SolarSystem/GalacticJurisdiction.swift @@ -2,10 +2,10 @@ import FluentKit import Foundation import NIOCore -public final class GalacticJurisdiction: Model { +public final class GalacticJurisdiction: Model, @unchecked Sendable { public static let schema = "galaxy_jurisdictions" - public final class IDValue: Fields, Hashable { + public final class IDValue: Fields, Hashable, @unchecked Sendable { @Parent(key: "galaxy_id") public var galaxy: Galaxy diff --git a/Sources/FluentBenchmark/SolarSystem/Galaxy.swift b/Sources/FluentBenchmark/SolarSystem/Galaxy.swift index 93411087..1b937ed9 100644 --- a/Sources/FluentBenchmark/SolarSystem/Galaxy.swift +++ b/Sources/FluentBenchmark/SolarSystem/Galaxy.swift @@ -3,7 +3,7 @@ import Foundation import NIOCore import XCTest -public final class Galaxy: Model { +public final class Galaxy: Model, @unchecked Sendable { public static let schema = "galaxies" @ID(key: .id) diff --git a/Sources/FluentBenchmark/SolarSystem/Governor.swift b/Sources/FluentBenchmark/SolarSystem/Governor.swift index a2b94c11..59a243d2 100644 --- a/Sources/FluentBenchmark/SolarSystem/Governor.swift +++ b/Sources/FluentBenchmark/SolarSystem/Governor.swift @@ -3,7 +3,7 @@ import Foundation import NIOCore import XCTest -public final class Governor: Model { +public final class Governor: Model, @unchecked Sendable { public static let schema = "governors" @ID(key: .id) diff --git a/Sources/FluentBenchmark/SolarSystem/Jurisdiction.swift b/Sources/FluentBenchmark/SolarSystem/Jurisdiction.swift index c3cb325f..a48c0f4a 100644 --- a/Sources/FluentBenchmark/SolarSystem/Jurisdiction.swift +++ b/Sources/FluentBenchmark/SolarSystem/Jurisdiction.swift @@ -3,7 +3,7 @@ import Foundation import NIOCore import XCTest -public final class Jurisdiction: Model { +public final class Jurisdiction: Model, @unchecked Sendable { public static let schema = "jurisdictions" @ID(key: .id) diff --git a/Sources/FluentBenchmark/SolarSystem/Moon.swift b/Sources/FluentBenchmark/SolarSystem/Moon.swift index 7a5e0a91..3900518b 100644 --- a/Sources/FluentBenchmark/SolarSystem/Moon.swift +++ b/Sources/FluentBenchmark/SolarSystem/Moon.swift @@ -2,7 +2,7 @@ import FluentKit import Foundation import NIOCore -public final class Moon: Model { +public final class Moon: Model, @unchecked Sendable { public static let schema = "moons" @ID(key: .id) diff --git a/Sources/FluentBenchmark/SolarSystem/Planet.swift b/Sources/FluentBenchmark/SolarSystem/Planet.swift index 7e5da08d..01c97467 100644 --- a/Sources/FluentBenchmark/SolarSystem/Planet.swift +++ b/Sources/FluentBenchmark/SolarSystem/Planet.swift @@ -3,7 +3,7 @@ import Foundation import NIOCore import XCTest -public final class Planet: Model { +public final class Planet: Model, @unchecked Sendable { public static let schema = "planets" @ID(key: .id) diff --git a/Sources/FluentBenchmark/SolarSystem/PlanetTag.swift b/Sources/FluentBenchmark/SolarSystem/PlanetTag.swift index 8b38ec43..8dc2e74d 100644 --- a/Sources/FluentBenchmark/SolarSystem/PlanetTag.swift +++ b/Sources/FluentBenchmark/SolarSystem/PlanetTag.swift @@ -3,7 +3,7 @@ import Foundation import NIOCore import XCTest -public final class PlanetTag: Model { +public final class PlanetTag: Model, @unchecked Sendable { public static let schema = "planet+tag" @ID(key: .id) diff --git a/Sources/FluentBenchmark/SolarSystem/Star.swift b/Sources/FluentBenchmark/SolarSystem/Star.swift index 45ab4097..9ef087e6 100644 --- a/Sources/FluentBenchmark/SolarSystem/Star.swift +++ b/Sources/FluentBenchmark/SolarSystem/Star.swift @@ -3,7 +3,7 @@ import Foundation import NIOCore import XCTest -public final class Star: Model { +public final class Star: Model, @unchecked Sendable { public static let schema = "stars" @ID(key: .id) diff --git a/Sources/FluentBenchmark/SolarSystem/Tag.swift b/Sources/FluentBenchmark/SolarSystem/Tag.swift index ff2feae7..e99342a0 100644 --- a/Sources/FluentBenchmark/SolarSystem/Tag.swift +++ b/Sources/FluentBenchmark/SolarSystem/Tag.swift @@ -3,7 +3,7 @@ import Foundation import NIOCore import XCTest -public final class Tag: Model { +public final class Tag: Model, @unchecked Sendable { public static let schema = "tags" @ID(key: .id) diff --git a/Sources/FluentBenchmark/Tests/ArrayTests.swift b/Sources/FluentBenchmark/Tests/ArrayTests.swift index 5de5b35a..8ca7b7d4 100644 --- a/Sources/FluentBenchmark/Tests/ArrayTests.swift +++ b/Sources/FluentBenchmark/Tests/ArrayTests.swift @@ -68,7 +68,7 @@ private struct Qux: Codable { var foo: String } -private final class Foo: Model { +private final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) @@ -114,7 +114,7 @@ private enum Role: String, Codable, Equatable { case client } -private final class User: Model { +private final class User: Model, @unchecked Sendable { static let schema = "users" @ID(key: .id) @@ -144,7 +144,7 @@ private struct UserMigration: Migration { } } -private final class FooSet: Model { +private final class FooSet: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) diff --git a/Sources/FluentBenchmark/Tests/ChildTests.swift b/Sources/FluentBenchmark/Tests/ChildTests.swift index f4a7d7b0..166e18f7 100644 --- a/Sources/FluentBenchmark/Tests/ChildTests.swift +++ b/Sources/FluentBenchmark/Tests/ChildTests.swift @@ -107,7 +107,7 @@ extension FluentBenchmarker { } -private final class Foo: Model { +private final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) @@ -143,7 +143,7 @@ private struct FooMigration: Migration { } } -private final class Bar: Model { +private final class Bar: Model, @unchecked Sendable { static let schema = "bars" @ID(key: .id) @@ -179,7 +179,7 @@ private struct BarMigration: Migration { } } -private final class Baz: Model { +private final class Baz: Model, @unchecked Sendable { static let schema = "bazs" @ID(key: .id) @@ -214,7 +214,7 @@ private struct BazMigration: Migration { } } -private final class Game: Model { +private final class Game: Model, @unchecked Sendable { static let schema = "games" @ID(custom: .id, generatedBy: .database) @@ -251,7 +251,7 @@ private struct GameMigration: Migration { } } -private final class Player: Model { +private final class Player: Model, @unchecked Sendable { static let schema = "players" @ID(custom: .id, generatedBy: .database) diff --git a/Sources/FluentBenchmark/Tests/ChildrenTests.swift b/Sources/FluentBenchmark/Tests/ChildrenTests.swift index ce569ca4..62b3d0f0 100644 --- a/Sources/FluentBenchmark/Tests/ChildrenTests.swift +++ b/Sources/FluentBenchmark/Tests/ChildrenTests.swift @@ -35,7 +35,7 @@ extension FluentBenchmarker { } -private final class Foo: Model { +private final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) @@ -71,7 +71,7 @@ private struct FooMigration: Migration { } } -private final class Bar: Model { +private final class Bar: Model, @unchecked Sendable { static let schema = "bars" @ID(key: .id) @@ -106,7 +106,7 @@ private struct BarMigration: Migration { } } -private final class Baz: Model { +private final class Baz: Model, @unchecked Sendable { static let schema = "bazs" @ID(key: .id) diff --git a/Sources/FluentBenchmark/Tests/ChunkTests.swift b/Sources/FluentBenchmark/Tests/ChunkTests.swift index 9877834b..cd7f136a 100644 --- a/Sources/FluentBenchmark/Tests/ChunkTests.swift +++ b/Sources/FluentBenchmark/Tests/ChunkTests.swift @@ -1,4 +1,5 @@ import NIOCore +import NIOConcurrencyHelpers import XCTest extension FluentBenchmarker { @@ -10,8 +11,6 @@ extension FluentBenchmarker { try runTest(#function, [ GalaxyMigration(), ]) { - var fetched64: [Result] = [] - var fetched2047: [Result] = [] let saves = (1...512).map { i -> EventLoopFuture in return Galaxy(name: "Milky Way \(i)") @@ -19,29 +18,33 @@ extension FluentBenchmarker { } try EventLoopFuture.andAllSucceed(saves, on: self.database.eventLoop).wait() + let fetched64 = NIOLockedValueBox(0) + try Galaxy.query(on: self.database).chunk(max: 64) { chunk in guard chunk.count == 64 else { XCTFail("bad chunk count") return } - fetched64 += chunk + fetched64.withLockedValue { $0 += chunk.count } }.wait() - guard fetched64.count == 512 else { - XCTFail("did not fetch all - only \(fetched64.count) out of 512") + guard fetched64.withLockedValue({ $0 }) == 512 else { + XCTFail("did not fetch all - only \(fetched64.withLockedValue { $0 }) out of 512") return } + let fetched511 = NIOLockedValueBox(0) + try Galaxy.query(on: self.database).chunk(max: 511) { chunk in guard chunk.count == 511 || chunk.count == 1 else { XCTFail("bad chunk count") return } - fetched2047 += chunk + fetched511.withLockedValue { $0 += chunk.count } }.wait() - guard fetched2047.count == 512 else { - XCTFail("did not fetch all - only \(fetched2047.count) out of 512") + guard fetched511.withLockedValue({ $0 }) == 512 else { + XCTFail("did not fetch all - only \(fetched511.withLockedValue { $0 }) out of 512") return } } diff --git a/Sources/FluentBenchmark/Tests/CodableTests.swift b/Sources/FluentBenchmark/Tests/CodableTests.swift index 68c2429c..90b0d71c 100644 --- a/Sources/FluentBenchmark/Tests/CodableTests.swift +++ b/Sources/FluentBenchmark/Tests/CodableTests.swift @@ -28,7 +28,7 @@ extension FluentBenchmarker { } } -final class Question: Model { +final class Question: Model, @unchecked Sendable { static let schema = "questions" @ID(custom: "id") @@ -57,7 +57,7 @@ final class Question: Model { } } -final class Project: Model { +final class Project: Model, @unchecked Sendable { static let schema = "projects" @ID(custom: "id") diff --git a/Sources/FluentBenchmark/Tests/CompositeIDTests.swift b/Sources/FluentBenchmark/Tests/CompositeIDTests.swift index 7f178081..b44d4b8d 100644 --- a/Sources/FluentBenchmark/Tests/CompositeIDTests.swift +++ b/Sources/FluentBenchmark/Tests/CompositeIDTests.swift @@ -175,10 +175,10 @@ extension FluentBenchmarker { } } -public final class CompositeIDModel: Model { +public final class CompositeIDModel: Model, @unchecked Sendable { public static let schema = "composite_id_models" - public final class IDValue: Fields, Hashable { + public final class IDValue: Fields, Hashable, @unchecked Sendable { @Field(key: "name") public var name: String diff --git a/Sources/FluentBenchmark/Tests/CompositeRelationTests.swift b/Sources/FluentBenchmark/Tests/CompositeRelationTests.swift index 22b809d6..278d3f1b 100644 --- a/Sources/FluentBenchmark/Tests/CompositeRelationTests.swift +++ b/Sources/FluentBenchmark/Tests/CompositeRelationTests.swift @@ -146,10 +146,10 @@ extension FluentBenchmarker { } } -final class CompositeIDParentModel: Model { +final class CompositeIDParentModel: Model, @unchecked Sendable { static let schema = "composite_id_parent_models" - final class IDValue: Fields, Hashable { + final class IDValue: Fields, Hashable, @unchecked Sendable { @Field(key: "name") var name: String @@ -228,7 +228,7 @@ final class CompositeIDParentModel: Model { } } -final class CompositeIDChildModel: Model { +final class CompositeIDChildModel: Model, @unchecked Sendable { static let schema = "composite_id_child_models" @ID(custom: .id) @@ -339,10 +339,10 @@ extension DatabaseSchema.Constraint { } } -final class CompositeParentTheFirst: Model { +final class CompositeParentTheFirst: Model, @unchecked Sendable { static let schema = "composite_parent_the_first" - final class IDValue: Fields, Hashable { + final class IDValue: Fields, Hashable, @unchecked Sendable { @Parent(key: "parent_id") var parent: Galaxy @@ -389,10 +389,10 @@ final class CompositeParentTheFirst: Model { } } -final class CompositeParentTheSecond: Model { +final class CompositeParentTheSecond: Model, @unchecked Sendable { static let schema = "composite_parent_the_second" - final class IDValue: Fields, Hashable { + final class IDValue: Fields, Hashable, @unchecked Sendable { @CompositeParent(prefix: "ref", strategy: .snakeCase) var parent: CompositeParentTheFirst diff --git a/Sources/FluentBenchmark/Tests/EagerLoadTests.swift b/Sources/FluentBenchmark/Tests/EagerLoadTests.swift index 6ee0779f..318cda72 100644 --- a/Sources/FluentBenchmark/Tests/EagerLoadTests.swift +++ b/Sources/FluentBenchmark/Tests/EagerLoadTests.swift @@ -278,7 +278,7 @@ extension FluentBenchmarker { } } -private final class A: Model { +private final class A: Model, @unchecked Sendable { static let schema = "a" @ID @@ -290,7 +290,7 @@ private final class A: Model { init() { } } -private final class B: Model { +private final class B: Model, @unchecked Sendable { static let schema = "b" @ID @@ -302,7 +302,7 @@ private final class B: Model { init() { } } -private final class C: Model { +private final class C: Model, @unchecked Sendable { static let schema = "c" @ID diff --git a/Sources/FluentBenchmark/Tests/EnumTests.swift b/Sources/FluentBenchmark/Tests/EnumTests.swift index e294ec15..aa648f4f 100644 --- a/Sources/FluentBenchmark/Tests/EnumTests.swift +++ b/Sources/FluentBenchmark/Tests/EnumTests.swift @@ -310,7 +310,7 @@ private enum Bar: String, Codable { case baz, qux, quz, quzz } -private final class Foo: Model { +private final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) @@ -389,7 +389,7 @@ private enum Animal: UInt8, Codable { case dog, cat } -private final class Pet: Model { +private final class Pet: Model, @unchecked Sendable { static let schema = "pets" @ID(key: .id) @@ -420,7 +420,7 @@ private struct PetMigration: Migration { } } -private final class Flags: Model { +private final class Flags: Model, @unchecked Sendable { static let schema = "flags" @ID(key: .id) @@ -457,7 +457,7 @@ private final class Flags: Model { } } -private final class RawFlags: Model { +private final class RawFlags: Model, @unchecked Sendable { static let schema = "flags" @ID(key: .id) var id: UUID? diff --git a/Sources/FluentBenchmark/Tests/FilterTests.swift b/Sources/FluentBenchmark/Tests/FilterTests.swift index b375ed59..94f73006 100644 --- a/Sources/FluentBenchmark/Tests/FilterTests.swift +++ b/Sources/FluentBenchmark/Tests/FilterTests.swift @@ -247,7 +247,7 @@ extension FluentBenchmarker { } } -private final class FooOwner: Model { +private final class FooOwner: Model, @unchecked Sendable { static let schema = "foo_owners" @ID var id: UUID? @Field(key: "name") var name: String @@ -263,7 +263,7 @@ private enum FooEnumType: String, Codable { case baz } -private final class Foo: Model { +private final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID var id: UUID? @OptionalField(key: "bar") var bar: String? diff --git a/Sources/FluentBenchmark/Tests/GroupTests.swift b/Sources/FluentBenchmark/Tests/GroupTests.swift index 219272ef..e29a2418 100644 --- a/Sources/FluentBenchmark/Tests/GroupTests.swift +++ b/Sources/FluentBenchmark/Tests/GroupTests.swift @@ -90,7 +90,7 @@ extension FluentBenchmarker { // MARK: Flat -private final class FlatMoon: Model { +private final class FlatMoon: Model, @unchecked Sendable { static let schema = "moons" @ID(key: .id) @@ -99,7 +99,7 @@ private final class FlatMoon: Model { @Field(key: "name") var name: String - final class Planet: Fields { + final class Planet: Fields, @unchecked Sendable { @Field(key: "name") var name: String @@ -110,11 +110,11 @@ private final class FlatMoon: Model { @Field(key: "type") var type: PlanetType - final class Star: Fields { + final class Star: Fields, @unchecked Sendable { @Field(key: "name") var name: String - final class Galaxy: Fields { + final class Galaxy: Fields, @unchecked Sendable { @Field(key: "name") var name: String diff --git a/Sources/FluentBenchmark/Tests/IDTests.swift b/Sources/FluentBenchmark/Tests/IDTests.swift index 41c41c81..9407507a 100644 --- a/Sources/FluentBenchmark/Tests/IDTests.swift +++ b/Sources/FluentBenchmark/Tests/IDTests.swift @@ -76,7 +76,7 @@ extension FluentBenchmarker { } // Model recommended, default @ID configuration. -private final class Foo: Model { +private final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID @@ -101,7 +101,7 @@ private struct FooMigration: Migration { } // Model with custom id key and type. -private final class StringFoo: Model { +private final class StringFoo: Model, @unchecked Sendable { static let schema = "foos" @ID(custom: .id, generatedBy: .user) @@ -126,7 +126,7 @@ private struct StringFooMigration: Migration { } // Model with auto-incrementing id. -private final class AutoincrementingFoo: Model { +private final class AutoincrementingFoo: Model, @unchecked Sendable { static let schema = "foos" @ID(custom: .id, generatedBy: .database) @@ -151,7 +151,7 @@ private struct AutoincrementingFooMigration: Migration { } // Model with auto-incrementing and custom key. -private final class CustomAutoincrementingFoo: Model { +private final class CustomAutoincrementingFoo: Model, @unchecked Sendable { static let schema = "foos" @ID(custom: "bar", generatedBy: .database) diff --git a/Sources/FluentBenchmark/Tests/JoinTests.swift b/Sources/FluentBenchmark/Tests/JoinTests.swift index fb5a7997..ead55465 100644 --- a/Sources/FluentBenchmark/Tests/JoinTests.swift +++ b/Sources/FluentBenchmark/Tests/JoinTests.swift @@ -156,7 +156,7 @@ extension FluentBenchmarker { } private func testJoin_aliasNesting() throws { - final class ChatParticipant: Model { + final class ChatParticipant: Model, @unchecked Sendable { static let schema = "chat_participants" @ID(key: .id) @@ -166,7 +166,7 @@ extension FluentBenchmarker { var user: User } - final class User: Model { + final class User: Model, @unchecked Sendable { static let schema = "users" @ID(key: .id) @@ -179,7 +179,7 @@ extension FluentBenchmarker { } final class OtherParticipant: ModelAlias { static let name: String = "other_participant" - var model = ChatParticipant() + let model = ChatParticipant() } _ = User.query(on: self.database) @@ -234,7 +234,7 @@ extension FluentBenchmarker { } } -private final class Team: Model { +private final class Team: Model, @unchecked Sendable { static let schema = "teams" @ID(key: .id) @@ -270,7 +270,7 @@ private struct TeamMigration: Migration { } } -private final class Match: Model { +private final class Match: Model, @unchecked Sendable { static let schema = "matches" @ID(key: .id) @@ -316,7 +316,7 @@ private struct TeamMatchSeed: Migration { let b = Team(name: "b") let c = Team(name: "c") return a.create(on: database).and(b.create(on: database)).and(c.create(on: database)).flatMap { _ -> EventLoopFuture in - return .andAllSucceed([ + .andAllSucceed([ Match(name: "a vs. b", homeTeam: a, awayTeam: b).save(on: database), Match(name: "a vs. c", homeTeam: a, awayTeam: c).save(on: database), Match(name: "b vs. c", homeTeam: b, awayTeam: c).save(on: database), @@ -337,7 +337,7 @@ private struct TeamMatchSeed: Migration { } -private final class School: Model { +private final class School: Model, @unchecked Sendable { static let schema = "schools" @ID(key: .id) @@ -423,7 +423,7 @@ private struct SchoolSeed: Migration { } } -private final class City: Model { +private final class City: Model, @unchecked Sendable { static let schema = "cities" @ID(key: .id) @@ -475,6 +475,6 @@ private struct CitySeed: Migration { } func revert(on database: any Database) -> EventLoopFuture { - return database.eventLoop.makeSucceededFuture(()) + database.eventLoop.makeSucceededFuture(()) } } diff --git a/Sources/FluentBenchmark/Tests/MiddlewareTests.swift b/Sources/FluentBenchmark/Tests/MiddlewareTests.swift index b3b886bb..aace231d 100644 --- a/Sources/FluentBenchmark/Tests/MiddlewareTests.swift +++ b/Sources/FluentBenchmark/Tests/MiddlewareTests.swift @@ -140,7 +140,7 @@ private struct TestError: Error { var string: String } -private final class User: Model { +private final class User: Model, @unchecked Sendable { static let schema = "users" @ID(key: .id) diff --git a/Sources/FluentBenchmark/Tests/MigratorTests.swift b/Sources/FluentBenchmark/Tests/MigratorTests.swift index c4e74605..f8dfdfa7 100644 --- a/Sources/FluentBenchmark/Tests/MigratorTests.swift +++ b/Sources/FluentBenchmark/Tests/MigratorTests.swift @@ -17,9 +17,10 @@ extension FluentBenchmarker { let migrations = Migrations() migrations.add(GalaxyMigration()) migrations.add(StarMigration()) - + + let database = self.database let migrator = Migrator( - databaseFactory: { _ in self.database }, + databaseFactory: { _ in database }, migrations: migrations, on: self.database.eventLoop ) @@ -45,9 +46,10 @@ extension FluentBenchmarker { migrations.add(GalaxyMigration()) migrations.add(ErrorMigration()) migrations.add(StarMigration()) - + + let database = self.database let migrator = Migrator( - databaseFactory: { _ in self.database }, + databaseFactory: { _ in database }, migrations: migrations, on: self.database.eventLoop ) diff --git a/Sources/FluentBenchmark/Tests/ModelTests.swift b/Sources/FluentBenchmark/Tests/ModelTests.swift index 83d6a4d5..611099ac 100644 --- a/Sources/FluentBenchmark/Tests/ModelTests.swift +++ b/Sources/FluentBenchmark/Tests/ModelTests.swift @@ -183,12 +183,12 @@ extension FluentBenchmarker { private func testModel_useOfFieldsWithoutGroup() throws { try runTest(#function, []) { - final class Contained: Fields { + final class Contained: Fields, @unchecked Sendable { @Field(key: "something") var something: String @Field(key: "another") var another: Int init() {} } - final class Enclosure: Model { + final class Enclosure: Model, @unchecked Sendable { static let schema = "enclosures" @ID(custom: .id) var id: Int? @Field(key: "primary") var primary: Contained @@ -253,7 +253,7 @@ struct BadFooOutput: DatabaseOutput { } } -private final class Foo: Model { +private final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) @@ -283,7 +283,7 @@ private struct FooMigration: Migration { } } -private final class User: Model { +private final class User: Model, @unchecked Sendable { static let schema = "users" @ID(key: .id) @@ -312,7 +312,7 @@ private struct UserMigration: Migration { } } -private final class Todo: Model { +private final class Todo: Model, @unchecked Sendable { static let schema = "todos" @ID(key: .id) @@ -341,7 +341,7 @@ private struct TodoMigration: Migration { } } -private final class Bar: Model { +private final class Bar: Model, @unchecked Sendable { static let schema = "bars" @ID diff --git a/Sources/FluentBenchmark/Tests/OptionalParentTests.swift b/Sources/FluentBenchmark/Tests/OptionalParentTests.swift index 041a6ad0..1f844653 100644 --- a/Sources/FluentBenchmark/Tests/OptionalParentTests.swift +++ b/Sources/FluentBenchmark/Tests/OptionalParentTests.swift @@ -89,7 +89,7 @@ extension FluentBenchmarker { } } -private final class User: Model { +private final class User: Model, @unchecked Sendable { struct Pet: Codable { enum Animal: String, Codable { case cat, dog diff --git a/Sources/FluentBenchmark/Tests/PerformanceTests+Siblings.swift b/Sources/FluentBenchmark/Tests/PerformanceTests+Siblings.swift index 6bcb1abe..e6c1be77 100644 --- a/Sources/FluentBenchmark/Tests/PerformanceTests+Siblings.swift +++ b/Sources/FluentBenchmark/Tests/PerformanceTests+Siblings.swift @@ -147,7 +147,7 @@ private struct ExpeditionPeopleSeed: Migration { } } -private final class Person: Model { +private final class Person: Model, @unchecked Sendable { static let schema = "people" @ID @@ -191,7 +191,7 @@ private struct PersonMigration: Migration { } } -private final class Expedition: Model { +private final class Expedition: Model, @unchecked Sendable { static let schema = "expeditions" @ID(key: .id) @@ -245,7 +245,7 @@ private struct ExpeditionMigration: Migration { } } -private final class ExpeditionOfficer: Model { +private final class ExpeditionOfficer: Model, @unchecked Sendable { static let schema = "expedition+officer" @ID(key: .id) @@ -274,7 +274,7 @@ private struct ExpeditionOfficerMigration: Migration { } } -private final class ExpeditionScientist: Model { +private final class ExpeditionScientist: Model, @unchecked Sendable { static let schema = "expedition+scientist" @ID(key: .id) @@ -304,7 +304,7 @@ private struct ExpeditionScientistMigration: Migration { } -private final class ExpeditionDoctor: Model { +private final class ExpeditionDoctor: Model, @unchecked Sendable { static let schema = "expedition+doctor" @ID(key: .id) diff --git a/Sources/FluentBenchmark/Tests/PerformanceTests.swift b/Sources/FluentBenchmark/Tests/PerformanceTests.swift index 5504868b..d05a09d5 100644 --- a/Sources/FluentBenchmark/Tests/PerformanceTests.swift +++ b/Sources/FluentBenchmark/Tests/PerformanceTests.swift @@ -39,7 +39,7 @@ extension FluentBenchmarker { } } -private final class Foo: Model { +private final class Foo: Model, @unchecked Sendable { static let schema = "foos" struct Thud: Codable { diff --git a/Sources/FluentBenchmark/Tests/SQLTests.swift b/Sources/FluentBenchmark/Tests/SQLTests.swift index 7de7e34b..0c982c02 100644 --- a/Sources/FluentBenchmark/Tests/SQLTests.swift +++ b/Sources/FluentBenchmark/Tests/SQLTests.swift @@ -21,7 +21,7 @@ extension FluentBenchmarker { // test db.first(decoding:) do { - let user = try sql.raw("SELECT * FROM users").first(decoding: User.self).wait() + let user = try sql.raw("SELECT * FROM users").first(decodingFluent: User.self).wait() XCTAssertNotNil(user) if let user = user { XCTAssertEqual(user.id, tanner.id) @@ -33,7 +33,7 @@ extension FluentBenchmarker { // test db.all(decoding:) do { - let users = try sql.raw("SELECT * FROM users").all(decoding: User.self).wait() + let users = try sql.raw("SELECT * FROM users").all(decodingFluent: User.self).wait() XCTAssertEqual(users.count, 1) if let user = users.first { XCTAssertEqual(user.id, tanner.id) @@ -46,7 +46,7 @@ extension FluentBenchmarker { // test row.decode() do { let users = try sql.raw("SELECT * FROM users").all().wait().map { - try $0.decode(model: User.self) + try $0.decode(fluentModel: User.self) } XCTAssertEqual(users.count, 1) if let user = users.first { @@ -61,7 +61,7 @@ extension FluentBenchmarker { } -private final class User: Model { +private final class User: Model, @unchecked Sendable { static let schema = "users" @ID(key: .id) diff --git a/Sources/FluentBenchmark/Tests/SchemaTests.swift b/Sources/FluentBenchmark/Tests/SchemaTests.swift index f4bc10da..388b6dc3 100644 --- a/Sources/FluentBenchmark/Tests/SchemaTests.swift +++ b/Sources/FluentBenchmark/Tests/SchemaTests.swift @@ -176,7 +176,7 @@ extension FluentBenchmarker { } } -final class Category: Model { +final class Category: Model, @unchecked Sendable { static let schema = "categories" @ID var id: UUID? @Field(key: "name") var name: String diff --git a/Sources/FluentBenchmark/Tests/SetTests.swift b/Sources/FluentBenchmark/Tests/SetTests.swift index 79c24dfc..e543fdff 100644 --- a/Sources/FluentBenchmark/Tests/SetTests.swift +++ b/Sources/FluentBenchmark/Tests/SetTests.swift @@ -50,7 +50,7 @@ extension FluentBenchmarker { } } -private final class Test: Model { +private final class Test: Model, @unchecked Sendable { static let schema = "test" @ID(key: .id) @@ -81,7 +81,7 @@ private enum Foo: String, Codable { case bar, baz } -private final class Test2: Model { +private final class Test2: Model, @unchecked Sendable { static let schema = "test" @ID(key: .id) diff --git a/Sources/FluentBenchmark/Tests/SoftDeleteTests.swift b/Sources/FluentBenchmark/Tests/SoftDeleteTests.swift index 3885684b..1004b32d 100644 --- a/Sources/FluentBenchmark/Tests/SoftDeleteTests.swift +++ b/Sources/FluentBenchmark/Tests/SoftDeleteTests.swift @@ -133,7 +133,7 @@ extension FluentBenchmarker { // Tests eager load of @Parent relation that has been soft-deleted. private func testSoftDelete_parent() throws { - final class Foo: Model { + final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) @@ -158,7 +158,7 @@ extension FluentBenchmarker { } } - final class Bar: Model { + final class Bar: Model, @unchecked Sendable { static let schema = "bars" @ID(key: .id) @@ -227,7 +227,7 @@ extension FluentBenchmarker { } } -private final class Trash: Model { +private final class Trash: Model, @unchecked Sendable { static let schema = "trash" @ID(key: .id) diff --git a/Sources/FluentBenchmark/Tests/TimestampTests.swift b/Sources/FluentBenchmark/Tests/TimestampTests.swift index 4bfc8ed3..50d50c66 100644 --- a/Sources/FluentBenchmark/Tests/TimestampTests.swift +++ b/Sources/FluentBenchmark/Tests/TimestampTests.swift @@ -169,7 +169,7 @@ extension FluentBenchmarker { } } -private final class User: Model { +private final class User: Model, @unchecked Sendable { static let schema = "users" @ID(key: .id) @@ -213,7 +213,7 @@ private struct UserMigration: Migration { } -private final class Event: Model { +private final class Event: Model, @unchecked Sendable { static let schema = "events" @ID(key: .id) diff --git a/Sources/FluentBenchmark/Tests/UniqueTests.swift b/Sources/FluentBenchmark/Tests/UniqueTests.swift index 03fa23af..48310f46 100644 --- a/Sources/FluentBenchmark/Tests/UniqueTests.swift +++ b/Sources/FluentBenchmark/Tests/UniqueTests.swift @@ -35,7 +35,7 @@ extension FluentBenchmarker { } } -private final class Foo: Model { +private final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) diff --git a/Sources/FluentKit/Concurrency/AsyncMigration.swift b/Sources/FluentKit/Concurrency/AsyncMigration.swift index 37033853..fbeebfde 100644 --- a/Sources/FluentKit/Concurrency/AsyncMigration.swift +++ b/Sources/FluentKit/Concurrency/AsyncMigration.swift @@ -7,18 +7,14 @@ public protocol AsyncMigration: Migration { public extension AsyncMigration { func prepare(on database: any Database) -> EventLoopFuture { - let promise = database.eventLoop.makePromise(of: Void.self) - promise.completeWithTask { + database.eventLoop.makeFutureWithTask { try await self.prepare(on: database) } - return promise.futureResult } func revert(on database: any Database) -> EventLoopFuture { - let promise = database.eventLoop.makePromise(of: Void.self) - promise.completeWithTask { + database.eventLoop.makeFutureWithTask { try await self.revert(on: database) } - return promise.futureResult } } diff --git a/Sources/FluentKit/Concurrency/AsyncModelMiddleware.swift b/Sources/FluentKit/Concurrency/AsyncModelMiddleware.swift index 7c026153..28bbb707 100644 --- a/Sources/FluentKit/Concurrency/AsyncModelMiddleware.swift +++ b/Sources/FluentKit/Concurrency/AsyncModelMiddleware.swift @@ -17,31 +17,28 @@ extension AsyncModelMiddleware { on db: any Database, chainingTo next: any AnyModelResponder ) -> EventLoopFuture { - let promise = db.eventLoop.makePromise(of: Void.self) - promise.completeWithTask { - guard let modelType = model as? Model else { - try await next.handle(event, model, on: db).get() - return - } + guard let modelType = (model as? Model).map({ UnsafeTransfer(wrappedValue: $0) }) else { + return next.handle(event, model, on: db) + } + return db.eventLoop.makeFutureWithTask { let responder = AsyncBasicModelResponder { responderEvent, responderModel, responderDB in - return try await next.handle(responderEvent, responderModel, on: responderDB).get() + try await next.handle(responderEvent, responderModel, on: responderDB).get() } switch event { case .create: - try await self.create(model: modelType, on: db, next: responder) + try await self.create(model: modelType.wrappedValue, on: db, next: responder) case .update: - try await self.update(model: modelType, on: db, next: responder) + try await self.update(model: modelType.wrappedValue, on: db, next: responder) case .delete(let force): - try await self.delete(model: modelType, force: force, on: db, next: responder) + try await self.delete(model: modelType.wrappedValue, force: force, on: db, next: responder) case .softDelete: - try await self.softDelete(model: modelType, on: db, next: responder) + try await self.softDelete(model: modelType.wrappedValue, on: db, next: responder) case .restore: - try await self.restore(model: modelType, on: db, next: responder) + try await self.restore(model: modelType.wrappedValue, on: db, next: responder) } } - return promise.futureResult } public func create(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws { diff --git a/Sources/FluentKit/Concurrency/Database+Concurrency.swift b/Sources/FluentKit/Concurrency/Database+Concurrency.swift index 7e422893..676d31bc 100644 --- a/Sources/FluentKit/Concurrency/Database+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/Database+Concurrency.swift @@ -1,19 +1,19 @@ import NIOCore public extension Database { - func transaction(_ closure: @Sendable @escaping (any Database) async throws -> T) async throws -> T { + func transaction(_ closure: @escaping @Sendable (any 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) } - return promise.futureResult + self.eventLoop.makeFutureWithTask { + try await closure(db) + } }.get() } - func withConnection(_ closure: @Sendable @escaping (any Database) async throws -> T) async throws -> T { + func withConnection(_ closure: @escaping @Sendable (any 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) } - return promise.futureResult + self.eventLoop.makeFutureWithTask { + try await closure(db) + } }.get() } } diff --git a/Sources/FluentKit/Concurrency/ModelResponder+Concurrency.swift b/Sources/FluentKit/Concurrency/ModelResponder+Concurrency.swift index adc78b4c..e2898ed9 100644 --- a/Sources/FluentKit/Concurrency/ModelResponder+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/ModelResponder+Concurrency.swift @@ -10,11 +10,11 @@ public protocol AnyAsyncModelResponder: AnyModelResponder { extension AnyAsyncModelResponder { func handle(_ event: ModelEvent, _ model: any AnyModel, on db: any Database) -> EventLoopFuture { - let promise = db.eventLoop.makePromise(of: Void.self) - promise.completeWithTask { - try await self.handle(event, model, on: db) + let model = UnsafeTransfer(wrappedValue: model) + + return db.eventLoop.makeFutureWithTask { + try await self.handle(event, model.wrappedValue, on: db) } - return promise.futureResult } } @@ -41,13 +41,13 @@ extension AnyAsyncModelResponder { } internal struct AsyncBasicModelResponder: AnyAsyncModelResponder { - private let _handle: (ModelEvent, any AnyModel, any Database) async throws -> Void + private let _handle: @Sendable (ModelEvent, any AnyModel, any Database) async throws -> Void internal func handle(_ event: ModelEvent, _ model: any AnyModel, on db: any Database) async throws { - return try await _handle(event, model, db) + try await _handle(event, model, db) } - init(handle: @escaping (ModelEvent, any AnyModel, any Database) async throws -> Void) { + init(handle: @escaping @Sendable (ModelEvent, any AnyModel, any Database) async throws -> Void) { self._handle = handle } } diff --git a/Sources/FluentKit/Concurrency/QueryBuilder+Concurrency.swift b/Sources/FluentKit/Concurrency/QueryBuilder+Concurrency.swift index 92c034e7..aa4469dd 100644 --- a/Sources/FluentKit/Concurrency/QueryBuilder+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/QueryBuilder+Concurrency.swift @@ -16,7 +16,7 @@ public extension QueryBuilder { // MARK: - Fetch - func chunk(max: Int, closure: @escaping ([Result]) -> ()) async throws { + func chunk(max: Int, closure: @escaping @Sendable ([Result]) -> ()) async throws { try await self.chunk(max: max, closure: closure).get() } @@ -47,11 +47,11 @@ public extension QueryBuilder { try await self.run().get() } - func all(_ onOutput: @escaping (Result) -> ()) async throws { + func all(_ onOutput: @escaping @Sendable (Result) -> ()) async throws { try await self.all(onOutput).get() } - func run(_ onOutput: @escaping (any DatabaseOutput) -> ()) async throws { + func run(_ onOutput: @escaping @Sendable (any DatabaseOutput) -> ()) async throws { try await self.run(onOutput).get() } @@ -61,109 +61,109 @@ public extension QueryBuilder { } func count(_ key: KeyPath) async throws -> Int - where Field: QueryableProperty, Field.Model == Model + where Field: QueryableProperty, Field.Model == Model, Field.Value: Sendable { try await self.count(key).get() } func count(_ key: KeyPath) async throws -> Int - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { try await self.count(key).get() } func sum(_ key: KeyPath) async throws -> Field.Value? - where Field: QueryableProperty, Field.Model == Model + where Field: QueryableProperty, Field.Model == Model, Field.Value: Sendable { try await self.sum(key).get() } func sum(_ key: KeyPath) async throws -> Field.Value? - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { 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 & Sendable, 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.IDValue + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, 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, Field.Value: Sendable { try await self.average(key).get() } func average(_ key: KeyPath) async throws -> Field.Value? - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { 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 & Sendable, 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 + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, 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, Field.Value: Sendable { try await self.min(key).get() } func min(_ key: KeyPath) async throws -> Field.Value? - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { 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 & Sendable, 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.IDValue + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, 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, Field.Value: Sendable { try await self.max(key).get() } func max(_ key: KeyPath) async throws -> Field.Value? - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { 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 & Sendable, 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 + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, Field.Model == Model.IDValue { try await self.max(key).get() } @@ -173,7 +173,7 @@ public extension QueryBuilder { _ 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 & Sendable { try await self.aggregate(method, field, as: type).get() } @@ -183,7 +183,7 @@ public extension QueryBuilder { _ field: KeyPath, as type: Result.Type = Result.self ) async throws -> Result - where Field: QueryableProperty, Field.Model == Model.IDValue, Result: Codable + where Field: QueryableProperty, Field.Model == Model.IDValue, Result: Codable & Sendable { try await self.aggregate(method, field, as: type).get() } @@ -193,7 +193,7 @@ public extension QueryBuilder { _ field: FieldKey, as type: Result.Type = Result.self ) async throws -> Result - where Result: Codable + where Result: Codable & Sendable { try await self.aggregate(method, field, as: type).get() } @@ -203,7 +203,7 @@ public extension QueryBuilder { _ path: [FieldKey], as type: Result.Type = Result.self ) async throws -> Result - where Result: Codable + where Result: Codable & Sendable { try await self.aggregate(method, path, as: type).get() } diff --git a/Sources/FluentKit/Concurrency/Siblings+Concurrency.swift b/Sources/FluentKit/Concurrency/Siblings+Concurrency.swift index 01c3db87..88695964 100644 --- a/Sources/FluentKit/Concurrency/Siblings+Concurrency.swift +++ b/Sources/FluentKit/Concurrency/Siblings+Concurrency.swift @@ -19,19 +19,19 @@ public extension SiblingsProperty { // MARK: Operations /// Attach multiple models with plain edit closure. - func attach(_ tos: [To], on database: any Database, _ edit: (Through) -> () = { _ in }) async throws { + func attach(_ tos: [To], on database: any Database, _ edit: @escaping @Sendable (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 { + func attach(_ to: To, on database: any Database, _ edit: @escaping @Sendable (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: any Database, - _ edit: @escaping (Through) -> () = { _ in } + _ edit: @escaping @Sendable (Through) -> () = { _ in } ) async throws { try await self.attach(to, method: method, on: database, edit).get() } @@ -45,7 +45,7 @@ public extension SiblingsProperty { func attach( _ tos: [To], on database: any Database, - _ edit: @Sendable @escaping (Through) async throws -> () + _ edit: @escaping @Sendable (Through) async throws -> () ) async throws { guard let fromID = self.idValue else { throw SiblingsPropertyError.owningModelIdRequired(property: self.name) @@ -71,7 +71,7 @@ public extension SiblingsProperty { /// 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 { + func attach(_ to: To, on database: any Database, _ edit: @escaping @Sendable (Through) async throws -> ()) async throws { try await self.attach(to, method: .always, on: database, edit) } @@ -80,7 +80,7 @@ public extension SiblingsProperty { /// 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 -> () + _ edit: @escaping @Sendable (Through) async throws -> () ) async throws { switch method { case .ifNotExists: diff --git a/Sources/FluentKit/Database/Database+Logging.swift b/Sources/FluentKit/Database/Database+Logging.swift index 252002d9..c81e6c82 100644 --- a/Sources/FluentKit/Database/Database+Logging.swift +++ b/Sources/FluentKit/Database/Database+Logging.swift @@ -25,7 +25,7 @@ extension LoggingOverrideDatabase: Database { func execute( query: DatabaseQuery, - onOutput: @escaping (any DatabaseOutput) -> () + onOutput: @escaping @Sendable (any DatabaseOutput) -> () ) -> EventLoopFuture { self.database.execute(query: query, onOutput: onOutput) } @@ -47,17 +47,17 @@ extension LoggingOverrideDatabase: Database { self.database.inTransaction } - func transaction(_ closure: @escaping (any Database) -> EventLoopFuture) -> EventLoopFuture { + func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { self.database.transaction(closure) } - func withConnection(_ closure: @escaping (any Database) -> EventLoopFuture) -> EventLoopFuture { + func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { self.database.withConnection(closure) } } extension LoggingOverrideDatabase: SQLDatabase where D: SQLDatabase { - func execute(sql query: any SQLExpression, _ onRow: @escaping (any SQLRow) -> ()) -> EventLoopFuture { + func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> ()) -> EventLoopFuture { self.database.execute(sql: query, onRow) } var dialect: any SQLDialect { self.database.dialect } diff --git a/Sources/FluentKit/Database/Database.swift b/Sources/FluentKit/Database/Database.swift index 65ca9357..12711d81 100644 --- a/Sources/FluentKit/Database/Database.swift +++ b/Sources/FluentKit/Database/Database.swift @@ -1,12 +1,12 @@ import NIOCore import Logging -public protocol Database { +public protocol Database: Sendable { var context: DatabaseContext { get } func execute( query: DatabaseQuery, - onOutput: @escaping (any DatabaseOutput) -> () + onOutput: @escaping @Sendable (any DatabaseOutput) -> () ) -> EventLoopFuture func execute( @@ -19,9 +19,9 @@ public protocol Database { var inTransaction: Bool { get } - func transaction(_ closure: @escaping (any Database) -> EventLoopFuture) -> EventLoopFuture + func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture - func withConnection(_ closure: @escaping (any Database) -> EventLoopFuture) -> EventLoopFuture + func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture } extension Database { @@ -54,17 +54,17 @@ extension Database { } } -public protocol DatabaseDriver { +public protocol DatabaseDriver: Sendable { func makeDatabase(with context: DatabaseContext) -> any Database func shutdown() } -public protocol DatabaseConfiguration { +public protocol DatabaseConfiguration: Sendable { var middleware: [any AnyModelMiddleware] { get set } func makeDriver(for databases: Databases) -> any DatabaseDriver } -public struct DatabaseContext { +public struct DatabaseContext: Sendable { public let configuration: any DatabaseConfiguration public let logger: Logger public let eventLoop: any EventLoop diff --git a/Sources/FluentKit/Database/DatabaseInput.swift b/Sources/FluentKit/Database/DatabaseInput.swift index e4c4542c..19ad36e3 100644 --- a/Sources/FluentKit/Database/DatabaseInput.swift +++ b/Sources/FluentKit/Database/DatabaseInput.swift @@ -115,9 +115,9 @@ private struct PrefixedDatabaseInput: DatabaseInput { /// 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. +/// > 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 diff --git a/Sources/FluentKit/Database/DatabaseOutput.swift b/Sources/FluentKit/Database/DatabaseOutput.swift index c809cab3..781225ff 100644 --- a/Sources/FluentKit/Database/DatabaseOutput.swift +++ b/Sources/FluentKit/Database/DatabaseOutput.swift @@ -1,4 +1,4 @@ -public protocol DatabaseOutput: CustomStringConvertible { +public protocol DatabaseOutput: CustomStringConvertible, Sendable { func schema(_ schema: String) -> any DatabaseOutput func contains(_ key: FieldKey) -> Bool func decodeNil(_ key: FieldKey) throws -> Bool diff --git a/Sources/FluentKit/Database/Databases.swift b/Sources/FluentKit/Database/Databases.swift index bdaf3967..e3b4ec61 100644 --- a/Sources/FluentKit/Database/Databases.swift +++ b/Sources/FluentKit/Database/Databases.swift @@ -4,15 +4,15 @@ import NIOCore import NIOPosix import Logging -public struct DatabaseConfigurationFactory { - public let make: () -> any DatabaseConfiguration +public struct DatabaseConfigurationFactory: Sendable { + public let make: @Sendable () -> any DatabaseConfiguration - public init(make: @escaping () -> any DatabaseConfiguration) { + public init(make: @escaping @Sendable () -> any DatabaseConfiguration) { self.make = make } } -public final class Databases { +public final class Databases: @unchecked Sendable { // @unchecked is safe here; mutable data is protected by lock public let eventLoopGroup: any EventLoopGroup public let threadPool: NIOThreadPool diff --git a/Sources/FluentKit/Database/KeyPrefixingStrategy.swift b/Sources/FluentKit/Database/KeyPrefixingStrategy.swift index 52fd3d68..add18b65 100644 --- a/Sources/FluentKit/Database/KeyPrefixingStrategy.swift +++ b/Sources/FluentKit/Database/KeyPrefixingStrategy.swift @@ -1,5 +1,5 @@ /// A strategy describing how to apply a prefix to a ``FieldKey``. -public enum KeyPrefixingStrategy: CustomStringConvertible { +public enum KeyPrefixingStrategy: CustomStringConvertible, Sendable { /// The "do nothing" strategy - the prefix is applied to each key by simple concatenation. case none @@ -13,7 +13,7 @@ public enum KeyPrefixingStrategy: CustomStringConvertible { /// 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) + case custom(@Sendable (_ prefix: FieldKey, _ idFieldKey: FieldKey) -> FieldKey) // See `CustomStringConvertible.description`. public var description: String { diff --git a/Sources/FluentKit/Docs.docc/Resources/vapor-fluentkit-logo.svg b/Sources/FluentKit/Docs.docc/Resources/vapor-fluentkit-logo.svg new file mode 100644 index 00000000..3d468c81 --- /dev/null +++ b/Sources/FluentKit/Docs.docc/Resources/vapor-fluentkit-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/FluentKit/Docs.docc/index.md b/Sources/FluentKit/Docs.docc/index.md index ec8f1015..7628721a 100644 --- a/Sources/FluentKit/Docs.docc/index.md +++ b/Sources/FluentKit/Docs.docc/index.md @@ -12,4 +12,4 @@ let planets = try await Planet.query(on: database) .all() ``` -For more information, see the [Vapor documentation](https://docs.vapor.codes/fluent/overview/). \ No newline at end of file +For more information, see the [Fluent documentation](https://docs.vapor.codes/fluent/overview/). diff --git a/Sources/FluentKit/Docs.docc/theme-settings.json b/Sources/FluentKit/Docs.docc/theme-settings.json new file mode 100644 index 00000000..ec916e9a --- /dev/null +++ b/Sources/FluentKit/Docs.docc/theme-settings.json @@ -0,0 +1,21 @@ +{ + "theme": { + "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, + "border-radius": "0", + "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "color": { + "fluentkit": { "dark": "hsl(200, 75%, 85%)", "light": "hsl(200, 75%, 75%)" }, + "documentation-intro-fill": "radial-gradient(circle at top, var(--color-fluentkit) 30%, #000 100%)", + "documentation-intro-accent": "var(--color-fluentkit)", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "fill": { "dark": "#000", "light": "#fff" } + }, + "icons": { "technology": "/fluentkit/images/vapor-fluentkit-logo.svg" } + }, + "features": { + "quickNavigation": { "enable": true }, + "i18n": { "enable": true } + } +} diff --git a/Sources/FluentKit/Enum/DatabaseEnum.swift b/Sources/FluentKit/Enum/DatabaseEnum.swift index 9a1a8a3c..7aa4734e 100644 --- a/Sources/FluentKit/Enum/DatabaseEnum.swift +++ b/Sources/FluentKit/Enum/DatabaseEnum.swift @@ -1,5 +1,5 @@ -public struct DatabaseEnum { - public enum Action { +public struct DatabaseEnum: Sendable { + public enum Action: Sendable { case create case update case delete diff --git a/Sources/FluentKit/Enum/EnumBuilder.swift b/Sources/FluentKit/Enum/EnumBuilder.swift index c563a601..4a186462 100644 --- a/Sources/FluentKit/Enum/EnumBuilder.swift +++ b/Sources/FluentKit/Enum/EnumBuilder.swift @@ -1,4 +1,5 @@ import NIOCore +import NIOConcurrencyHelpers import SQLKit extension Database { @@ -7,13 +8,18 @@ extension Database { } } -public final class EnumBuilder { +public final class EnumBuilder: Sendable { let database: any Database - public var `enum`: DatabaseEnum + let lockedEnum: NIOLockedValueBox + + public var `enum`: DatabaseEnum { + get { self.lockedEnum.withLockedValue { $0 } } + set { self.lockedEnum.withLockedValue { $0 = newValue } } + } init(database: any Database, name: String) { self.database = database - self.enum = .init(name: name) + self.lockedEnum = .init(.init(name: name)) } public func `case`(_ name: String) -> Self { diff --git a/Sources/FluentKit/Enum/EnumMetadata.swift b/Sources/FluentKit/Enum/EnumMetadata.swift index 190e4016..b6430e99 100644 --- a/Sources/FluentKit/Enum/EnumMetadata.swift +++ b/Sources/FluentKit/Enum/EnumMetadata.swift @@ -1,11 +1,11 @@ import NIOCore import Foundation -final class EnumMetadata: Model { +final class EnumMetadata: Model, @unchecked Sendable { static let schema = "_fluent_enums" static var migration: any Migration { - return EnumMetadataMigration() + EnumMetadataMigration() } @ID(key: .id) @@ -17,7 +17,7 @@ final class EnumMetadata: Model { @Field(key: "case") var `case`: String - init() { } + init() {} init(id: IDValue? = nil, name: String, `case`: String) { self.id = id @@ -26,10 +26,10 @@ final class EnumMetadata: Model { } } -private struct EnumMetadataMigration: Migration { - func prepare(on database: any Database) -> EventLoopFuture { - database.schema("_fluent_enums") - .field(.id, .uuid, .identifier(auto: false)) +private struct EnumMetadataMigration: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(EnumMetadata.schema) + .id() .field("name", .string, .required) .field("case", .string, .required) .unique(on: "name", "case") @@ -37,7 +37,7 @@ private struct EnumMetadataMigration: Migration { .create() } - func revert(on database: any Database) -> EventLoopFuture { - database.schema("_fluent_enums").delete() + func revert(on database: any Database) async throws { + try await database.schema(EnumMetadata.schema).delete() } } diff --git a/Sources/FluentKit/Enum/EnumProperty.swift b/Sources/FluentKit/Enum/EnumProperty.swift index 3e99e5e4..3e07b000 100644 --- a/Sources/FluentKit/Enum/EnumProperty.swift +++ b/Sources/FluentKit/Enum/EnumProperty.swift @@ -1,6 +1,6 @@ extension Fields { public typealias Enum = EnumProperty - where Value: Codable, + where Value: Codable & Sendable, Value: RawRepresentable, Value.RawValue == String } @@ -10,7 +10,7 @@ extension Fields { @propertyWrapper public final class EnumProperty where Model: FluentKit.Fields, - Value: Codable, + Value: Codable & Sendable, Value: RawRepresentable, Value.RawValue == String { diff --git a/Sources/FluentKit/Enum/OptionalEnumProperty.swift b/Sources/FluentKit/Enum/OptionalEnumProperty.swift index 9e27141e..51cddd64 100644 --- a/Sources/FluentKit/Enum/OptionalEnumProperty.swift +++ b/Sources/FluentKit/Enum/OptionalEnumProperty.swift @@ -1,6 +1,6 @@ extension Fields { public typealias OptionalEnum = OptionalEnumProperty - where Value: Codable, + where Value: Codable & Sendable, Value: RawRepresentable, Value.RawValue == String } @@ -10,7 +10,7 @@ extension Fields { @propertyWrapper public final class OptionalEnumProperty where Model: FluentKit.Fields, - WrappedValue: Codable, + WrappedValue: Codable & Sendable, WrappedValue: RawRepresentable, WrappedValue.RawValue == String { diff --git a/Sources/FluentKit/Exports.swift b/Sources/FluentKit/Exports.swift index 9cded41b..28aef3f0 100644 --- a/Sources/FluentKit/Exports.swift +++ b/Sources/FluentKit/Exports.swift @@ -1,27 +1,10 @@ -#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 - -@_exported import Logging - -@_exported import protocol NIO.EventLoop -@_exported import class NIO.EventLoopFuture -@_exported import struct NIO.EventLoopPromise -@_exported import protocol NIO.EventLoopGroup -@_exported import class NIO.NIOThreadPool - -#endif +@_documentation(visibility: internal) @_exported import protocol NIOCore.EventLoop +@_documentation(visibility: internal) @_exported import class NIOCore.EventLoopFuture +@_documentation(visibility: internal) @_exported import struct NIOCore.EventLoopPromise +@_documentation(visibility: internal) @_exported import protocol NIOCore.EventLoopGroup +@_documentation(visibility: internal) @_exported import class NIOPosix.NIOThreadPool diff --git a/Sources/FluentKit/Middleware/ModelMiddleware.swift b/Sources/FluentKit/Middleware/ModelMiddleware.swift index 5b4dae27..d717ce02 100644 --- a/Sources/FluentKit/Middleware/ModelMiddleware.swift +++ b/Sources/FluentKit/Middleware/ModelMiddleware.swift @@ -1,6 +1,6 @@ import NIOCore -public protocol AnyModelMiddleware { +public protocol AnyModelMiddleware: Sendable { func handle( _ event: ModelEvent, _ model: any AnyModel, @@ -67,7 +67,10 @@ extension AnyModelMiddleware { } extension Array where Element == any AnyModelMiddleware { - internal func chainingTo(_ type: Model.Type, closure: @escaping (ModelEvent, Model, any Database) throws -> EventLoopFuture) -> any AnyModelResponder where Model: FluentKit.Model { + internal func chainingTo( + _ type: Model.Type, + closure: @escaping @Sendable (ModelEvent, Model, any Database) throws -> EventLoopFuture + ) -> any AnyModelResponder where Model: FluentKit.Model { var responder: any AnyModelResponder = BasicModelResponder(handle: closure) for middleware in reversed() { responder = middleware.makeResponder(chainingTo: responder) @@ -85,7 +88,7 @@ private struct ModelMiddlewareResponder: AnyModelResponder { } } -public enum ModelEvent { +public enum ModelEvent: Sendable { case create case update case delete(Bool) diff --git a/Sources/FluentKit/Middleware/ModelResponder.swift b/Sources/FluentKit/Middleware/ModelResponder.swift index 33fb7352..cfb49733 100644 --- a/Sources/FluentKit/Middleware/ModelResponder.swift +++ b/Sources/FluentKit/Middleware/ModelResponder.swift @@ -1,6 +1,6 @@ import NIOCore -public protocol AnyModelResponder { +public protocol AnyModelResponder: Sendable { func handle( _ event: ModelEvent, _ model: any AnyModel, @@ -31,7 +31,7 @@ extension AnyModelResponder { } internal struct BasicModelResponder: AnyModelResponder where Model: FluentKit.Model { - private let _handle: (ModelEvent, Model, any Database) throws -> EventLoopFuture + private let _handle: @Sendable (ModelEvent, Model, any Database) throws -> EventLoopFuture internal func handle(_ event: ModelEvent, _ model: any AnyModel, on db: any Database) -> EventLoopFuture { guard let modelType = model as? Model else { @@ -45,7 +45,7 @@ internal struct BasicModelResponder: AnyModelResponder where Model: Fluen } } - init(handle: @escaping (ModelEvent, Model, any Database) throws -> EventLoopFuture) { + init(handle: @escaping @Sendable (ModelEvent, Model, any Database) throws -> EventLoopFuture) { self._handle = handle } } diff --git a/Sources/FluentKit/Migration/Migration.swift b/Sources/FluentKit/Migration/Migration.swift index 49fc90f9..4cab0881 100644 --- a/Sources/FluentKit/Migration/Migration.swift +++ b/Sources/FluentKit/Migration/Migration.swift @@ -3,7 +3,7 @@ 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. -public protocol Migration { +public protocol Migration: Sendable { /// The name of the migration which Fluent uses to track the state of. var name: String { get } diff --git a/Sources/FluentKit/Migration/MigrationLog.swift b/Sources/FluentKit/Migration/MigrationLog.swift index b4a884c2..d2e8302f 100644 --- a/Sources/FluentKit/Migration/MigrationLog.swift +++ b/Sources/FluentKit/Migration/MigrationLog.swift @@ -2,7 +2,7 @@ import NIOCore import Foundation /// Stores information about `Migration`s that have been run. -public final class MigrationLog: Model { +public final class MigrationLog: Model, @unchecked Sendable { public static let schema = "_fluent_migrations" public static var migration: any Migration { @@ -24,7 +24,7 @@ public final class MigrationLog: Model { @Timestamp(key: "updated_at", on: .update) public var updatedAt: Date? - public init() { } + public init() {} public init(id: IDValue? = nil, name: String, batch: Int) { self.id = id @@ -35,9 +35,9 @@ public final class MigrationLog: Model { } } -private struct MigrationLogMigration: Migration { - func prepare(on database: any Database) -> EventLoopFuture { - database.schema("_fluent_migrations") +private struct MigrationLogMigration: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(MigrationLog.schema) .field(.id, .uuid, .identifier(auto: false)) .field("name", .string, .required) .field("batch", .int, .required) @@ -48,7 +48,7 @@ private struct MigrationLogMigration: Migration { .create() } - func revert(on database: any Database) -> EventLoopFuture { - database.schema("_fluent_migrations").delete() + func revert(on database: any Database) async throws { + try await database.schema(MigrationLog.schema).delete() } } diff --git a/Sources/FluentKit/Migration/Migrations.swift b/Sources/FluentKit/Migration/Migrations.swift index cabd8b72..44dd36f3 100644 --- a/Sources/FluentKit/Migration/Migrations.swift +++ b/Sources/FluentKit/Migration/Migrations.swift @@ -1,12 +1,14 @@ -public final class Migrations { - var storage: [DatabaseID?: [any Migration]] +import NIOConcurrencyHelpers + +public final class Migrations: Sendable { + let storage: NIOLockedValueBox<[DatabaseID?: [any Migration]]> public init() { - self.storage = [:] + self.storage = .init([:]) } public func add(_ migration: any Migration, to id: DatabaseID? = nil) { - self.storage[id, default: []].append(migration) + self.storage.withLockedValue { $0[id, default: []].append(migration) } } @inlinable @@ -15,6 +17,6 @@ public final class Migrations { } public func add(_ migrations: [any Migration], to id: DatabaseID? = nil) { - self.storage[id, default: []].append(contentsOf: migrations) + self.storage.withLockedValue { $0[id, default: []].append(contentsOf: migrations) } } } diff --git a/Sources/FluentKit/Migration/Migrator.swift b/Sources/FluentKit/Migration/Migrator.swift index 87be2968..02755015 100644 --- a/Sources/FluentKit/Migration/Migrator.swift +++ b/Sources/FluentKit/Migration/Migrator.swift @@ -3,8 +3,8 @@ import AsyncKit import Logging import NIOCore -public struct Migrator { - public let databaseFactory: (DatabaseID?) -> (any Database) +public struct Migrator: Sendable { + public let databaseFactory: @Sendable (DatabaseID?) -> (any Database) public let migrations: Migrations public let eventLoop: any EventLoop public let migrationLogLevel: Logger.Level @@ -27,7 +27,7 @@ public struct Migrator { } public init( - databaseFactory: @escaping (DatabaseID?) -> (any Database), + databaseFactory: @escaping @Sendable (DatabaseID?) -> (any Database), migrations: Migrations, on eventLoop: any EventLoop, migrationLogLevel: Logger.Level = .info @@ -113,31 +113,31 @@ 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, migrationLogLeveL: self.migrationLogLevel)) - } + self.migrations.storage.withLockedValue { $0.map { + handler(.init(id: $0, database: self.databaseFactory($0), migrations: $1, migrationLogLevel: self.migrationLogLevel)) + } } .flatten(on: self.eventLoop) } } -private final class DatabaseMigrator { +private final class DatabaseMigrator: Sendable { let migrations: [any Migration] - let database: any Database + let database: any Database & Sendable let id: DatabaseID? let migrationLogLevel: Logger.Level - init(id: DatabaseID?, database: any Database, migrations: [any Migration], migrationLogLeveL: Logger.Level) { + init(id: DatabaseID?, database: any Database & Sendable, migrations: [any Migration], migrationLogLevel: Logger.Level) { self.migrations = migrations self.database = database self.id = id - self.migrationLogLevel = migrationLogLeveL + self.migrationLogLevel = migrationLogLevel } // MARK: Setup func setupIfNeeded() -> EventLoopFuture { - return MigrationLog.migration.prepare(on: self.database) - .map(self.preventUnstableNames) + MigrationLog.migration.prepare(on: self.database) + .map { self.preventUnstableNames() } } /// An unstable name is a name that is not the same every time migrations @@ -160,7 +160,7 @@ private final class DatabaseMigrator { // MARK: Prepare func prepareBatch() -> EventLoopFuture { - return self.lastBatchNumber().flatMap { batch in + self.lastBatchNumber().flatMap { batch in self.unpreparedMigrations().sequencedFlatMapEach { self.prepare($0, batch: batch + 1) } } } @@ -168,35 +168,35 @@ private final class DatabaseMigrator { // MARK: Revert func revertLastBatch() -> EventLoopFuture { - return self.lastBatchNumber().flatMap(self.revertBatch(number:)) + self.lastBatchNumber().flatMap { self.revertBatch(number: $0) } } func revertBatch(number: Int) -> EventLoopFuture { - return self.preparedMigrations(batch: number).sequencedFlatMapEach(self.revert) + self.preparedMigrations(batch: number).sequencedFlatMapEach(self.revert) } func revertAllBatches() -> EventLoopFuture { - return self.preparedMigrations().sequencedFlatMapEach(self.revert) + self.preparedMigrations().sequencedFlatMapEach(self.revert) } // MARK: Preview func previewPrepareBatch() -> EventLoopFuture<[any Migration]> { - return self.unpreparedMigrations() + self.unpreparedMigrations() } func previewRevertLastBatch() -> EventLoopFuture<[any Migration]> { - return self.lastBatchNumber().flatMap { batch in - return self.preparedMigrations(batch: batch) + self.lastBatchNumber().flatMap { batch in + self.preparedMigrations(batch: batch) } } func previewRevertBatch(number: Int) -> EventLoopFuture<[any Migration]> { - return self.preparedMigrations(batch: number) + self.preparedMigrations(batch: number) } func previewRevertAllBatches() -> EventLoopFuture<[any Migration]> { - return self.preparedMigrations() + self.preparedMigrations() } // MARK: Private @@ -224,34 +224,34 @@ private final class DatabaseMigrator { } private func revertMigrationLog() -> EventLoopFuture { - return MigrationLog.migration.revert(on: self.database) + MigrationLog.migration.revert(on: self.database) } private func lastBatchNumber() -> EventLoopFuture { - return MigrationLog.query(on: self.database).sort(\.$batch, .descending).first().map { log in + MigrationLog.query(on: self.database).sort(\.$batch, .descending).first().map { log in log?.batch ?? 0 } } private func preparedMigrations() -> EventLoopFuture<[any Migration]> { - return MigrationLog.query(on: self.database).all().map { logs in - return self.migrations.filter { migration in - return logs.contains(where: { $0.name == migration.name }) + MigrationLog.query(on: self.database).all().map { logs in + self.migrations.filter { migration in + logs.contains(where: { $0.name == migration.name }) }.reversed() } } private func preparedMigrations(batch: Int) -> EventLoopFuture<[any Migration]> { - return MigrationLog.query(on: self.database).filter(\.$batch == batch).all().map { logs in - return self.migrations.filter { migration in - return logs.contains(where: { $0.name == migration.name }) + MigrationLog.query(on: self.database).filter(\.$batch == batch).all().map { logs in + self.migrations.filter { migration in + logs.contains(where: { $0.name == migration.name }) }.reversed() } } private func unpreparedMigrations() -> EventLoopFuture<[any Migration]> { - return MigrationLog.query(on: self.database).all().map { logs in - return self.migrations.compactMap { migration in + MigrationLog.query(on: self.database).all().map { logs in + self.migrations.compactMap { migration in if logs.contains(where: { $0.name == migration.name }) { return nil } return migration } diff --git a/Sources/FluentKit/Model/EagerLoad.swift b/Sources/FluentKit/Model/EagerLoad.swift index 1ac3f76c..a92021c0 100644 --- a/Sources/FluentKit/Model/EagerLoad.swift +++ b/Sources/FluentKit/Model/EagerLoad.swift @@ -11,7 +11,7 @@ extension EagerLoader { } } -public protocol AnyEagerLoader { +public protocol AnyEagerLoader: Sendable { func anyRun(models: [any AnyModel], on database: any Database) -> EventLoopFuture } diff --git a/Sources/FluentKit/Model/Fields+Codable.swift b/Sources/FluentKit/Model/Fields+Codable.swift index 2695ec6a..29a3c27d 100644 --- a/Sources/FluentKit/Model/Fields+Codable.swift +++ b/Sources/FluentKit/Model/Fields+Codable.swift @@ -1,3 +1,5 @@ +import struct SQLKit.SomeCodingKey + extension Fields { public init(from decoder: any Decoder) throws { self.init() diff --git a/Sources/FluentKit/Model/Fields.swift b/Sources/FluentKit/Model/Fields.swift index d8047dc9..f72afd06 100644 --- a/Sources/FluentKit/Model/Fields.swift +++ b/Sources/FluentKit/Model/Fields.swift @@ -1,3 +1,5 @@ +import SQLKit + /// 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. @@ -12,16 +14,16 @@ /// 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 { +public protocol Fields: AnyObject, Codable, Sendable { /// 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. + /// > 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: [any AnyProperty] { get } init() @@ -64,9 +66,9 @@ extension Fields { /// 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``). + /// > 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: any DatabaseInput) { for field in self.databaseProperties { field.input(to: input) @@ -77,8 +79,8 @@ extension Fields { /// 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``). + /// > 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: any DatabaseOutput) throws { for field in self.databaseProperties { try field.output(from: output) @@ -104,16 +106,16 @@ extension Fields { /// 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. + /// > 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: 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. + /// > Warning: Like ``properties``, this method uses reflection, and incurs all of the accompanying + /// > performance penalties. internal var codableProperties: [SomeCodingKey: any AnyCodableProperty] { return .init(uniqueKeysWithValues: _FastChildSequence(subject: self).compactMap { guard let value = $1 as? any AnyCodableProperty, @@ -154,9 +156,12 @@ private final class HasChangesInput: DatabaseInput { // MARK: Collect Input extension Fields { + /// For internal use only. + /// /// 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] { + @_spi(FluentSQLSPI) + public/*package*/ func collectInput(withDefaultedValues defaultedValues: Bool = false) -> [FieldKey: DatabaseQuery.Value] { let input = DictionaryInput(wantsUnmodifiedKeys: defaultedValues) self.input(to: input) return input.storage diff --git a/Sources/FluentKit/Model/MirrorBypass.swift b/Sources/FluentKit/Model/MirrorBypass.swift index 99906be5..6a29da1c 100644 --- a/Sources/FluentKit/Model/MirrorBypass.swift +++ b/Sources/FluentKit/Model/MirrorBypass.swift @@ -1,4 +1,4 @@ -#if compiler(<5.10) +#if compiler(<6) @_silgen_name("swift_reflectionMirror_normalizedType") internal func _getNormalizedType(_: T, type: Any.Type) -> Any.Type @@ -24,40 +24,40 @@ internal struct _FastChildIterator: IteratorProtocol { deinit { self.freeFunc(self.ptr) } } -#if compiler(<5.10) + #if compiler(<6) private let subject: AnyObject private let type: Any.Type private let childCount: Int private var index: Int -#else + #else private var iterator: Mirror.Children.Iterator -#endif + #endif private var lastNameBox: _CStringBox? -#if compiler(<5.10) + #if compiler(<6) fileprivate init(subject: AnyObject, type: Any.Type, childCount: Int) { self.subject = subject self.type = type self.childCount = childCount self.index = 0 } -#else + #else fileprivate init(iterator: Mirror.Children.Iterator) { self.iterator = iterator } -#endif + #endif init(subject: AnyObject) { -#if compiler(<5.10) + #if compiler(<6) let type = _getNormalizedType(subject, type: Swift.type(of: subject)) self.init( subject: subject, type: type, childCount: _getChildCount(subject, type: type) ) -#else + #else self.init(iterator: Mirror(reflecting: subject).children.makeIterator()) -#endif + #endif } /// The `name` pointer returned by this iterator has a rather unusual lifetime guarantee - it shall remain valid @@ -66,10 +66,10 @@ internal struct _FastChildIterator: IteratorProtocol { /// `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. + /// > 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) + #if compiler(<6) guard self.index < self.childCount else { self.lastNameBox = nil // ensure any lingering name gets freed return nil @@ -82,7 +82,7 @@ internal struct _FastChildIterator: IteratorProtocol { 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 + #else guard let child = self.iterator.next() else { self.lastNameBox = nil return nil @@ -100,34 +100,34 @@ internal struct _FastChildIterator: IteratorProtocol { self.lastNameBox = nil return (name: nil, child: child.value) } -#endif + #endif } } internal struct _FastChildSequence: Sequence { -#if compiler(<5.10) + #if compiler(<6) private let subject: AnyObject private let type: Any.Type private let childCount: Int -#else + #else private let children: Mirror.Children -#endif + #endif init(subject: AnyObject) { -#if compiler(<5.10) + #if compiler(<6) self.subject = subject self.type = _getNormalizedType(subject, type: Swift.type(of: subject)) self.childCount = _getChildCount(subject, type: self.type) -#else + #else self.children = Mirror(reflecting: subject).children -#endif + #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 + #if compiler(<6) + _FastChildIterator(subject: self.subject, type: self.type, childCount: self.childCount) + #else + _FastChildIterator(iterator: self.children.makeIterator()) + #endif } } diff --git a/Sources/FluentKit/Model/Model+CRUD.swift b/Sources/FluentKit/Model/Model+CRUD.swift index e4042209..524a8465 100644 --- a/Sources/FluentKit/Model/Model+CRUD.swift +++ b/Sources/FluentKit/Model/Model+CRUD.swift @@ -17,6 +17,7 @@ extension Model { } private func _create(on database: any Database) -> EventLoopFuture { + let transfer = UnsafeTransfer(wrappedValue: self) precondition(!self._$idExists) self.touchTimestamps(.create, .update) if self.anyID is any AnyQueryableProperty { @@ -28,12 +29,12 @@ extension Model { .run { promise.succeed($0) } .cascadeFailure(to: promise) return promise.futureResult.flatMapThrowing { output in - var input = self.collectInput() - if case .default = self._$id.inputValue { + var input = transfer.wrappedValue.collectInput() + if case .default = transfer.wrappedValue._$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 transfer.wrappedValue.output(from: SavedInput(input)) } } else { return Self.query(on: database) @@ -41,7 +42,7 @@ extension Model { .action(.create) .run() .flatMapThrowing { - try self.output(from: SavedInput(self.collectInput())) + try transfer.wrappedValue.output(from: SavedInput(transfer.wrappedValue.collectInput())) } } } @@ -60,13 +61,14 @@ extension Model { self.touchTimestamps(.update) let input = self.collectInput() guard let id = self.id else { throw FluentError.idRequired } + let transfer = UnsafeTransfer(wrappedValue: self) return Self.query(on: database) .filter(id: id) .set(input) .update() .flatMapThrowing { - try self.output(from: SavedInput(input)) + try transfer.wrappedValue.output(from: SavedInput(input)) } } @@ -85,13 +87,14 @@ extension Model { private func _delete(force: Bool = false, on database: any Database) throws -> EventLoopFuture { guard let id = self.id else { throw FluentError.idRequired } + let transfer = UnsafeTransfer(wrappedValue: self) return Self.query(on: database) .filter(id: id) .delete(force: force) .map { - if force || self.deletedTimestamp == nil { - self._$idExists = false + if force || transfer.wrappedValue.deletedTimestamp == nil { + transfer.wrappedValue._$idExists = false } } } @@ -109,6 +112,7 @@ extension Model { timestamp.touch(date: nil) precondition(self._$idExists) guard let id = self.id else { throw FluentError.idRequired } + let transfer = UnsafeTransfer(wrappedValue: self) return Self.query(on: database) .withDeleted() .filter(id: id) @@ -117,8 +121,8 @@ extension Model { .run() .flatMapThrowing { - try self.output(from: SavedInput(self.collectInput())) - self._$idExists = true + try transfer.wrappedValue.output(from: SavedInput(transfer.wrappedValue.collectInput())) + transfer.wrappedValue._$idExists = true } } @@ -146,18 +150,20 @@ extension Collection where Element: FluentKit.Model { precondition(self.allSatisfy { $0._$idExists }) + let transfer = UnsafeTransfer(wrappedValue: self) // ouch, the retains... + return EventLoopFuture.andAllSucceed(self.map { model in database.configuration.middleware.chainingTo(Element.self) { event, model, db in - return db.eventLoop.makeSucceededFuture(()) + db.eventLoop.makeSucceededFuture(()) }.delete(model, force: force, on: database) }, on: database.eventLoop).flatMap { Element.query(on: database) - .filter(ids: self.map { $0.id! }) + .filter(ids: transfer.wrappedValue.map { $0.id! }) .delete(force: force) }.map { guard force else { return } - for model in self where model.deletedTimestamp == nil { + for model in transfer.wrappedValue where model.deletedTimestamp == nil { model._$idExists = false } } @@ -170,6 +176,8 @@ extension Collection where Element: FluentKit.Model { precondition(self.allSatisfy { !$0._$idExists }) + let transfer = UnsafeTransfer(wrappedValue: self) // ouch, the retains... + return EventLoopFuture.andAllSucceed(self.enumerated().map { idx, model in database.configuration.middleware.chainingTo(Element.self) { event, model, db in if model.anyID is any AnyQueryableProperty { @@ -180,10 +188,10 @@ extension Collection where Element: FluentKit.Model { }.create(model, on: database) }, on: database.eventLoop).flatMap { Element.query(on: database) - .set(self.map { $0.collectInput(withDefaultedValues: database is any SQLDatabase) }) + .set(transfer.wrappedValue.map { $0.collectInput(withDefaultedValues: database is any SQLDatabase) }) .create() }.map { - for model in self { + for model in transfer.wrappedValue { model._$idExists = true } } diff --git a/Sources/FluentKit/Model/Model.swift b/Sources/FluentKit/Model/Model.swift index bcfeae2f..175821bb 100644 --- a/Sources/FluentKit/Model/Model.swift +++ b/Sources/FluentKit/Model/Model.swift @@ -1,7 +1,7 @@ import NIOCore public protocol Model: AnyModel { - associatedtype IDValue: Codable, Hashable + associatedtype IDValue: Codable, Hashable, Sendable var id: IDValue? { get set } } @@ -35,10 +35,10 @@ extension Model { /// 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. + /// > 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 } diff --git a/Sources/FluentKit/Operators/FieldOperators.swift b/Sources/FluentKit/Operators/FieldOperators.swift index 69793d99..e797c2f3 100644 --- a/Sources/FluentKit/Operators/FieldOperators.swift +++ b/Sources/FluentKit/Operators/FieldOperators.swift @@ -176,7 +176,7 @@ public func !=~ ( .init(lhs, .contains(inverse: true, .prefix), rhs) } -public struct ModelFieldFilter +public struct ModelFieldFilter: Sendable where Left: FluentKit.Schema, Right: FluentKit.Schema { public init( diff --git a/Sources/FluentKit/Operators/ValueOperators.swift b/Sources/FluentKit/Operators/ValueOperators.swift index e7339142..ed356969 100644 --- a/Sources/FluentKit/Operators/ValueOperators.swift +++ b/Sources/FluentKit/Operators/ValueOperators.swift @@ -130,7 +130,7 @@ public func <= (lhs: KeyPath, rhs: DatabaseQuery.Val .init(lhs, .lessThanOrEqual, rhs) } -public struct ModelValueFilter where Model: Fields { +public struct ModelValueFilter: Sendable where Model: Fields { public init( _ lhs: KeyPath, _ method: DatabaseQuery.Filter.Method, @@ -148,7 +148,7 @@ public struct ModelValueFilter where Model: Fields { let value: DatabaseQuery.Value } -public struct ModelCompositeIDFilter where Model: FluentKit.Model, Model.IDValue: Fields { +public struct ModelCompositeIDFilter: Sendable where Model: FluentKit.Model, Model.IDValue: Fields { public init( _ method: DatabaseQuery.Filter.Method, _ rhs: Model.IDValue diff --git a/Sources/FluentKit/Properties/Boolean.swift b/Sources/FluentKit/Properties/Boolean.swift index 2f99e097..50e5570a 100644 --- a/Sources/FluentKit/Properties/Boolean.swift +++ b/Sources/FluentKit/Properties/Boolean.swift @@ -41,7 +41,7 @@ extension Fields { /// func revert(on database: Database) async throws -> Void { try await database.schema(MyModel.schema).delete() } /// } /// -/// - Note: See also ``OptionalBooleanProperty`` and ``BooleanPropertyFormat``. +/// > Note: See also ``OptionalBooleanProperty`` and ``BooleanPropertyFormat``. @propertyWrapper public final class BooleanProperty where Model: FluentKit.Fields, Format: BooleanPropertyFormat diff --git a/Sources/FluentKit/Properties/BooleanPropertyFormat.swift b/Sources/FluentKit/Properties/BooleanPropertyFormat.swift index b2131752..83bee2b7 100644 --- a/Sources/FluentKit/Properties/BooleanPropertyFormat.swift +++ b/Sources/FluentKit/Properties/BooleanPropertyFormat.swift @@ -1,6 +1,6 @@ /// A conversion between `Bool` and an arbitrary alternative storage format, usually a string. -public protocol BooleanPropertyFormat { - associatedtype Value: Codable +public protocol BooleanPropertyFormat: Sendable { + associatedtype Value: Codable & Sendable init() @@ -27,10 +27,10 @@ extension BooleanPropertyFormat where Self == DefaultBooleanPropertyFormat { /// Represent a `Bool` as any integer type. Any value other than `0` or `1` is considered invalid. /// -/// - Note: This format is primarily useful when the underlying database's native boolean format is -/// an integer of different width than the one that was used by the model - for example, a MySQL -/// model with a `BIGINT` field instead of the default `TINYINT`. -public struct IntegerBooleanPropertyFormat: BooleanPropertyFormat { +/// > Note: This format is primarily useful when the underlying database's native boolean format is +/// > an integer of different width than the one that was used by the model - for example, a MySQL +/// > model with a `BIGINT` field instead of the default `TINYINT`. +public struct IntegerBooleanPropertyFormat: BooleanPropertyFormat { public init() {} public func parse(_ value: T) -> Bool? { diff --git a/Sources/FluentKit/Properties/Children.swift b/Sources/FluentKit/Properties/Children.swift index e7ebac9e..56a7cc43 100644 --- a/Sources/FluentKit/Properties/Children.swift +++ b/Sources/FluentKit/Properties/Children.swift @@ -1,4 +1,5 @@ import NIOCore +import NIOConcurrencyHelpers extension Model { public typealias Children = ChildrenProperty @@ -8,14 +9,14 @@ extension Model { // MARK: Type @propertyWrapper -public final class ChildrenProperty +public final class ChildrenProperty: @unchecked Sendable where From: Model, To: Model { public typealias Key = RelationParentKey public let parentKey: Key var idValue: From.IDValue? - + public var value: [To]? public convenience init(for parent: KeyPath>) { @@ -102,7 +103,7 @@ extension ChildrenProperty: CustomStringConvertible { // MARK: Property -extension ChildrenProperty: AnyProperty { } +extension ChildrenProperty: AnyProperty {} extension ChildrenProperty: Property { public typealias Model = From @@ -220,8 +221,9 @@ private struct ChildrenEagerLoader: EagerLoader if (self.withDeleted) { builder.withDeleted() } + let models = UnsafeTransfer(wrappedValue: models) return builder.all().map { - for model in models { + for model in models.wrappedValue { let id = model[keyPath: self.relationKey].idValue! model[keyPath: self.relationKey].value = $0.filter { child in switch parentKey { diff --git a/Sources/FluentKit/Properties/CompositeChildren.swift b/Sources/FluentKit/Properties/CompositeChildren.swift index 94925c7e..1d62014d 100644 --- a/Sources/FluentKit/Properties/CompositeChildren.swift +++ b/Sources/FluentKit/Properties/CompositeChildren.swift @@ -1,4 +1,5 @@ import NIOCore +import NIOConcurrencyHelpers extension Model { /// A convenience alias for ``CompositeChildrenProperty``. It is strongly recommended that callers use this @@ -22,8 +23,8 @@ extension Model { /// /// Example: /// -/// - Note: This example is somewhat contrived; in reality, this kind of metadata would have much more -/// complex relationships. +/// > Note: This example is somewhat contrived; in reality, this kind of metadata would have much more +/// > complex relationships. /// /// ``` /// final class TableMetadata: Model { @@ -73,7 +74,7 @@ extension Model { /// } /// ``` @propertyWrapper -public final class CompositeChildrenProperty +public final class CompositeChildrenProperty: @unchecked Sendable where From: Model, To: Model, From.IDValue: Fields { public typealias Key = CompositeRelationParentKey @@ -200,10 +201,11 @@ private struct CompositeChildrenEagerLoader: EagerLoader _ = parentKey.queryFilterIds(ids, in: query) } + let models = UnsafeTransfer(wrappedValue: models) return builder.all().map { let indexedResults = Dictionary(grouping: $0, by: { parentKey.referencedId(in: $0)! }) - for model in models { + for model in models.wrappedValue { model[keyPath: self.relationKey].value = indexedResults[model[keyPath: self.relationKey].idValue!] ?? [] } } diff --git a/Sources/FluentKit/Properties/CompositeID.swift b/Sources/FluentKit/Properties/CompositeID.swift index cbd42bb6..28db8878 100644 --- a/Sources/FluentKit/Properties/CompositeID.swift +++ b/Sources/FluentKit/Properties/CompositeID.swift @@ -1,3 +1,5 @@ +import NIOConcurrencyHelpers + extension Model { public typealias CompositeID = CompositeIDProperty where Value: Fields @@ -6,11 +8,11 @@ extension Model { // MARK: Type @propertyWrapper @dynamicMemberLookup -public final class CompositeIDProperty +public final class CompositeIDProperty: @unchecked Sendable where Model: FluentKit.Model, Value: FluentKit.Fields { - public var value: Value? - public var exists: Bool + public var value: Value? = .init(.init()) + public var exists: Bool = false var cachedOutput: (any DatabaseOutput)? public var projectedValue: CompositeIDProperty { self } @@ -20,11 +22,7 @@ public final class CompositeIDProperty set { self.value = newValue } } - public init() { - self.value = .init() - self.exists = false - self.cachedOutput = nil - } + public init() {} public subscript( dynamicMember keyPath: KeyPath diff --git a/Sources/FluentKit/Properties/CompositeOptionalChild.swift b/Sources/FluentKit/Properties/CompositeOptionalChild.swift index bbd320e1..4e8cdebc 100644 --- a/Sources/FluentKit/Properties/CompositeOptionalChild.swift +++ b/Sources/FluentKit/Properties/CompositeOptionalChild.swift @@ -1,4 +1,5 @@ import NIOCore +import NIOConcurrencyHelpers extension Model { /// A convenience alias for ``CompositeOptionalChildProperty``. It is strongly recommended that callers use this @@ -22,8 +23,8 @@ extension Model { /// /// Example: /// -/// - Note: This example is somewhat contrived; in reality, this kind of metadata would have much more -/// complex relationships. +/// > Note: This example is somewhat contrived; in reality, this kind of metadata would have much more +/// > complex relationships. /// /// ``` /// final class TableMetadata: Model { @@ -58,7 +59,7 @@ extension Model { /// } /// ``` @propertyWrapper -public final class CompositeOptionalChildProperty +public final class CompositeOptionalChildProperty: @unchecked Sendable where From: Model, To: Model, From.IDValue: Fields { public typealias Key = CompositeRelationParentKey @@ -187,10 +188,11 @@ private struct CompositeOptionalChildEagerLoader: EagerLoader if (self.withDeleted) { builder.withDeleted() } + let models = UnsafeTransfer(wrappedValue: models) return builder.all().map { let indexedResults = Dictionary(grouping: $0, by: { parentKey.referencedId(in: $0)! }) - for model in models { + for model in models.wrappedValue { model[keyPath: self.relationKey].value = indexedResults[model[keyPath: self.relationKey].idValue!]?.first } } diff --git a/Sources/FluentKit/Properties/CompositeOptionalParent.swift b/Sources/FluentKit/Properties/CompositeOptionalParent.swift index e0b0f145..a20bf958 100644 --- a/Sources/FluentKit/Properties/CompositeOptionalParent.swift +++ b/Sources/FluentKit/Properties/CompositeOptionalParent.swift @@ -1,4 +1,6 @@ import NIOCore +import NIOConcurrencyHelpers +import struct SQLKit.SomeCodingKey extension Model { /// A convenience alias for ``CompositeOptionalParentProperty``. It is strongly recommended that callers @@ -20,8 +22,8 @@ extension Model { /// /// Example: /// -/// - Note: This example is somewhat contrived; in reality, this kind of metadata would have much more -/// complex relationships. +/// > Note: This example is somewhat contrived; in reality, this kind of metadata would have much more +/// > complex relationships. /// /// ``` /// final class TableMetadata: Model { @@ -69,7 +71,7 @@ extension Model { /// } /// ``` @propertyWrapper @dynamicMemberLookup -public final class CompositeOptionalParentProperty +public final class CompositeOptionalParentProperty: @unchecked Sendable where From: Model, To: Model, To.IDValue: Fields { public let prefix: FieldKey @@ -217,18 +219,20 @@ private struct CompositeOptionalParentEagerLoader: EagerLoader let withDeleted: Bool func run(models: [From], on database: any Database) -> EventLoopFuture { - var sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) - let nilParentModels = sets.removeValue(forKey: nil) ?? [] + var _sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) + let nilParentModels = UnsafeTransfer(wrappedValue: _sets.removeValue(forKey: nil) ?? []) + let sets = UnsafeTransfer(wrappedValue: _sets) let builder = To.query(on: database) - .group(.or) { _ = sets.keys.reduce($0) { query, id in query.group(.and) { id!.input(to: QueryFilterInput(builder: $0)) } } } + .group(.or) { _ = sets.wrappedValue.keys.reduce($0) { query, id in query.group(.and) { id!.input(to: QueryFilterInput(builder: $0)) } } } if (self.withDeleted) { builder.withDeleted() } + return builder.all().flatMapThrowing { let parents = Dictionary(uniqueKeysWithValues: $0.map { ($0.id!, $0) }) - for (parentId, models) in sets { + for (parentId, models) in sets.wrappedValue { guard let parent = parents[parentId!] else { database.logger.debug( "Missing parent model in eager-load lookup results.", @@ -238,7 +242,7 @@ private struct CompositeOptionalParentEagerLoader: EagerLoader } models.forEach { $0[keyPath: self.relationKey].value = .some(.some(parent)) } } - nilParentModels.forEach { $0[keyPath: self.relationKey].value = .some(.none) } + nilParentModels.wrappedValue.forEach { $0[keyPath: self.relationKey].value = .some(.none) } } } } diff --git a/Sources/FluentKit/Properties/CompositeParent.swift b/Sources/FluentKit/Properties/CompositeParent.swift index 646a7231..9c9b4d81 100644 --- a/Sources/FluentKit/Properties/CompositeParent.swift +++ b/Sources/FluentKit/Properties/CompositeParent.swift @@ -1,4 +1,6 @@ import NIOCore +import NIOConcurrencyHelpers +import struct SQLKit.SomeCodingKey extension Model { /// A convenience alias for ``CompositeParentProperty``. It is strongly recommended that callers use this @@ -20,8 +22,8 @@ extension Model { /// /// Example: /// -/// - Note: This example is somewhat contrived; in reality, this kind of metadata would have much more -/// complex relationships. +/// > Note: This example is somewhat contrived; in reality, this kind of metadata would have much more +/// > complex relationships. /// /// ``` /// final class TableMetadata: Model { @@ -64,12 +66,12 @@ extension Model { /// } /// ``` @propertyWrapper @dynamicMemberLookup -public final class CompositeParentProperty +public final class CompositeParentProperty: @unchecked Sendable where From: Model, To: Model, To.IDValue: Fields { public let prefix: FieldKey public let prefixingStrategy: KeyPrefixingStrategy - public var id: To.IDValue + public var id: To.IDValue = .init() public var value: To? public var wrappedValue: To { @@ -91,7 +93,6 @@ public final class CompositeParentProperty /// - strategy: The strategy to use when applying prefixes to keys. ``KeyPrefixingStrategy/snakeCase`` is /// the default. public init(prefix: FieldKey, strategy: KeyPrefixingStrategy = .snakeCase) { - self.id = .init() self.prefix = prefix self.prefixingStrategy = strategy } @@ -194,11 +195,11 @@ private struct CompositeParentEagerLoader: EagerLoader let withDeleted: Bool func run(models: [From], on database: any Database) -> EventLoopFuture { - let sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) + let sets = UnsafeTransfer(wrappedValue: Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id })) let builder = To.query(on: database) .group(.or) { - _ = sets.keys.reduce($0) { query, id in query.group(.and) { id.input(to: QueryFilterInput(builder: $0)) } } + _ = sets.wrappedValue.keys.reduce($0) { query, id in query.group(.and) { id.input(to: QueryFilterInput(builder: $0)) } } } if (self.withDeleted) { builder.withDeleted() @@ -207,7 +208,7 @@ private struct CompositeParentEagerLoader: EagerLoader .flatMapThrowing { let parents = Dictionary(uniqueKeysWithValues: $0.map { ($0.id!, $0) }) - for (parentId, models) in sets { + for (parentId, models) in sets.wrappedValue { guard let parent = parents[parentId] else { database.logger.debug( "Missing parent model in eager-load lookup results.", diff --git a/Sources/FluentKit/Properties/Field.swift b/Sources/FluentKit/Properties/Field.swift index a81cc6cd..9d523cd5 100644 --- a/Sources/FluentKit/Properties/Field.swift +++ b/Sources/FluentKit/Properties/Field.swift @@ -1,13 +1,15 @@ +import NIOConcurrencyHelpers + extension Fields { public typealias Field = FieldProperty - where Value: Codable + where Value: Codable & Sendable } // MARK: Type @propertyWrapper -public final class FieldProperty - where Model: FluentKit.Fields, Value: Codable +public final class FieldProperty: @unchecked Sendable + where Model: FluentKit.Fields, Value: Codable & Sendable { public let key: FieldKey var outputValue: Value? diff --git a/Sources/FluentKit/Properties/FieldKey.swift b/Sources/FluentKit/Properties/FieldKey.swift index d2549bc5..569c76a5 100644 --- a/Sources/FluentKit/Properties/FieldKey.swift +++ b/Sources/FluentKit/Properties/FieldKey.swift @@ -1,4 +1,4 @@ -public indirect enum FieldKey { +public indirect enum FieldKey: Sendable { case id case string(String) case aggregate diff --git a/Sources/FluentKit/Properties/Group.swift b/Sources/FluentKit/Properties/Group.swift index 8183c726..977c16f2 100644 --- a/Sources/FluentKit/Properties/Group.swift +++ b/Sources/FluentKit/Properties/Group.swift @@ -1,3 +1,5 @@ +import NIOConcurrencyHelpers + extension Fields { public typealias Group = GroupProperty where Value: Fields @@ -6,7 +8,7 @@ extension Fields { // MARK: Type @propertyWrapper @dynamicMemberLookup -public final class GroupProperty +public final class GroupProperty: @unchecked Sendable where Model: FluentKit.Fields, Value: FluentKit.Fields { public let key: FieldKey diff --git a/Sources/FluentKit/Properties/ID.swift b/Sources/FluentKit/Properties/ID.swift index 6f972956..b3e9624f 100644 --- a/Sources/FluentKit/Properties/ID.swift +++ b/Sources/FluentKit/Properties/ID.swift @@ -2,14 +2,14 @@ import Foundation extension Model { public typealias ID = IDProperty - where Value: Codable + where Value: Codable & Sendable } // MARK: Type @propertyWrapper -public final class IDProperty - where Model: FluentKit.Model, Value: Codable +public final class IDProperty: @unchecked Sendable + where Model: FluentKit.Model, Value: Codable & Sendable { public enum Generator { case user @@ -109,7 +109,7 @@ public final class IDProperty case .database: self.inputValue = .default case .random: - let generatable = Value.self as! any (RandomGeneratable & Encodable).Type + let generatable = Value.self as! any (RandomGeneratable & Encodable & Sendable).Type self.inputValue = .bind(generatable.generateRandom()) case .user: // do nothing diff --git a/Sources/FluentKit/Properties/OptionalBoolean.swift b/Sources/FluentKit/Properties/OptionalBoolean.swift index 512f8ffa..7c77be61 100644 --- a/Sources/FluentKit/Properties/OptionalBoolean.swift +++ b/Sources/FluentKit/Properties/OptionalBoolean.swift @@ -41,7 +41,7 @@ extension Fields { /// func revert(on database: Database) async throws -> Void { try await database.schema(MyModel.schema).delete() } /// } /// -/// - Note: See also ``BooleanProperty`` and ``BooleanPropertyFormat``. +/// > Note: See also ``BooleanProperty`` and ``BooleanPropertyFormat``. @propertyWrapper public final class OptionalBooleanProperty where Model: FluentKit.Fields, Format: BooleanPropertyFormat diff --git a/Sources/FluentKit/Properties/OptionalChild.swift b/Sources/FluentKit/Properties/OptionalChild.swift index 817eb6fc..372f8f05 100644 --- a/Sources/FluentKit/Properties/OptionalChild.swift +++ b/Sources/FluentKit/Properties/OptionalChild.swift @@ -1,4 +1,5 @@ import NIOCore +import NIOConcurrencyHelpers extension Model { public typealias OptionalChild = OptionalChildProperty @@ -8,7 +9,7 @@ extension Model { // MARK: Type @propertyWrapper -public final class OptionalChildProperty +public final class OptionalChildProperty: @unchecked Sendable where From: Model, To: Model { public typealias Key = RelationParentKey @@ -205,8 +206,9 @@ private struct OptionalChildEagerLoader: EagerLoader if (self.withDeleted) { builder.withDeleted() } + let models = UnsafeTransfer(wrappedValue: models) return builder.all().map { - for model in models { + for model in models.wrappedValue { let id = model[keyPath: self.relationKey].idValue! let children = $0.filter { child in switch parentKey { diff --git a/Sources/FluentKit/Properties/OptionalField.swift b/Sources/FluentKit/Properties/OptionalField.swift index 46d9c6ab..4b0c6b6a 100644 --- a/Sources/FluentKit/Properties/OptionalField.swift +++ b/Sources/FluentKit/Properties/OptionalField.swift @@ -1,13 +1,15 @@ +import NIOConcurrencyHelpers + extension Fields { public typealias OptionalField = OptionalFieldProperty - where Value: Codable + where Value: Codable & Sendable } // MARK: Type @propertyWrapper -public final class OptionalFieldProperty - where Model: FluentKit.Fields, WrappedValue: Codable +public final class OptionalFieldProperty: @unchecked Sendable + where Model: FluentKit.Fields, WrappedValue: Codable & Sendable { public let key: FieldKey var outputValue: WrappedValue?? diff --git a/Sources/FluentKit/Properties/OptionalParent.swift b/Sources/FluentKit/Properties/OptionalParent.swift index 554a3e1f..4dff02a4 100644 --- a/Sources/FluentKit/Properties/OptionalParent.swift +++ b/Sources/FluentKit/Properties/OptionalParent.swift @@ -1,4 +1,6 @@ import NIOCore +import NIOConcurrencyHelpers +import struct SQLKit.SomeCodingKey extension Model { public typealias OptionalParent = OptionalParentProperty @@ -8,7 +10,7 @@ extension Model { // MARK: Type @propertyWrapper -public final class OptionalParentProperty +public final class OptionalParentProperty: @unchecked Sendable where From: Model, To: Model { @OptionalFieldProperty @@ -170,23 +172,24 @@ private struct OptionalParentEagerLoader: EagerLoader let withDeleted: Bool func run(models: [From], on database: any Database) -> EventLoopFuture { - var sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) - let nilParentModels = sets.removeValue(forKey: nil) ?? [] + var _sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) + let nilParentModels = UnsafeTransfer(wrappedValue: _sets.removeValue(forKey: nil) ?? []) + let sets = UnsafeTransfer(wrappedValue: _sets) - if sets.isEmpty { + if sets.wrappedValue.isEmpty { // Fetching "To" objects is unnecessary when no models have an id for "To". - nilParentModels.forEach { $0[keyPath: self.relationKey].value = .some(.none) } + nilParentModels.wrappedValue.forEach { $0[keyPath: self.relationKey].value = .some(.none) } return database.eventLoop.makeSucceededVoidFuture() } - let builder = To.query(on: database).filter(\._$id ~~ Set(sets.keys.compactMap { $0 })) + let builder = To.query(on: database).filter(\._$id ~~ Set(sets.wrappedValue.keys.compactMap { $0 })) if (self.withDeleted) { builder.withDeleted() } return builder.all().flatMapThrowing { let parents = Dictionary(uniqueKeysWithValues: $0.map { ($0.id!, $0) }) - for (parentId, models) in sets { + for (parentId, models) in sets.wrappedValue { guard let parent = parents[parentId!] else { database.logger.debug( "Missing parent model in eager-load lookup results.", @@ -196,7 +199,7 @@ private struct OptionalParentEagerLoader: EagerLoader } models.forEach { $0[keyPath: self.relationKey].value = .some(.some(parent)) } } - nilParentModels.forEach { $0[keyPath: self.relationKey].value = .some(.none) } + nilParentModels.wrappedValue.forEach { $0[keyPath: self.relationKey].value = .some(.none) } } } } diff --git a/Sources/FluentKit/Properties/Parent.swift b/Sources/FluentKit/Properties/Parent.swift index b2d5b3ec..cb01cc95 100644 --- a/Sources/FluentKit/Properties/Parent.swift +++ b/Sources/FluentKit/Properties/Parent.swift @@ -1,4 +1,6 @@ import NIOCore +import NIOConcurrencyHelpers +import struct SQLKit.SomeCodingKey extension Model { public typealias Parent = ParentProperty @@ -8,7 +10,7 @@ extension Model { // MARK: Type @propertyWrapper -public final class ParentProperty +public final class ParentProperty: @unchecked Sendable where From: Model, To: Model { @FieldProperty @@ -164,15 +166,15 @@ private struct ParentEagerLoader: EagerLoader let withDeleted: Bool func run(models: [From], on database: any Database) -> EventLoopFuture { - let sets = Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id }) - let builder = To.query(on: database).filter(\._$id ~~ Set(sets.keys)) + let sets = UnsafeTransfer(wrappedValue: Dictionary(grouping: models, by: { $0[keyPath: self.relationKey].id })) + let builder = To.query(on: database).filter(\._$id ~~ Set(sets.wrappedValue.keys)) if (self.withDeleted) { builder.withDeleted() } return builder.all().flatMapThrowing { let parents = Dictionary(uniqueKeysWithValues: $0.map { ($0.id!, $0) }) - for (parentId, models) in sets { + for (parentId, models) in sets.wrappedValue { guard let parent = parents[parentId] else { database.logger.debug( "Missing parent model in eager-load lookup results.", diff --git a/Sources/FluentKit/Properties/Property.swift b/Sources/FluentKit/Properties/Property.swift index a87f00ef..41a8c614 100644 --- a/Sources/FluentKit/Properties/Property.swift +++ b/Sources/FluentKit/Properties/Property.swift @@ -17,7 +17,7 @@ public protocol AnyProperty: AnyObject { /// many-to-many relation. public protocol Property: AnyProperty { associatedtype Model: Fields - associatedtype Value: Codable + associatedtype Value: Codable & Sendable var value: Value? { get set } } @@ -40,12 +40,12 @@ extension AnyProperty where Self: Property { /// receives output from the results of read queries, provides input to write queries, /// and/or represents one or more model fields. /// -/// - Note: Most "database" properties participate in all three aspects (is/has fields, -/// provides input, receives output), but certain properties only participate in -/// receiving output (most notably the non-parent relation property types). Those -/// properties only behave in this manner because the ability to look up the needed -/// information on demand was not available in Swift until after the implementation was -/// effectively complete. They should not be considered actual "database" properties. +/// > Note: Most "database" properties participate in all three aspects (is/has fields, +/// > provides input, receives output), but certain properties only participate in +/// > receiving output (most notably the non-parent relation property types). Those +/// > properties only behave in this manner because the ability to look up the needed +/// > information on demand was not available in Swift until after the implementation was +/// > effectively complete. They should not be considered actual "database" properties. public protocol AnyDatabaseProperty: AnyProperty { var keys: [FieldKey] { get } func input(to input: any DatabaseInput) @@ -59,12 +59,12 @@ public protocol AnyDatabaseProperty: AnyProperty { /// which also wish to participate. Just about every property type is codable. /// /// > Warning: The various relation property types sometimes behave somewhat oddly -/// when encoded and/or decoded. +/// > when encoded and/or decoded. /// /// > TODO: When corresponding parent and child properties on their respective models -/// refer to each other, such as due to both relations being eager-loaded, both -/// encoding and decoding will crash due to infinite recursion. At some point, look -/// into a way to at least error out rather than crashing. +/// > refer to each other, such as due to both relations being eager-loaded, both +/// > encoding and decoding will crash due to infinite recursion. At some point, look +/// > into a way to at least error out rather than crashing. public protocol AnyCodableProperty: AnyProperty { /// Encode the property's data to an external representation. func encode(to encoder: any Encoder) throws @@ -115,12 +115,12 @@ public protocol QueryableProperty: AnyQueryableProperty, Property { /// version of ``AnyQueryableProperty/queryableValue()-3uzih``, except that this /// version will always have an input and thus can not return `nil`. /// - /// - Warning: The existence of this method implies that any two identically-typed - /// instances of a property _must_ encode their values into queries in exactly - /// the same fashion, and Fluent does have code paths which proceed on that - /// assumption. For example, this requirement is the primary reason that a - /// ``TimestampProperty``'s format is represented as a generic type parameter - /// rather than being provided to an initializer. + /// > Warning: The existence of this method implies that any two identically-typed + /// > instances of a property _must_ encode their values into queries in exactly + /// > the same fashion, and Fluent does have code paths which proceed on that + /// > assumption. For example, this requirement is the primary reason that a + /// > ``TimestampProperty``'s format is represented as a generic type parameter + /// > rather than being provided to an initializer. static func queryValue(_ value: Value) -> DatabaseQuery.Value } diff --git a/Sources/FluentKit/Properties/Relation.swift b/Sources/FluentKit/Properties/Relation.swift index 9a6e6e4e..68fb8362 100644 --- a/Sources/FluentKit/Properties/Relation.swift +++ b/Sources/FluentKit/Properties/Relation.swift @@ -3,9 +3,9 @@ import NIOCore /// A protocol which designates a conforming type as representing a database relation of any kind. Intended /// for use only by FluentKit property wrappers. /// -/// - Note: This protocol should probably require conformance to ``Property``, but adding that requirement -/// wouldn't have enough value to be worth having to hand-wave a technically semver-major change. -public protocol Relation { +/// > Note: This protocol should probably require conformance to ``Property``, but adding that requirement +/// > wouldn't have enough value to be worth having to hand-wave a technically semver-major change. +public protocol Relation: Sendable { associatedtype RelatedValue var name: String { get } var value: RelatedValue? { get set } @@ -17,8 +17,8 @@ extension Relation { /// /// If the value is loaded (including reloading), the value is set in the property before being returned. /// - /// - Note: This API is strongly preferred over ``Relation/load(on:)``, even when the caller does not need - /// the returned value, in order to minimize unnecessary database traffic. + /// > Note: This API is strongly preferred over ``Relation/load(on:)``, even when the caller does not need + /// > the returned value, in order to minimize unnecessary database traffic. /// /// - Parameters: /// - reload: If `true`, load the value from the database unconditionally, overwriting any previously @@ -44,7 +44,7 @@ extension Relation { /// /// This type was extracted from its original definitions as a subtype of the property types. A typealias is /// provided on the property types to maintain public API compatibility. -public enum RelationParentKey +public enum RelationParentKey: Sendable where From: FluentKit.Model, To: FluentKit.Model { case required(KeyPath>) @@ -67,9 +67,9 @@ extension RelationParentKey: CustomStringConvertible { /// purposes of ``CompositeChildrenProperty`` etc. makes it impractical to combine this and ``RelationParentKey`` /// in a single helper type. /// -/// - Note: This type is public partly to allow FluentKit users to introspect model metadata, but mostly it's -/// to maintain parity with ``RelationParentKey``, which was public in its original definition. -public enum CompositeRelationParentKey +/// > Note: This type is public partly to allow FluentKit users to introspect model metadata, but mostly it's +/// > to maintain parity with ``RelationParentKey``, which was public in its original definition. +public enum CompositeRelationParentKey: Sendable where From: FluentKit.Model, To: FluentKit.Model, From.IDValue: Fields { case required(KeyPath>) diff --git a/Sources/FluentKit/Properties/Siblings.swift b/Sources/FluentKit/Properties/Siblings.swift index 7bf8d8a6..e1c618ca 100644 --- a/Sources/FluentKit/Properties/Siblings.swift +++ b/Sources/FluentKit/Properties/Siblings.swift @@ -1,4 +1,5 @@ import NIOCore +import NIOConcurrencyHelpers extension Model { public typealias Siblings = SiblingsProperty @@ -8,7 +9,7 @@ extension Model { // MARK: Type @propertyWrapper -public final class SiblingsProperty +public final class SiblingsProperty: @unchecked Sendable where From: Model, To: Model, Through: Model { public enum AttachMethod { @@ -148,18 +149,19 @@ public final class SiblingsProperty _ to: To, method: AttachMethod, on database: any Database, - _ edit: @escaping (Through) -> () = { _ in } + _ edit: @escaping @Sendable (Through) -> () = { _ in } ) -> EventLoopFuture { switch method { case .always: return self.attach(to, on: database, edit) case .ifNotExists: - return self.isAttached(to: to, on: database).flatMap { alreadyAttached in + let to = UnsafeTransfer(wrappedValue: to) + return self.isAttached(to: to.wrappedValue, on: database).flatMap { alreadyAttached in if alreadyAttached { return database.eventLoop.makeSucceededFuture(()) } - return self.attach(to, on: database, edit) + return self.attach(to.wrappedValue, on: database, edit) } } } @@ -173,7 +175,7 @@ public final class SiblingsProperty public func attach( _ to: To, on database: any Database, - _ edit: (Through) -> () = { _ in } + _ edit: @Sendable (Through) -> () = { _ in } ) -> EventLoopFuture { guard let fromID = self.idValue else { return database.eventLoop.makeFailedFuture(SiblingsPropertyError.owningModelIdRequired(property: self.name)) @@ -388,15 +390,14 @@ private struct SiblingsEagerLoader: EagerLoader if (self.withDeleted) { builder.withDeleted() } - return builder.all() - .flatMapThrowing - { + let models = UnsafeTransfer(wrappedValue: models) + return builder.all().flatMapThrowing { var map: [From.IDValue: [To]] = [:] for to in $0 { let fromID = try to.joined(Through.self)[keyPath: from].id map[fromID, default: []].append(to) } - for model in models { + for model in models.wrappedValue { guard let id = model.id else { throw FluentError.idRequired } model[keyPath: self.relationKey].value = map[id] ?? [] } diff --git a/Sources/FluentKit/Properties/TimestampFormat.swift b/Sources/FluentKit/Properties/TimestampFormat.swift index 25643280..a32043bd 100644 --- a/Sources/FluentKit/Properties/TimestampFormat.swift +++ b/Sources/FluentKit/Properties/TimestampFormat.swift @@ -1,10 +1,11 @@ -import class NIO.ThreadSpecificVariable +import NIOConcurrencyHelpers +import class NIOPosix.ThreadSpecificVariable import Foundation // MARK: Format -public protocol TimestampFormat { - associatedtype Value: Codable +public protocol TimestampFormat: Sendable { + associatedtype Value: Codable & Sendable func parse(_ value: Value) -> Date? func serialize(_ date: Date) -> Value? @@ -52,32 +53,21 @@ extension TimestampFormatFactory { withMilliseconds: Bool ) -> TimestampFormatFactory { .init { - let formatter = ISO8601DateFormatter.threadSpecific - if withMilliseconds { - formatter.formatOptions.insert(.withFractionalSeconds) + ISO8601DateFormatter.shared.withLockedValue { + if withMilliseconds { + $0.formatOptions.insert(.withFractionalSeconds) + } + return ISO8601TimestampFormat(formatter: $0) } - return ISO8601TimestampFormat(formatter: formatter) } } } extension ISO8601DateFormatter { - private static var cache: ThreadSpecificVariable = .init() - - static var threadSpecific: ISO8601DateFormatter { - let formatter: ISO8601DateFormatter - if let existing = ISO8601DateFormatter.cache.currentValue { - formatter = existing - } else { - let new = ISO8601DateFormatter() - self.cache.currentValue = new - formatter = new - } - return formatter - } + fileprivate static let shared: NIOLockedValueBox = .init(.init()) } -public struct ISO8601TimestampFormat: TimestampFormat { +public struct ISO8601TimestampFormat: TimestampFormat, @unchecked Sendable { public typealias Value = String let formatter: ISO8601DateFormatter diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Aggregate.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Aggregate.swift index a1137ecd..98f11971 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Aggregate.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Aggregate.swift @@ -14,117 +14,117 @@ extension QueryBuilder { } public func count(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model + where Field: QueryableProperty, Field.Model == Model, Field.Value: Sendable { self.aggregate(.count, key, as: Int.self) } public func count(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { self.aggregate(.count, key, as: Int.self) } // TODO: `Field.Value` is not always the correct result type for `SUM()`, try `.aggregate(.sum, key, as: ...)` for now public func sum(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model + where Field: QueryableProperty, Field.Model == Model, Field.Value: Sendable { self.aggregate(.sum, key) } // TODO: `Field.Value` is not always the correct result type for `SUM()`, try `.aggregate(.sum, key, as: ...)` for now public func sum(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { self.aggregate(.sum, key) } // TODO: `Field.Value` is not always the correct result type for `SUM()`, try `.aggregate(.sum, key, as: ...)` for now public func sum(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, Field.Model == Model { self.aggregate(.sum, key) } // TODO: `Field.Value` is not always the correct result type for `SUM()`, try `.aggregate(.sum, key, as: ...)` for now public func sum(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, Field.Model == Model.IDValue { self.aggregate(.sum, key) } // TODO: `Field.Value` is not always the correct result type for `AVG()`, try `.aggregate(.average, key, as: ...)` for now public func average(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model + where Field: QueryableProperty, Field.Model == Model, Field.Value: Sendable { self.aggregate(.average, key) } // TODO: `Field.Value` is not always the correct result type for `AVG()`, try `.aggregate(.average, key, as: ...)` for now public func average(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { self.aggregate(.average, key) } // TODO: `Field.Value` is not always the correct result type for `AVG()`, try `.aggregate(.average, key, as: ...)` for now public func average(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, Field.Model == Model { self.aggregate(.average, key) } // TODO: `Field.Value` is not always the correct result type for `AVG()`, try `.aggregate(.average, key, as: ...)` for now public func average(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, Field.Model == Model.IDValue { self.aggregate(.average, key) } public func min(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model + where Field: QueryableProperty, Field.Model == Model, Field.Value: Sendable { self.aggregate(.minimum, key) } public func min(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { self.aggregate(.minimum, key) } public func min(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, Field.Model == Model { self.aggregate(.minimum, key) } public func min(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, Field.Model == Model.IDValue { self.aggregate(.minimum, key) } public func max(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model + where Field: QueryableProperty, Field.Model == Model, Field.Value: Sendable { self.aggregate(.maximum, key) } public func max(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Model == Model.IDValue, Field.Value: Sendable { self.aggregate(.maximum, key) } public func max(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, Field.Model == Model { self.aggregate(.maximum, key) } public func max(_ key: KeyPath) -> EventLoopFuture - where Field: QueryableProperty, Field.Value: OptionalType, Field.Model == Model.IDValue + where Field: QueryableProperty, Field.Value: OptionalType & Sendable, Field.Model == Model.IDValue { self.aggregate(.maximum, key) } @@ -134,7 +134,7 @@ extension QueryBuilder { _ field: KeyPath, as: Result.Type = Result.self ) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model, Result: Codable + where Field: QueryableProperty, Field.Model == Model, Result: Codable & Sendable { self.aggregate(method, Model.path(for: field), as: Result.self) } @@ -144,7 +144,7 @@ extension QueryBuilder { _ field: KeyPath, as: Result.Type = Result.self ) -> EventLoopFuture - where Field: QueryableProperty, Field.Model == Model.IDValue, Result: Codable + where Field: QueryableProperty, Field.Model == Model.IDValue, Result: Codable & Sendable { self.aggregate(method, Model.path(for: field), as: Result.self) } @@ -155,7 +155,7 @@ extension QueryBuilder { _ field: FieldKey, as: Result.Type = Result.self ) -> EventLoopFuture - where Result: Codable + where Result: Codable & Sendable { self.aggregate(method, [field], as: Result.self) } @@ -165,7 +165,7 @@ extension QueryBuilder { _ path: [FieldKey], as: Result.Type = Result.self ) -> EventLoopFuture - where Result: Codable + where Result: Codable & Sendable { self.aggregate( .field( @@ -180,7 +180,7 @@ extension QueryBuilder { _ aggregate: DatabaseQuery.Aggregate, as: Result.Type = Result.self ) -> EventLoopFuture - where Result: Codable + where Result: Codable & Sendable { let copy = self.copy() // Remove all eager load requests otherwise we try to diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Filter.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Filter.swift index 5e77893b..51af5c47 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Filter.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Filter.swift @@ -71,7 +71,7 @@ extension QueryBuilder { _ method: DatabaseQuery.Filter.Method, _ value: Value ) -> Self - where Value: Codable + where Value: Codable & Sendable { self.filter([fieldName], method, value) } @@ -82,7 +82,7 @@ extension QueryBuilder { _ method: DatabaseQuery.Filter.Method, _ value: Value ) -> Self - where Value: Codable + where Value: Codable & Sendable { self.filter( .extendedPath(fieldPath, schema: Model.schemaOrAlias, space: Model.spaceIfNotAliased), diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Join.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Join.swift index d8f50e01..8a3553c4 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Join.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Join.swift @@ -46,14 +46,14 @@ extension QueryBuilder { /// In debug builds, the join is checked (when possible) to verify that it corresponds correctly to the provided /// model type; an assertion failure occurs if there is a mismatch. This check is not performed in release builds. /// - /// - Warning: The space, schema, and alias specified by the join description _must_ match the `space`, `schema`, - /// and `alias` properties of the provided `Foreign` type. Violation of this rule will cause runtime errors in - /// most kinds of queries, and incorrect data may be returned from queries which do run. + /// > Warning: The space, schema, and alias specified by the join description _must_ match the `space`, `schema`, + /// > and `alias` properties of the provided `Foreign` type. Violation of this rule will cause runtime errors in + /// > most kinds of queries, and incorrect data may be returned from queries which do run. /// - /// - Tip: If you find that the requirements of your join are incompatible with this rule, you're probably trying - /// to do something that's too complex for Fluent's API to accomodate. The recommended solution is to bypass - /// Fluent and execute the desired query more directly, either via SQLKit when working with an SQL database, or - /// via MongoKitten if using MongoDB. + /// > Tip: If you find that the requirements of your join are incompatible with this rule, you're probably trying + /// > to do something that's too complex for Fluent's API to accomodate. The recommended solution is to bypass + /// > Fluent and execute the desired query more directly, either via SQLKit when working with an SQL database, or + /// > via MongoKitten if using MongoDB. @discardableResult public func join( _ foreign: Foreign.Type, diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder+Paginate.swift b/Sources/FluentKit/Query/Builder/QueryBuilder+Paginate.swift index 3a1a2324..3b504f7e 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder+Paginate.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder+Paginate.swift @@ -106,7 +106,7 @@ public struct PageMetadata: Codable { } /// Represents information needed to generate a `Page` from the full result set. -public struct PageRequest: Decodable { +public struct PageRequest: Decodable, Sendable { /// Page number to request. Starts at `1`. public let page: Int diff --git a/Sources/FluentKit/Query/Builder/QueryBuilder.swift b/Sources/FluentKit/Query/Builder/QueryBuilder.swift index 4d3ec652..c83dfb71 100644 --- a/Sources/FluentKit/Query/Builder/QueryBuilder.swift +++ b/Sources/FluentKit/Query/Builder/QueryBuilder.swift @@ -146,22 +146,37 @@ public final class QueryBuilder // MARK: Fetch - public func chunk(max: Int, closure: @escaping ([Result]) -> ()) -> EventLoopFuture { - var partial: [Result] = [] + public func chunk(max: Int, closure: @escaping @Sendable ([Result]) -> ()) -> EventLoopFuture { + #if swift(<5.10) + let partial: UnsafeMutableTransferBox<[Result]> = .init([]) + partial.wrappedValue.reserveCapacity(max) + return self.all { row in + partial.wrappedValue.append(row) + if partial.wrappedValue.count >= max { + closure(partial.wrappedValue) + partial.wrappedValue.removeAll(keepingCapacity: true) + } + }.flatMapThrowing { + if !partial.wrappedValue.isEmpty { + closure(partial.wrappedValue) + } + } + #else + nonisolated(unsafe) var partial: [Result, any Error>] = [] partial.reserveCapacity(max) + return self.all { row in - partial.append(row) + partial.append(row.map { .init(wrappedValue: $0) }) if partial.count >= max { - closure(partial) - partial = [] + closure(partial.map { $0.map { $0.wrappedValue } }) + partial.removeAll(keepingCapacity: true) } - }.flatMapThrowing { - // any stragglers + }.flatMapThrowing { if !partial.isEmpty { - closure(partial) - partial = [] + closure(partial.map { $0.map { $0.wrappedValue } }) } } + #endif } public func first() -> EventLoopFuture { @@ -203,41 +218,66 @@ public final class QueryBuilder } public func all() -> EventLoopFuture<[Model]> { - var models: [Result] = [] - return self.all { model in - models.append(model) - }.flatMapThrowing { - return try models - .map { try $0.get() } - } + #if swift(<5.10) + let models: UnsafeMutableTransferBox<[Result]> = .init([]) + + return self + .all { models.wrappedValue.append($0) } + .flatMapThrowing { try models.wrappedValue.map { try $0.get() } } + #else + nonisolated(unsafe) var models: [Result, any Error>] = [] + + return self + .all { models.append($0.map { .init(wrappedValue: $0) }) } + .flatMapThrowing { try models.map { try $0.get().wrappedValue } } + #endif } public func run() -> EventLoopFuture { - return self.run { _ in } + self.run { _ in } } - public func all(_ onOutput: @escaping (Result) -> ()) -> EventLoopFuture { - var all: [Model] = [] + #if swift(<5.10) + private final class AllWrapper: @unchecked Sendable { + var all: [UnsafeTransfer] = [] + var isEmpty: Bool { self.all.isEmpty } + func append(_ value: UnsafeTransfer) { self.all.append(value) } + } + #endif + + public func all(_ onOutput: @escaping @Sendable (Result) -> ()) -> EventLoopFuture { + #if swift(>=5.10) + nonisolated(unsafe) var all: [UnsafeTransfer] = [] + #else + let all: AllWrapper = .init() + #endif let done = self.run { output in onOutput(.init(catching: { let model = Model() try model.output(from: output.qualifiedSchema(space: Model.spaceIfNotAliased, Model.schemaOrAlias)) - all.append(model) + all.append(.init(wrappedValue: model)) return model })) } // if eager loads exist, run them, and update models if !self.eagerLoaders.isEmpty { - return done.flatMap { + let loaders = self.eagerLoaders + let db = self.database + + return done.flatMapWithEventLoop { // don't run eager loads if result set was empty guard !all.isEmpty else { - return self.database.eventLoop.makeSucceededFuture(()) + return $1.makeSucceededFuture(()) } // run eager loads - return self.eagerLoaders.sequencedFlatMapEach(on: self.database.eventLoop) { loader in - return loader.anyRun(models: all, on: self.database) + return loaders.sequencedFlatMapEach(on: $1) { loader in + #if swift(>=5.10) + loader.anyRun(models: all.map { $0.wrappedValue }, on: db) + #else + loader.anyRun(models: all.all.map { $0.wrappedValue }, on: db) + #endif } } } else { @@ -251,7 +291,7 @@ public final class QueryBuilder return self } - public func run(_ onOutput: @escaping (any DatabaseOutput) -> ()) -> EventLoopFuture { + public func run(_ onOutput: @escaping @Sendable (any DatabaseOutput) -> ()) -> EventLoopFuture { // make a copy of this query before mutating it // so that run can be called multiple times var query = self.query @@ -293,19 +333,15 @@ public final class QueryBuilder self.database.logger.debug("\(self.query)") self.database.history?.add(self.query) + let loop = self.database.eventLoop + let done = self.database.execute(query: query) { output in - assert( - self.database.eventLoop.inEventLoop, - "database driver output was not on eventloop" - ) + loop.assertInEventLoop() onOutput(output) } done.whenComplete { _ in - assert( - self.database.eventLoop.inEventLoop, - "database driver output was not on eventloop" - ) + loop.assertInEventLoop() } return done } @@ -328,3 +364,7 @@ public final class QueryBuilder query.input = data } } + +#if swift(<6) || !$InferSendableFromCaptures +extension Swift.KeyPath: @unchecked Sendable {} +#endif diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Action.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Action.swift index a6783762..fca77cfb 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Action.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Action.swift @@ -1,11 +1,11 @@ extension DatabaseQuery { - public enum Action { + public enum Action: Sendable { case create case read case update case delete case aggregate(Aggregate) - case custom(Any) + case custom(any Sendable) } } diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Aggregate.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Aggregate.swift index 2ef6ef8d..6015760b 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Aggregate.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Aggregate.swift @@ -1,15 +1,15 @@ extension DatabaseQuery { - public enum Aggregate { - public enum Method { + public enum Aggregate: Sendable { + public enum Method: Sendable { case count case sum case average case minimum case maximum - case custom(Any) + case custom(any Sendable) } case field(Field, Method) - case custom(Any) + case custom(any Sendable) } } diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Field.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Field.swift index d73b0c28..41de04bc 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Field.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Field.swift @@ -1,8 +1,8 @@ extension DatabaseQuery { - public enum Field { + public enum Field: Sendable { case path([FieldKey], schema: String) case extendedPath([FieldKey], schema: String, space: String?) - case custom(Any) + case custom(any Sendable) } } diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Filter.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Filter.swift index 0e267095..a447a0e5 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Filter.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Filter.swift @@ -1,6 +1,6 @@ extension DatabaseQuery { - public enum Filter { - public enum Method { + public enum Filter: Sendable { + public enum Method: Sendable { public static var equal: Method { return .equality(inverse: false) } @@ -34,7 +34,7 @@ extension DatabaseQuery { /// LHS exists in/doesn't exist in RHS case subset(inverse: Bool) - public enum Contains { + public enum Contains: Sendable { case prefix case suffix case anywhere @@ -44,19 +44,19 @@ extension DatabaseQuery { case contains(inverse: Bool, Contains) /// Custom method - case custom(Any) + case custom(any Sendable) } - public enum Relation { + public enum Relation: Sendable { case and case or - case custom(Any) + case custom(any Sendable) } case value(Field, Method, Value) case field(Field, Method, Field) case group([Filter], Relation) - case custom(Any) + case custom(any Sendable) } } diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Join.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Join.swift index 720f20ad..e6080626 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Join.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Join.swift @@ -1,9 +1,9 @@ extension DatabaseQuery { - public enum Join { - public enum Method { + public enum Join: Sendable { + public enum Method: Sendable { case inner case left - case custom(Any) + case custom(any Sendable) } case join( @@ -31,7 +31,7 @@ extension DatabaseQuery { filters: [Filter] ) - case custom(Any) + case custom(any Sendable) } } diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Range.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Range.swift index 48f852dc..df0ddcb2 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Range.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Range.swift @@ -1,12 +1,12 @@ extension DatabaseQuery { - public enum Limit { + public enum Limit: Sendable { case count(Int) - case custom(Any) + case custom(any Sendable) } - public enum Offset { + public enum Offset: Sendable { case count(Int) - case custom(Any) + case custom(any Sendable) } } diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Sort.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Sort.swift index 424bb291..467fbeb9 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Sort.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Sort.swift @@ -1,12 +1,12 @@ extension DatabaseQuery { - public enum Sort { - public enum Direction { + public enum Sort: Sendable { + public enum Direction: Sendable { case ascending case descending - case custom(Any) + case custom(any Sendable) } case sort(Field, Direction) - case custom(Any) + case custom(any Sendable) } } diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery+Value.swift b/Sources/FluentKit/Query/Database/DatabaseQuery+Value.swift index f3d0101a..5917f906 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery+Value.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery+Value.swift @@ -1,12 +1,12 @@ extension DatabaseQuery { - public enum Value { - case bind(any Encodable) + public enum Value: Sendable { + case bind(any Encodable & Sendable) case dictionary([FieldKey: Value]) case array([Value]) case null case enumCase(String) case `default` - case custom(Any) + case custom(any Sendable) } } diff --git a/Sources/FluentKit/Query/Database/DatabaseQuery.swift b/Sources/FluentKit/Query/Database/DatabaseQuery.swift index e956ae73..ecc24de0 100644 --- a/Sources/FluentKit/Query/Database/DatabaseQuery.swift +++ b/Sources/FluentKit/Query/Database/DatabaseQuery.swift @@ -1,4 +1,4 @@ -public struct DatabaseQuery { +public struct DatabaseQuery: Sendable { public var schema: String public var space: String? public var customIDKey: FieldKey? diff --git a/Sources/FluentKit/Query/QueryHistory.swift b/Sources/FluentKit/Query/QueryHistory.swift index 75a808df..8d03e591 100644 --- a/Sources/FluentKit/Query/QueryHistory.swift +++ b/Sources/FluentKit/Query/QueryHistory.swift @@ -1,22 +1,25 @@ -import struct NIOConcurrencyHelpers.NIOLock +import struct NIOConcurrencyHelpers.NIOLockedValueBox -/// Holds the history of queries for a database -public final class QueryHistory { - /// The queries that were executed over a period of time - public var queries: [DatabaseQuery] - - /// Protects - private var lock: NIOLock - - /// Create a new `QueryHistory` with no existing history - public init() { - self.queries = [] - self.lock = .init() +/// Holds the history of queries for a database. +public final class QueryHistory: @unchecked Sendable { + /// The underlying (locked) storage. + private let _queries: NIOLockedValueBox<[DatabaseQuery]> = .init([]) + + /// The queries that have been executed. + /// + /// > Warning: This array can be modified aribtrarily by any code with access to the ``QueryHistory`` + /// > object; there is no guarantee that it represents a consistent and accurate history. This is an + /// > accidental design flaw that can't be changed now without breaking the API. + public var queries: [DatabaseQuery] { + get { self._queries.withLockedValue { $0 } } + set { self._queries.withLockedValue { $0 = newValue } } } + /// Create a new ``QueryHistory`` with no existing history. + public init() {} + + /// Add a query to the history. func add(_ query: DatabaseQuery) { - self.lock.lock() - defer { self.lock.unlock() } - queries.append(query) + self._queries.withLockedValue { $0.append(query) } } } diff --git a/Sources/FluentKit/Schema/DatabaseSchema.swift b/Sources/FluentKit/Schema/DatabaseSchema.swift index 410538cd..e994435a 100644 --- a/Sources/FluentKit/Schema/DatabaseSchema.swift +++ b/Sources/FluentKit/Schema/DatabaseSchema.swift @@ -1,14 +1,14 @@ import struct Foundation.Date import struct Foundation.UUID -public struct DatabaseSchema { - public enum Action { +public struct DatabaseSchema: Sendable { + public enum Action: Sendable { case create case update case delete } - public indirect enum DataType { + public indirect enum DataType: Sendable { public static var int: DataType { return .int64 } @@ -28,7 +28,7 @@ public struct DatabaseSchema { case bool - public struct Enum { + public struct Enum: Sendable { public var name: String public var cases: [String] @@ -61,10 +61,10 @@ public struct DatabaseSchema { .array(of: nil) } case array(of: DataType?) - case custom(Any) + case custom(any Sendable) } - public enum FieldConstraint { + public enum FieldConstraint: Sendable { public static func references( _ schema: String, space: String? = nil, @@ -90,15 +90,15 @@ public struct DatabaseSchema { onDelete: ForeignKeyAction, onUpdate: ForeignKeyAction ) - case custom(Any) + case custom(any Sendable) } - public enum Constraint { + public enum Constraint: Sendable { case constraint(ConstraintAlgorithm, name: String?) - case custom(Any) + case custom(any Sendable) } - public enum ConstraintAlgorithm { + public enum ConstraintAlgorithm: Sendable { case unique(fields: [FieldName]) case foreignKey( _ fields: [FieldName], @@ -109,10 +109,10 @@ public struct DatabaseSchema { onUpdate: ForeignKeyAction ) case compositeIdentifier(_ fields: [FieldName]) - case custom(Any) + case custom(any Sendable) } - public enum ForeignKeyAction { + public enum ForeignKeyAction: Sendable { case noAction case restrict case cascade @@ -120,29 +120,29 @@ public struct DatabaseSchema { case setDefault } - public enum FieldDefinition { + public enum FieldDefinition: Sendable { case definition( name: FieldName, dataType: DataType, constraints: [FieldConstraint] ) - case custom(Any) + case custom(any Sendable) } - public enum FieldUpdate { + public enum FieldUpdate: Sendable { case dataType(name: FieldName, dataType: DataType) - case custom(Any) + case custom(any Sendable) } - public enum FieldName { + public enum FieldName: Sendable { case key(FieldKey) - case custom(Any) + case custom(any Sendable) } - public enum ConstraintDelete { + public enum ConstraintDelete: Sendable { case constraint(ConstraintAlgorithm) case name(String) - case custom(Any) + case custom(any Sendable) /// Deletion specifier for an explicitly-named constraint known to be a referential constraint. /// @@ -204,7 +204,7 @@ extension DatabaseSchema.ConstraintDelete { /// The use of `@_spi` will be replaced with the `package` modifier once a suitable minimum version /// of Swift becomes required. @_spi(FluentSQLSPI) - public/*package*/ struct _ForeignKeyByNameExtension { + public/*package*/ struct _ForeignKeyByNameExtension: Sendable { public/*package*/ let name: String } } diff --git a/Sources/FluentKit/Utilities/SomeCodingKey.swift b/Sources/FluentKit/Utilities/SomeCodingKey.swift index 3fb4183d..89fc816a 100644 --- a/Sources/FluentKit/Utilities/SomeCodingKey.swift +++ b/Sources/FluentKit/Utilities/SomeCodingKey.swift @@ -1,23 +1,6 @@ -/// An implementation of `CodingKey` intended to represent arbitrary string and integer coding keys. -/// -/// This structure is effectively an inverse complement of the `CodingKeyRepresentable` protocol. -/// -/// "_The standard library's version of a protocol whose requirements are trapped in an infinitely -/// recursive personal identity crisis._" - Unknown -public struct SomeCodingKey: CodingKey, Hashable { - public let stringValue: String - public let intValue: Int? - - public init(stringValue: String) { - self.stringValue = stringValue - self.intValue = Int(stringValue) - } +import SQLKit - public init(intValue: Int) { - self.stringValue = "\(intValue)" - self.intValue = intValue - } - +extension SQLKit.SomeCodingKey { public var description: String { "SomeCodingKey(\"\(self.stringValue)\"\(self.intValue.map { ", int: \($0)" } ?? ""))" } diff --git a/Sources/FluentKit/Utilities/UnsafeMutableTransferBox.swift b/Sources/FluentKit/Utilities/UnsafeMutableTransferBox.swift new file mode 100644 index 00000000..4c48c3ef --- /dev/null +++ b/Sources/FluentKit/Utilities/UnsafeMutableTransferBox.swift @@ -0,0 +1,14 @@ +struct UnsafeTransfer: @unchecked Sendable { + var wrappedValue: Wrapped +} + +@usableFromInline +final class UnsafeMutableTransferBox: @unchecked Sendable { + @usableFromInline + var wrappedValue: Wrapped + + @inlinable + init(_ wrappedValue: Wrapped) { + self.wrappedValue = wrappedValue + } +} diff --git a/Sources/FluentSQL/Utilities.swift b/Sources/FluentSQL/ConverterUtilities.swift similarity index 100% rename from Sources/FluentSQL/Utilities.swift rename to Sources/FluentSQL/ConverterUtilities.swift diff --git a/Sources/FluentSQL/DatabaseQuery+SQL.swift b/Sources/FluentSQL/DatabaseQuery+SQL.swift index 68641592..0731f4c3 100644 --- a/Sources/FluentSQL/DatabaseQuery+SQL.swift +++ b/Sources/FluentSQL/DatabaseQuery+SQL.swift @@ -41,7 +41,7 @@ extension DatabaseQuery.Filter { public static func sql( _ left: SQLIdentifier, _ op: SQLBinaryOperator, - _ right: any Encodable + _ right: any Encodable & Sendable ) -> Self { .sql(SQLBinaryExpression(left: left, op: op, right: SQLBind(right))) } @@ -93,7 +93,7 @@ extension DatabaseQuery.Sort { public static func sql( _ left: SQLIdentifier, _ op: SQLBinaryOperator, - _ right: any Encodable + _ right: any Encodable & Sendable ) -> Self { .sql(SQLBinaryExpression(left: left, op: op, right: SQLBind(right))) } diff --git a/Sources/FluentSQL/Docs.docc/Resources/vapor-fluentkit-logo.svg b/Sources/FluentSQL/Docs.docc/Resources/vapor-fluentkit-logo.svg new file mode 100644 index 00000000..3d468c81 --- /dev/null +++ b/Sources/FluentSQL/Docs.docc/Resources/vapor-fluentkit-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/FluentSQL/Docs.docc/index.md b/Sources/FluentSQL/Docs.docc/index.md index ed17331f..fc6fe99d 100644 --- a/Sources/FluentSQL/Docs.docc/index.md +++ b/Sources/FluentSQL/Docs.docc/index.md @@ -1,3 +1,5 @@ # ``FluentSQL`` -FluentSQL is a package to conform Fluent to SQLKit to allow queries to be mapped from Fluent to SQLKit. \ No newline at end of file +FluentSQL is a module containing the logic which maps FluentKit queries, schemata, models, and values to and from [SQLKit] types. + +[SQLKit]: https://api.vapor.codes/sqlkit/documentation/sqlkit diff --git a/Sources/FluentSQL/Docs.docc/theme-settings.json b/Sources/FluentSQL/Docs.docc/theme-settings.json new file mode 100644 index 00000000..ec916e9a --- /dev/null +++ b/Sources/FluentSQL/Docs.docc/theme-settings.json @@ -0,0 +1,21 @@ +{ + "theme": { + "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, + "border-radius": "0", + "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "color": { + "fluentkit": { "dark": "hsl(200, 75%, 85%)", "light": "hsl(200, 75%, 75%)" }, + "documentation-intro-fill": "radial-gradient(circle at top, var(--color-fluentkit) 30%, #000 100%)", + "documentation-intro-accent": "var(--color-fluentkit)", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "fill": { "dark": "#000", "light": "#fff" } + }, + "icons": { "technology": "/fluentkit/images/vapor-fluentkit-logo.svg" } + }, + "features": { + "quickNavigation": { "enable": true }, + "i18n": { "enable": true } + } +} diff --git a/Sources/FluentSQL/Exports.swift b/Sources/FluentSQL/Exports.swift index 220e0555..43748387 100644 --- a/Sources/FluentSQL/Exports.swift +++ b/Sources/FluentSQL/Exports.swift @@ -1,11 +1,2 @@ -#if swift(>=5.8) - @_documentation(visibility: internal) @_exported import FluentKit @_documentation(visibility: internal) @_exported import SQLKit - -#else - -@_exported import FluentKit -@_exported import SQLKit - -#endif diff --git a/Sources/FluentSQL/SQLDatabase+Model+Concurrency.swift b/Sources/FluentSQL/SQLDatabase+Model+Concurrency.swift index 5e26acb6..50dbfe96 100644 --- a/Sources/FluentSQL/SQLDatabase+Model+Concurrency.swift +++ b/Sources/FluentSQL/SQLDatabase+Model+Concurrency.swift @@ -3,21 +3,21 @@ import SQLKit import FluentKit extension SQLQueryFetcher { - public func first(decoding model: Model.Type) async throws -> Model? - where Model: FluentKit.Model - { - return try await self.all(decoding: Model.self).map { $0.first }.get() + public func first(decodingFluent model: Model.Type) async throws -> Model? { + try await self.first(decodingFluent: Model.self).get() + } + + @available(*, deprecated, renamed: "first(decodingFluent:)", message: "Renamed to first(decodingFluent:)") + public func first(decoding: Model.Type) async throws -> Model? { + try await self.first(decodingFluent: Model.self) } - public func all(decoding model: Model.Type) async throws -> [Model] - where Model: FluentKit.Model - { - return try await self.all().flatMapThrowing { rows in - try rows.map { row in - let model = Model() - try model.output(from: SQLDatabaseOutput(sql: row)) - return model - } - }.get() + public func all(decodingFluent: Model.Type) async throws -> [Model] { + try await self.all(decodingFluent: Model.self).get() + } + + @available(*, deprecated, renamed: "all(decodingFluent:)", message: "Renamed to all(decodingFluent:)") + public func all(decoding: Model.Type) async throws -> [Model] { + try await self.all(decodingFluent: Model.self) } } diff --git a/Sources/FluentSQL/SQLDatabase+Model.swift b/Sources/FluentSQL/SQLDatabase+Model.swift index 822da38b..3f28bd84 100644 --- a/Sources/FluentSQL/SQLDatabase+Model.swift +++ b/Sources/FluentSQL/SQLDatabase+Model.swift @@ -1,37 +1,40 @@ import SQLKit -import FluentKit +@_spi(FluentSQLSPI) import FluentKit extension SQLQueryFetcher { - public func first(decoding model: Model.Type) -> EventLoopFuture - where Model: FluentKit.Model - { - self.all(decoding: Model.self).map { $0.first } + @available(*, deprecated, renamed: "first(decodingFluent:)", message: "Renamed to first(decodingFluent:)") + public func first(decoding: Model.Type) -> EventLoopFuture { + self.first(decodingFluent: Model.self) + } + + public func first(decodingFluent: Model.Type) -> EventLoopFuture { + self.first().optionalFlatMapThrowing { row in try row.decode(fluentModel: Model.self) } } - public func all(decoding model: Model.Type) -> EventLoopFuture<[Model]> - where Model: FluentKit.Model - { - self.all().flatMapThrowing { rows in - try rows.map { row in - let model = Model() - try model.output(from: SQLDatabaseOutput(sql: row)) - return model - } - } + @available(*, deprecated, renamed: "all(decodingFluent:)", message: "Renamed to all(decodingFluent:)") + public func all(decoding: Model.Type) -> EventLoopFuture<[Model]> { + self.all(decodingFluent: Model.self) + } + + public func all(decodingFluent: Model.Type) -> EventLoopFuture<[Model]> { + self.all().flatMapEachThrowing { row in try row.decode(fluentModel: Model.self) } } } extension SQLRow { - public func decode(model: Model.Type) throws -> Model - where Model: FluentKit.Model - { + @available(*, deprecated, renamed: "decode(fluentModel:)", message: "Renamed to decode(fluentModel:)") + public func decode(model: Model.Type) throws -> Model { + try self.decode(fluentModel: Model.self) + } + + public func decode(fluentModel: Model.Type) throws -> Model { let model = Model() try model.output(from: SQLDatabaseOutput(sql: self)) return model } } -internal struct SQLDatabaseOutput: DatabaseOutput { +struct SQLDatabaseOutput: DatabaseOutput { let sql: any SQLRow var description: String { @@ -57,3 +60,77 @@ internal struct SQLDatabaseOutput: DatabaseOutput { } } +extension DatabaseQuery.Value { + /// This is pretty much exactly the same as what `SQLQueryConverter.value(_:)` does. The only obvious difference + /// is the `.dictionary()` case, which is never actually hit at runtime (it's not valid and ought to error out, + /// really, but why add more fatal errors than we have to?). + fileprivate var asSQLExpression: any SQLExpression { + switch self { + case .bind(let value): return SQLBind(value) + case .null: return SQLLiteral.null + case .array(let values): return SQLGroupExpression(SQLKit.SQLList(values.map(\.asSQLExpression), separator: SQLRaw(","))) + case .default: return SQLLiteral.default + case .enumCase(let str): return SQLLiteral.string(str) + case .custom(let any as any SQLExpression): + return any + case .custom(let any as any CustomStringConvertible): + return SQLRaw(any.description) + case .dictionary(_): fatalError("Dictionary database values are unimplemented for SQL") + case .custom(_): fatalError("Unsupported custom database value") + } + } +} + +extension Model { + fileprivate func encodeForSQL(withDefaultedValues: Bool) -> [(String, any SQLExpression)] { + self.collectInput(withDefaultedValues: withDefaultedValues).map { ($0.description, $1.asSQLExpression) } + } +} + +extension SQLInsertBuilder { + @discardableResult + public func fluentModel(_ model: Model) throws -> Self { + try self.fluentModels([model]) + } + + @discardableResult + public func fluentModels(_ models: [Model]) throws -> Self { + var validColumns: [String] = [] + + for model in models { + let pairs = model.encodeForSQL(withDefaultedValues: true) + + if validColumns.isEmpty { + validColumns = pairs.map(\.0) + self.columns(validColumns) + } else { + guard validColumns == pairs.map(\.0) else { + throw EncodingError.invalidValue(model, .init(codingPath: [], debugDescription: """ + One or more input Fluent models does not encode to the same set of columns. + """ + )) + } + } + self.values(pairs.map(\.1)) + } + return self + } +} + +extension SQLColumnUpdateBuilder { + @discardableResult + public func set( + fluentModel: Model + ) throws -> Self { + fluentModel.encodeForSQL(withDefaultedValues: false).reduce(self) { $0.set(SQLColumn($1.0), to: $1.1) } + } +} + +extension SQLConflictUpdateBuilder { + @discardableResult + public func set( + excludedContentOfFluentModel fluentModel: Model + ) throws -> Self { + fluentModel.encodeForSQL(withDefaultedValues: false).reduce(self) { $0.set(excludedValueOf: $1.0) } + } +} diff --git a/Sources/FluentSQL/SQLList+Deprecated.swift b/Sources/FluentSQL/SQLList+Deprecated.swift index 7eb5868a..92fab4c8 100644 --- a/Sources/FluentSQL/SQLList+Deprecated.swift +++ b/Sources/FluentSQL/SQLList+Deprecated.swift @@ -24,8 +24,8 @@ import SQLKit /// Original serialization: 1, 2, 3, 4, 5 /// Alternate serialization: 1 , 2 , 3 , 4 , 5 /// -/// - Warning: These extensions are not recommended, as it was never intended for this behavior to be -/// public. Convert code using these extensions to invoke the original ``SQLKit/SQLList`` directly. +/// > Warning: These extensions are not recommended, as it was never intended for this behavior to be +/// > public. Convert code using these extensions to invoke the original ``SQLKit/SQLList`` directly. extension SQLKit.SQLList { @available(*, deprecated, message: "Use `expressions` instead.") public var items: [any SQLExpression] { diff --git a/Sources/FluentSQL/SQLQueryConverter.swift b/Sources/FluentSQL/SQLQueryConverter.swift index 01eda1f4..3a741021 100644 --- a/Sources/FluentSQL/SQLQueryConverter.swift +++ b/Sources/FluentSQL/SQLQueryConverter.swift @@ -318,7 +318,6 @@ public struct SQLQueryConverter { } } - private func value(_ value: DatabaseQuery.Value) -> any SQLExpression { switch value { case .bind(let encodable): @@ -382,9 +381,9 @@ private struct EncodableDatabaseInput: Encodable { let input: [FieldKey: DatabaseQuery.Value] func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: FluentKit.SomeCodingKey.self) + var container = encoder.container(keyedBy: SomeCodingKey.self) for (key, value) in self.input { - try container.encode(EncodableDatabaseValue(value: value), forKey: FluentKit.SomeCodingKey(stringValue: key.description)) + try container.encode(EncodableDatabaseValue(value: value), forKey: SomeCodingKey(stringValue: key.description)) } } } @@ -392,14 +391,15 @@ private struct EncodableDatabaseInput: Encodable { private struct EncodableDatabaseValue: Encodable { let value: DatabaseQuery.Value func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self.value { case .bind(let encodable): - try encodable.encode(to: encoder) + try container.encode(encodable) case .null: - var container = encoder.singleValueContainer() try container.encodeNil() case .dictionary(let dictionary): - try EncodableDatabaseInput(input: dictionary).encode(to: encoder) + try container.encode(EncodableDatabaseInput(input: dictionary)) default: fatalError("Unsupported codable database value: \(self.value)") } diff --git a/Sources/FluentSQL/SQLSchemaConverter.swift b/Sources/FluentSQL/SQLSchemaConverter.swift index c143d6cd..633e20ed 100644 --- a/Sources/FluentSQL/SQLSchemaConverter.swift +++ b/Sources/FluentSQL/SQLSchemaConverter.swift @@ -280,7 +280,7 @@ public struct SQLSchemaConverter { /// SQL drop constraint expression with awareness of foreign keys (for MySQL's broken sake). /// -/// - Warning: This is only public for the benefit of `FluentBenchmarks`. DO NOT USE THIS TYPE! +/// > Warning: This is only public for the benefit of `FluentBenchmarks`. DO NOT USE THIS TYPE! public struct SQLDropTypedConstraint: SQLExpression { public let name: any SQLExpression public let algorithm: DatabaseSchema.ConstraintAlgorithm diff --git a/Sources/XCTFluent/Docs.docc/Resources/vapor-fluentkit-logo.svg b/Sources/XCTFluent/Docs.docc/Resources/vapor-fluentkit-logo.svg new file mode 100644 index 00000000..3d468c81 --- /dev/null +++ b/Sources/XCTFluent/Docs.docc/Resources/vapor-fluentkit-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/XCTFluent/Docs.docc/index.md b/Sources/XCTFluent/Docs.docc/index.md index 74be1f44..fc5eb90c 100644 --- a/Sources/XCTFluent/Docs.docc/index.md +++ b/Sources/XCTFluent/Docs.docc/index.md @@ -1,3 +1,3 @@ # ``XCTFluent`` -XCTFluent provides XCTest extensions to make it easy to write tests that use Vapor's FluentKit library. +XCTFluent provides XCTest extensions to make it easy to write tests using FluentKit. diff --git a/Sources/XCTFluent/Docs.docc/theme-settings.json b/Sources/XCTFluent/Docs.docc/theme-settings.json new file mode 100644 index 00000000..ec916e9a --- /dev/null +++ b/Sources/XCTFluent/Docs.docc/theme-settings.json @@ -0,0 +1,21 @@ +{ + "theme": { + "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, + "border-radius": "0", + "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "color": { + "fluentkit": { "dark": "hsl(200, 75%, 85%)", "light": "hsl(200, 75%, 75%)" }, + "documentation-intro-fill": "radial-gradient(circle at top, var(--color-fluentkit) 30%, #000 100%)", + "documentation-intro-accent": "var(--color-fluentkit)", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "fill": { "dark": "#000", "light": "#fff" } + }, + "icons": { "technology": "/fluentkit/images/vapor-fluentkit-logo.svg" } + }, + "features": { + "quickNavigation": { "enable": true }, + "i18n": { "enable": true } + } +} diff --git a/Sources/XCTFluent/DummyDatabase.swift b/Sources/XCTFluent/DummyDatabase.swift index fd2f435a..7df9190a 100644 --- a/Sources/XCTFluent/DummyDatabase.swift +++ b/Sources/XCTFluent/DummyDatabase.swift @@ -2,6 +2,7 @@ import FluentKit import Foundation import NIOEmbedded import NIOCore +import NIOConcurrencyHelpers public struct DummyDatabase: Database { public var context: DatabaseContext @@ -18,18 +19,18 @@ public struct DummyDatabase: Database { false } - public func execute(query: DatabaseQuery, onOutput: @escaping (any DatabaseOutput) -> ()) -> EventLoopFuture { + public func execute(query: DatabaseQuery, onOutput: @escaping @Sendable (any DatabaseOutput) -> ()) -> EventLoopFuture { for _ in 0..(_ closure: @escaping (any Database) -> EventLoopFuture) -> EventLoopFuture { + public func transaction(_ closure: @escaping @Sendable(any Database) -> EventLoopFuture) -> EventLoopFuture { closure(self) } - public func withConnection(_ closure: (any Database) -> EventLoopFuture) -> EventLoopFuture { + public func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { closure(self) } @@ -52,15 +53,15 @@ public struct DummyDatabaseConfiguration: DatabaseConfiguration { public final class DummyDatabaseDriver: DatabaseDriver { public let eventLoopGroup: any EventLoopGroup - var didShutdown: Bool + let didShutdown: NIOLockedValueBox public var fieldDecoder: any Decoder { - return DummyDecoder() + DummyDecoder() } public init(on eventLoopGroup: any EventLoopGroup) { self.eventLoopGroup = eventLoopGroup - self.didShutdown = false + self.didShutdown = .init(false) } public func makeDatabase(with context: DatabaseContext) -> any Database { @@ -68,10 +69,10 @@ public final class DummyDatabaseDriver: DatabaseDriver { } public func shutdown() { - self.didShutdown = true + self.didShutdown.withLockedValue { $0 = true } } deinit { - assert(self.didShutdown, "DummyDatabase did not shutdown before deinit.") + assert(self.didShutdown.withLockedValue { $0 }, "DummyDatabase did not shutdown before deinit.") } } @@ -105,50 +106,47 @@ public struct DummyRow: DatabaseOutput { } public func contains(_ key: FieldKey) -> Bool { - return true + true } public var description: String { - return "" + "" } } private struct DummyDecoder: Decoder { var codingPath: [any CodingKey] { - return [] + [] } var userInfo: [CodingUserInfoKey: Any] { - return [:] + [:] } init() { } - struct KeyedDecoder: KeyedDecodingContainerProtocol - where Key: CodingKey - { + struct KeyedDecoder: KeyedDecodingContainerProtocol { var codingPath: [any CodingKey] { - return [] + [] } + var allKeys: [Key] { - return [ - Key(stringValue: "test")! - ] + [Key(stringValue: "test")!] } - init() { } + init() {} func contains(_ key: Key) -> Bool { - return false + false } func decodeNil(forKey key: Key) throws -> Bool { - return false + false } - func decode(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable { + func decode(_ type: T.Type, forKey key: Key) throws -> T { if T.self is UUID.Type { return UUID() as! T } else { @@ -156,20 +154,20 @@ private struct DummyDecoder: Decoder { } } - func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer where NestedKey : CodingKey { - return KeyedDecodingContainer(KeyedDecoder()) + func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer { + .init(KeyedDecoder()) } func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer { - return UnkeyedDecoder() + UnkeyedDecoder() } func superDecoder() throws -> any Decoder { - return DummyDecoder() + DummyDecoder() } func superDecoder(forKey key: Key) throws -> any Decoder { - return DummyDecoder() + DummyDecoder() } } @@ -191,94 +189,94 @@ private struct DummyDecoder: Decoder { } mutating func decodeNil() throws -> Bool { - return true + true } mutating func decode(_ type: T.Type) throws -> T where T : Decodable { - return try T.init(from: DummyDecoder()) + try T.init(from: DummyDecoder()) } - mutating func nestedContainer(keyedBy type: NestedKey.Type) throws -> KeyedDecodingContainer where NestedKey : CodingKey { - return KeyedDecodingContainer(KeyedDecoder()) + mutating func nestedContainer(keyedBy type: NestedKey.Type) throws -> KeyedDecodingContainer { + KeyedDecodingContainer(KeyedDecoder()) } mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { - return UnkeyedDecoder() + UnkeyedDecoder() } mutating func superDecoder() throws -> any Decoder { - return DummyDecoder() + DummyDecoder() } } struct SingleValueDecoder: SingleValueDecodingContainer { var codingPath: [any CodingKey] { - return [] + [] } - init() { } + init() {} func decodeNil() -> Bool { - return false + false } func decode(_ type: Bool.Type) throws -> Bool { - return false + false } func decode(_ type: String.Type) throws -> String { - return "foo" + "foo" } func decode(_ type: Double.Type) throws -> Double { - return 3.14 + 3.14 } func decode(_ type: Float.Type) throws -> Float { - return 1.59 + 1.59 } func decode(_ type: Int.Type) throws -> Int { - return -42 + -42 } func decode(_ type: Int8.Type) throws -> Int8 { - return -8 + -8 } func decode(_ type: Int16.Type) throws -> Int16 { - return -16 + -16 } func decode(_ type: Int32.Type) throws -> Int32 { - return -32 + -32 } func decode(_ type: Int64.Type) throws -> Int64 { - return -64 + -64 } func decode(_ type: UInt.Type) throws -> UInt { - return 42 + 42 } func decode(_ type: UInt8.Type) throws -> UInt8 { - return 8 + 8 } func decode(_ type: UInt16.Type) throws -> UInt16 { - return 16 + 16 } func decode(_ type: UInt32.Type) throws -> UInt32 { - return 32 + 32 } func decode(_ type: UInt64.Type) throws -> UInt64 { - return 64 + 64 } - func decode(_ type: T.Type) throws -> T where T : Decodable { + func decode(_ type: T.Type) throws -> T { if T.self is UUID.Type { return UUID() as! T } else { @@ -287,15 +285,15 @@ private struct DummyDecoder: Decoder { } } - func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { - return .init(KeyedDecoder()) + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { + .init(KeyedDecoder()) } func unkeyedContainer() throws -> any UnkeyedDecodingContainer { - return UnkeyedDecoder() + UnkeyedDecoder() } func singleValueContainer() throws -> any SingleValueDecodingContainer { - return SingleValueDecoder() + SingleValueDecoder() } } diff --git a/Sources/XCTFluent/TestDatabase.swift b/Sources/XCTFluent/TestDatabase.swift index a84bce4a..07dfad71 100644 --- a/Sources/XCTFluent/TestDatabase.swift +++ b/Sources/XCTFluent/TestDatabase.swift @@ -2,6 +2,7 @@ import FluentKit import NIOEmbedded import Logging import NIOCore +import NIOConcurrencyHelpers /// Lets you mock the row results for each query. /// @@ -43,50 +44,52 @@ import NIOCore /// ]) /// public final class ArrayTestDatabase: TestDatabase { - var results: [[any DatabaseOutput]] + let results: NIOLockedValueBox<[[any DatabaseOutput]]> public init() { - self.results = [] + self.results = .init([]) } public func append(_ result: [any DatabaseOutput]) { - self.results.append(result) + self.results.withLockedValue { $0.append(result) } } public func append(_ result: [M]) where M: Model { - self.results.append(result.map { TestOutput($0) }) + self.results.withLockedValue { $0.append(result.map { TestOutput($0) }) } } - public func execute(query: DatabaseQuery, onOutput: (any DatabaseOutput) -> ()) throws { - guard !self.results.isEmpty else { + public func execute(query: DatabaseQuery, onOutput: @escaping @Sendable (any DatabaseOutput) -> ()) throws { + guard !self.results.withLockedValue({ $0.isEmpty }) else { throw TestDatabaseError.ranOutOfResults } - for output in self.results.removeFirst() { - onOutput(output) + self.results.withLockedValue { + for output in $0.removeFirst() { + onOutput(output) + } } } } public final class CallbackTestDatabase: TestDatabase { - var callback: (DatabaseQuery) -> [any DatabaseOutput] + let callback: @Sendable (DatabaseQuery) -> [any DatabaseOutput] - public init(callback: @escaping (DatabaseQuery) -> [any DatabaseOutput]) { + public init(callback: @escaping @Sendable (DatabaseQuery) -> [any DatabaseOutput]) { self.callback = callback } - public func execute(query: DatabaseQuery, onOutput: (any DatabaseOutput) -> ()) throws { + public func execute(query: DatabaseQuery, onOutput: @escaping @Sendable (any DatabaseOutput) -> ()) throws { for output in self.callback(query) { onOutput(output) } } } -public protocol TestDatabase { +public protocol TestDatabase: Sendable { func execute( query: DatabaseQuery, - onOutput: (any DatabaseOutput) -> () + onOutput: @escaping @Sendable (any DatabaseOutput) -> () ) throws } @@ -113,7 +116,7 @@ private struct _TestDatabase: Database { func execute( query: DatabaseQuery, - onOutput: @escaping (any DatabaseOutput) -> () + onOutput: @escaping @Sendable (any DatabaseOutput) -> () ) -> EventLoopFuture { guard context.eventLoop.inEventLoop else { return self.eventLoop.flatSubmit { @@ -128,11 +131,11 @@ private struct _TestDatabase: Database { return self.eventLoop.makeSucceededFuture(()) } - func transaction(_ closure: @escaping (any Database) -> EventLoopFuture) -> EventLoopFuture { + func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { closure(self) } - func withConnection(_ closure: (any Database) -> EventLoopFuture) -> EventLoopFuture { + func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { closure(self) } @@ -206,16 +209,16 @@ public struct TestOutput: DatabaseOutput { public var description: String { - return "" + "" } - var dummyDecodedFields: [FieldKey: Any] + var dummyDecodedFields: [FieldKey: any Sendable] public init() { self.dummyDecodedFields = [:] } - public init(_ mockFields: [FieldKey: Any]) { + public init(_ mockFields: [FieldKey: any Sendable]) { self.dummyDecodedFields = mockFields } @@ -248,16 +251,18 @@ public struct TestOutput: DatabaseOutput { ) } - public mutating func append(key: FieldKey, value: Any) { + public mutating func append(key: FieldKey, value: any Sendable) { dummyDecodedFields[key] = value } } private final class CollectInput: DatabaseInput { var storage: [FieldKey: DatabaseQuery.Value] + init() { self.storage = [:] } + func set(_ value: DatabaseQuery.Value, at key: FieldKey) { self.storage[key] = value } diff --git a/Tests/FluentKitTests/AsyncTests/AsyncFluentKitTests.swift b/Tests/FluentKitTests/AsyncTests/AsyncFluentKitTests.swift index ab1683b4..3fcf9199 100644 --- a/Tests/FluentKitTests/AsyncTests/AsyncFluentKitTests.swift +++ b/Tests/FluentKitTests/AsyncTests/AsyncFluentKitTests.swift @@ -117,12 +117,14 @@ final class AsyncFluentKitTests: XCTestCase { XCTAssertEqual(db.sqlSerializers.first?.sql.starts(with: #"SELECT DISTINCT "planets"."#), true) db.reset() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try await Planet.query(on: db).unique().count(\.$name) XCTAssertEqual(db.sqlSerializers.count, 1) XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT COUNT(DISTINCT "planets"."name") AS "aggregate" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() - _ = try await Planet.query(on: db).unique().sum(\.$id) + db.fakedRows.append([.init(["aggregate": 1])]) + _ = try await Planet.query(on: db).unique().aggregate(.sum, \.$id, as: Int.self) XCTAssertEqual(db.sqlSerializers.count, 1) XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT SUM(DISTINCT "planets"."id") AS "aggregate" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() @@ -131,7 +133,8 @@ final class AsyncFluentKitTests: XCTestCase { func testSQLSchemaCustomIndex() async throws { let db = DummyDatabaseForTestSQLSerializer() try await db.schema("foo").field(.custom("INDEX i_foo (foo)")).update() - print(db.sqlSerializers) + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"ALTER TABLE "foo" ADD INDEX i_foo (foo)"#) } func testRequiredFieldConstraint() async throws { @@ -265,6 +268,7 @@ final class AsyncFluentKitTests: XCTestCase { func testPlanet2FilterPlaceholder1() async throws { let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try await Planet2 .query(on: db) .filter(\.$nickName != "first") @@ -279,6 +283,7 @@ final class AsyncFluentKitTests: XCTestCase { func testPlanet2FilterPlaceholder2() async throws { let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try await Planet2 .query(on: db) .filter(\.$nickName != nil) @@ -293,6 +298,7 @@ final class AsyncFluentKitTests: XCTestCase { func testPlanet2FilterPlaceholder3() async throws { let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try await Planet2 .query(on: db) .filter(\.$nickName != "first") @@ -309,6 +315,7 @@ final class AsyncFluentKitTests: XCTestCase { func testPlanet2FilterPlaceholder4() async throws { let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try await Planet2 .query(on: db) .filter(\.$nickName != "first") @@ -324,7 +331,7 @@ final class AsyncFluentKitTests: XCTestCase { } func testDatabaseGeneratedIDOverride() async throws { - final class DGOFoo: Model { + final class DGOFoo: Model, @unchecked Sendable { static let schema = "foos" @ID(custom: .id) var id: Int? init() { } @@ -333,7 +340,6 @@ final class AsyncFluentKitTests: XCTestCase { } } - let test = CallbackTestDatabase { query in switch query.input[0] { case .dictionary(let input): @@ -357,11 +363,14 @@ final class AsyncFluentKitTests: XCTestCase { func testPaginationDoesNotCrashWithNegativeNumbers() async throws { let db = DummyDatabaseForTestSQLSerializer() + + db.fakedRows.append([.init(["aggregate": 1])]) let pageRequest1 = PageRequest(page: -1, per: 10) _ = try await Planet2 .query(on: db) .paginate(pageRequest1) + db.fakedRows.append([.init(["aggregate": 1])]) let pageRequest2 = PageRequest(page: 1, per: -10) _ = try await Planet2 .query(on: db) diff --git a/Tests/FluentKitTests/AsyncTests/AsyncQueryBuilderTests.swift b/Tests/FluentKitTests/AsyncTests/AsyncQueryBuilderTests.swift index 2b5d7600..f5b74c60 100644 --- a/Tests/FluentKitTests/AsyncTests/AsyncQueryBuilderTests.swift +++ b/Tests/FluentKitTests/AsyncTests/AsyncQueryBuilderTests.swift @@ -24,7 +24,7 @@ final class AsyncQueryBuilderTests: XCTestCase { let test = ArrayTestDatabase() test.append([ TestOutput([ - "id": planet.id as Any, + "id": planet.id as any Sendable, "name": planet.name, "star_id": UUID() ]) @@ -41,7 +41,7 @@ final class AsyncQueryBuilderTests: XCTestCase { let test = ArrayTestDatabase() test.append([ TestOutput([ - "id": planet.id as Any, + "id": planet.id as any Sendable, "name": planet.name, "star_id": UUID() ]), @@ -250,9 +250,14 @@ final class AsyncQueryBuilderTests: XCTestCase { // https://github.com/vapor/fluent-kit/issues/310 func testJoinOverloads() async throws { - var query: DatabaseQuery? + final class UnsafeMutableTransferBox: @unchecked Sendable { + var wrappedValue: Wrapped + init(_ wrappedValue: Wrapped) { self.wrappedValue = wrappedValue } + } + + let query = UnsafeMutableTransferBox(nil) let test = CallbackTestDatabase { - query = $0 + query.wrappedValue = $0 return [] } let planets = try await Planet.query(on: test.db) @@ -261,8 +266,8 @@ final class AsyncQueryBuilderTests: XCTestCase { .filter(Star.self, \.$name, .custom("ilike"), "sun") .all() XCTAssertEqual(planets.count, 0) - XCTAssertNotNil(query?.filters[1]) - switch query?.filters[1] { + XCTAssertNotNil(query.wrappedValue?.filters[1]) + switch query.wrappedValue?.filters[1] { case .value(let field, let method, let value): switch field { case .path(let path, let schema): diff --git a/Tests/FluentKitTests/CompositeIDTests.swift b/Tests/FluentKitTests/CompositeIDTests.swift index 7f42783b..ace4e0fb 100644 --- a/Tests/FluentKitTests/CompositeIDTests.swift +++ b/Tests/FluentKitTests/CompositeIDTests.swift @@ -97,15 +97,24 @@ final class CompositeIDTests: XCTestCase { planet.$tags.fromId = planet.id! let tag = Tag(id: .init(uuidString: "33333333-3333-3333-3333-333333333333")!, name: "Tag") + db.fakedRows.append([.init(["id": UUID()])]) _ = try model.$id.$planet.get(on: db).wait() + db.fakedRows.append([.init(["id": UUID()])]) _ = try planet.$planetTags.get(on: db).wait() + db.fakedRows.append([.init(["id": UUID()])]) _ = try planet.$tags.get(on: db).wait() + db.fakedRows.append([.init(["id": UUID()])]) try planet.$planetTags.create(model, on: db).wait() + db.fakedRows.append([.init(["id": UUID()])]) try planet.$tags.attach(tag, method: .always, on: db).wait() + db.fakedRows.append([.init(["aggregate": 1])]) try planet.$tags.attach(tag, method: .ifNotExists, on: db).wait() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try planet.$tags.isAttached(to: tag, on: db).wait() + db.fakedRows.append([.init(["id": UUID()])]) try planet.$tags.detach(tag, on: db).wait() + db.fakedRows.append([.init(["id": UUID()])]) try planet.$tags.detachAll(on: db).wait() XCTAssertEqual(db.sqlSerializers.count, 9) @@ -132,15 +141,19 @@ final class CompositeIDTests: XCTestCase { planet.$tags.fromId = planet.id! let tag = Tag(id: .init(uuidString: "33333333-3333-3333-3333-333333333333")!, name: "Tag") + db.fakedRows.append([.init(["id": UUID()])]) try await planet.$planetTags.create(model, on: db) + db.fakedRows.append([.init(["id": UUID()])]) try await planet.$tags.attach([tag], on: db) { pivot in _ = try await Planet.query(on: db).all() // just to make there be something async happening pivot.notation = "notation" } + db.fakedRows.append([.init(["id": UUID()])]) try await planet.$tags.attach(tag, on: db) { pivot in _ = try await Planet.query(on: db).all() // just to make there be something async happening pivot.notation = "notation" } + db.fakedRows.append(contentsOf: [[.init(["aggregate": 1])], [.init(["id": UUID()])]]) try await planet.$tags.attach(tag, method: .ifNotExists, on: db) { pivot in _ = try await Planet.query(on: db).all() // just to make there be something async happening pivot.notation = "notation" @@ -221,6 +234,8 @@ final class CompositeIDTests: XCTestCase { let moon3 = CompositeMoon(name: "D", planetSolarSystemId: sysId, planetNormalizedOrdinal: 1) let moon4 = CompositeMoon(name: "E", planetSolarSystemId: sysId, planetNormalizedOrdinal: 1, planetoidId: .init(solarSystemId: sysId, normalizedOrdinal: 3)) + db.fakedRows.append(contentsOf: [[.init(["id": UUID()])], [.init(["id": UUID()])], [.init(["id": UUID()])], [.init(["id": UUID()])], [.init(["id": UUID()])]]) + try planet1.create(on: db).wait() try [moon1, moon2, moon3, moon4].forEach { try $0.create(on: db).wait() } @@ -363,7 +378,7 @@ fileprivate func XCTAssertNilNil(_ expression: @autoclosure () throws -> Opti } } -final class PlanetUsingCompositePivot: Model { +final class PlanetUsingCompositePivot: Model, @unchecked Sendable { static let schema = Planet.schema @ID(key: .id) var id: UUID? @@ -381,10 +396,10 @@ final class PlanetUsingCompositePivot: Model { } } -final class CompositePlanetTag: Model { +final class CompositePlanetTag: Model, @unchecked Sendable { static let schema = "composite+planet+tag" - final class IDValue: Fields, Hashable { + final class IDValue: Fields, Hashable, @unchecked Sendable { @Parent(key: "planet_id") var planet: PlanetUsingCompositePivot @Parent(key: "tag_id") var tag: Tag @@ -424,7 +439,7 @@ struct CompositePlanetTagMigration: Migration { } } -final class SolarSystem: Model { +final class SolarSystem: Model, @unchecked Sendable { static let schema = "solar_system" @ID(key: .id) var id: UUID? @@ -439,11 +454,11 @@ final class SolarSystem: Model { } } -final class CompositePlanet: Model { +final class CompositePlanet: Model, @unchecked Sendable { static let schema = "composite+planet" // Note for the curious: "normalized ordinal" means "how many orbits from the center if a unique value was chosen for every planet despite overlapping or shared orbits" - final class IDValue: Fields, Hashable { + final class IDValue: Fields, Hashable, @unchecked Sendable { @Parent(key: "system_id") var solarSystem: SolarSystem @Field(key: "nrm_ord") var normalizedOrdinal: Int @@ -471,7 +486,7 @@ final class CompositePlanet: Model { } } -final class CompositeMoon: Model { +final class CompositeMoon: Model, @unchecked Sendable { static let schema = "composite+moon" @ID(key: .id) var id: UUID? diff --git a/Tests/FluentKitTests/DummyDatabaseForTestSQLSerializer.swift b/Tests/FluentKitTests/DummyDatabaseForTestSQLSerializer.swift index 2cf3d2f8..366e0c02 100644 --- a/Tests/FluentKitTests/DummyDatabaseForTestSQLSerializer.swift +++ b/Tests/FluentKitTests/DummyDatabaseForTestSQLSerializer.swift @@ -3,83 +3,114 @@ import FluentSQL import NIOEmbedded import SQLKit import XCTFluent +import NIOConcurrencyHelpers -public class DummyDatabaseForTestSQLSerializer: Database, SQLDatabase { - public var inTransaction: Bool { - false +struct FakedDatabaseRow: DatabaseOutput, SQLRow { + let data: [String: (any Sendable)?] + let schema: String? + + private func column(for key: FieldKey) -> String { "\(self.schema.map { "\($0)_" } ?? "")\(key.description)" } + func schema(_ schema: String) -> any DatabaseOutput { FakedDatabaseRow(self.data, schema: schema) } + func contains(_ key: FieldKey) -> Bool { self.contains(column: self.column(for: key)) } + func decodeNil(_ key: FieldKey) throws -> Bool { try self.decodeNil(column: self.column(for: key)) } + func decode(_ key: FieldKey, as: T.Type) throws -> T { try self.decode(column: self.column(for: key), as: T.self) } + + var allColumns: [String] { .init(self.data.keys) } + func contains(column: String) -> Bool { self.data.keys.contains(column) } + func decodeNil(column: String) throws -> Bool { self.data[column].map { $0 == nil } ?? true } + func decode(column c: String, as: D.Type) throws -> D { + guard case .some(.some(let v)) = self.data[c] else { throw DecodingError.keyNotFound(SomeCodingKey(stringValue: c), .init(codingPath: [], debugDescription: "")) } + guard let value = v as? D else { throw DecodingError.typeMismatch(D.self, .init(codingPath: [], debugDescription: "")) } + return value + } + + var description: String { "" } + + init(_ data: [String: (any Sendable)?], schema: String? = nil) { + self.data = data + self.schema = schema } +} - struct Configuration: DatabaseConfiguration { - func makeDriver(for databases: Databases) -> any DatabaseDriver { - fatalError() - } +final class DummyDatabaseForTestSQLSerializer: Database, SQLDatabase { + var inTransaction: Bool { false } - var middleware: [any AnyModelMiddleware] - init() { - self.middleware = [] - } + struct Configuration: DatabaseConfiguration { + func makeDriver(for databases: Databases) -> any DatabaseDriver { fatalError() } + var middleware: [any AnyModelMiddleware] = [] } - public var dialect: any SQLDialect { - DummyDatabaseDialect() - } + var dialect: any SQLDialect { DummyDatabaseDialect() } - public let context: DatabaseContext - public var sqlSerializers: [SQLSerializer] + let context: DatabaseContext - public init() { + let _sqlSerializers = NIOLockedValueBox<[SQLSerializer]>([]) + var sqlSerializers: [SQLSerializer] { + get { self._sqlSerializers.withLockedValue { $0 } } + set { self._sqlSerializers.withLockedValue { $0 = newValue } } + } + + let _fakedRows = NIOLockedValueBox<[[FakedDatabaseRow]]>([]) + var fakedRows: [[FakedDatabaseRow]] { + get { self._fakedRows.withLockedValue { $0 } } + set { self._fakedRows.withLockedValue { $0 = newValue } } + } + + init() { self.context = .init( configuration: Configuration(), logger: .init(label: "test"), eventLoop: EmbeddedEventLoop() ) - self.sqlSerializers = [] } - public func reset() { + func reset() { self.sqlSerializers = [] } - public func execute( + func execute( query: DatabaseQuery, - onOutput: @escaping (any DatabaseOutput) -> () + onOutput: @escaping @Sendable (any DatabaseOutput) -> () ) -> EventLoopFuture { - var sqlSerializer = SQLSerializer(database: self) let sqlExpression = SQLQueryConverter(delegate: DummyDatabaseConverterDelegate()).convert(query) - sqlExpression.serialize(to: &sqlSerializer) - self.sqlSerializers.append(sqlSerializer) - onOutput(DummyRow()) - return self.eventLoop.makeSucceededFuture(()) + + return self.execute(sql: sqlExpression, { row in onOutput(row as! any DatabaseOutput) }) } - public func execute(sql query: any SQLExpression, _ onRow: @escaping (any SQLRow) -> ()) -> EventLoopFuture { - fatalError() + func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> ()) -> EventLoopFuture { + var sqlSerializer = SQLSerializer(database: self) + query.serialize(to: &sqlSerializer) + self._sqlSerializers.withLockedValue { $0.append(sqlSerializer) } + if !self.fakedRows.isEmpty { + for row in self._fakedRows.withLockedValue({ $0.removeFirst() }) { + onRow(row) + } + } + return self.eventLoop.makeSucceededVoidFuture() } - public func transaction(_ closure: @escaping (any Database) -> EventLoopFuture) -> EventLoopFuture { + func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { closure(self) } - public func execute(schema: DatabaseSchema) -> EventLoopFuture { - var sqlSerializer = SQLSerializer(database: self) + func execute(schema: DatabaseSchema) -> EventLoopFuture { let sqlExpression = SQLSchemaConverter(delegate: DummyDatabaseConverterDelegate()).convert(schema) - sqlExpression.serialize(to: &sqlSerializer) - self.sqlSerializers.append(sqlSerializer) - return self.eventLoop.makeSucceededFuture(()) + + return self.execute(sql: sqlExpression, { _ in }) } - public func execute(enum: DatabaseEnum) -> EventLoopFuture { + func execute(enum: DatabaseEnum) -> EventLoopFuture { // do nothing - return self.eventLoop.makeSucceededFuture(()) + self.eventLoop.makeSucceededVoidFuture() } - public func withConnection( - _ closure: @escaping (any Database) -> EventLoopFuture + func withConnection( + _ closure: @escaping @Sendable (any Database) -> EventLoopFuture ) -> EventLoopFuture { closure(self) } - public func shutdown() { + func shutdown() { // } } @@ -99,28 +130,23 @@ struct DummyDatabaseDialect: SQLDialect { } var identifierQuote: any SQLExpression { - return SQLRaw("\"") + SQLRaw("\"") } var literalStringQuote: any SQLExpression { - return SQLRaw("'") + SQLRaw("'") } func bindPlaceholder(at position: Int) -> any SQLExpression { - return SQLRaw("$" + position.description) + SQLRaw("$" + position.description) } func literalBoolean(_ value: Bool) -> any SQLExpression { - switch value { - case false: - return SQLRaw("false") - case true: - return SQLRaw("true") - } + SQLRaw(value ? "true" : "false") } var autoIncrementClause: any SQLExpression { - return SQLRaw("GENERATED BY DEFAULT AS IDENTITY") + SQLRaw("GENERATED BY DEFAULT AS IDENTITY") } var sharedSelectLockExpression: (any SQLExpression)? { @@ -150,6 +176,6 @@ struct DummyDatabaseConverterDelegate: SQLConverterDelegate { } func nestedFieldExpression(_ column: String, _ path: [String]) -> any SQLExpression { - return SQLRaw("\(column)->>'\(path[0])'") + SQLRaw("\(column)->>'\(path[0])'") } } diff --git a/Tests/FluentKitTests/FilterQueryTests.swift b/Tests/FluentKitTests/FilterQueryTests.swift index 5b01cd74..5296dfb9 100644 --- a/Tests/FluentKitTests/FilterQueryTests.swift +++ b/Tests/FluentKitTests/FilterQueryTests.swift @@ -11,109 +11,68 @@ import XCTest import Foundation import FluentSQL -final class FilterQueryTests: XCTestCase { - override class func setUp() { - super.setUp() - XCTAssertTrue(isLoggingConfigured) - } - +final class FilterQueryTests: DbQueryTestCase { // MARK: Enum func test_enumEquals() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$status == .done).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" = 'done'"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$status == .done).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" = 'done'"#) } func test_enumNotEquals() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$status != .done).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" <> 'done'"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$status != .done).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" <> 'done'"#) } func test_enumIn() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$status ~~ [.done, .notDone]).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" IN ('done','notDone')"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$status ~~ [.done, .notDone]).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" IN ('done','notDone')"#) } func test_enumNotIn() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$status !~ [.done, .notDone]).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" NOT IN ('done','notDone')"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$status !~ [.done, .notDone]).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."status" NOT IN ('done','notDone')"#) } // MARK: OptionalEnum func test_optionalEnumEquals() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$optionalStatus == .done).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" = 'done'"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$optionalStatus == .done).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" = 'done'"#) } func test_optionalEnumNotEquals() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$optionalStatus != .done).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" <> 'done'"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$optionalStatus != .done).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" <> 'done'"#) } func test_optionalEnumIn() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$optionalStatus ~~ [.done, .notDone]).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" IN ('done','notDone')"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$optionalStatus ~~ [.done, .notDone]).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" IN ('done','notDone')"#) } func test_optionalEnumNotIn() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$optionalStatus !~ [.done, .notDone]).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" NOT IN ('done','notDone')"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$optionalStatus !~ [.done, .notDone]).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."optional_status" NOT IN ('done','notDone')"#) } // MARK: String func test_stringEquals() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$description == "hello").all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" = $1"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$description == "hello").all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" = $1"#) } func test_stringNotEquals() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$description != "hello").all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" <> $1"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$description != "hello").all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" <> $1"#) } func test_stringIn() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$description ~~ ["hello"]).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" IN ($1)"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$description ~~ ["hello"]).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" IN ($1)"#) } func test_stringNotIn() throws { - let db = DummyDatabaseForTestSQLSerializer() - _ = try Task.query(on: db).filter(\.$description !~ ["hello"]).all().wait() - XCTAssertEqual(db.sqlSerializers.count, 1) - XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" NOT IN ($1)"#) - db.reset() + _ = try Task.query(on: self.db).filter(\.$description !~ ["hello"]).all().wait() + assertQuery(self.db, #"SELECT "tasks"."id" AS "tasks_id", "tasks"."description" AS "tasks_description", "tasks"."status" AS "tasks_status", "tasks"."optional_status" AS "tasks_optional_status" FROM "tasks" WHERE "tasks"."description" NOT IN ($1)"#) } } @@ -121,7 +80,7 @@ enum Diggity: String, Codable { case done, notDone } -final class Task: Model { +final class Task: Model, @unchecked Sendable { static let schema = "tasks" @ID(custom: "id", generatedBy: .user) diff --git a/Tests/FluentKitTests/FluentKitTests.swift b/Tests/FluentKitTests/FluentKitTests.swift index 2087d7ee..ac401f17 100644 --- a/Tests/FluentKitTests/FluentKitTests.swift +++ b/Tests/FluentKitTests/FluentKitTests.swift @@ -15,16 +15,16 @@ final class FluentKitTests: XCTestCase { /// This test is a deliberate code smell put in place to prevent an even worse one from /// causing problems without at least some warning. Specifically, the output of - /// ``AnyModel//description`` is rather precise when it comes to labeling the input and + /// ``AnyModel/description`` is rather precise when it comes to labeling the input and /// output dictionaries when they are present. Non-trivial effort was made to produce this /// exact textual format. While it is never correct to rely on the output of a - /// ``description`` method (aside special cases like ``LosslessStringConvertible`` types), + /// `description` method (aside special cases like `LosslessStringConvertible` types), /// this has been public API for ages; [Hyrum's Law](https://www.hyrumslaw.com) thus applies. /// Since no part of Fluent or any of its drivers currently relies, or ever will rely, on /// the format in question, it is desirable to enforce that it should never change, just in /// case someone actually is relying on it for some hopefully very good reason. func testAnyModelDescriptionFormatHasNotChanged() throws { - final class Foo: Model { + final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) var id: UUID? @Field(key: "name") var name: String @@ -35,7 +35,9 @@ final class FluentKitTests: XCTestCase { let modelEmptyDesc = model.description (model.name, model.num) = ("Test", 42) let modelInputDesc = model.description - try model.save(on: DummyDatabaseForTestSQLSerializer()).wait() + let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["id": UUID()])]) + try model.save(on: db).wait() let modelOutputDesc = model.description model.num += 1 let modelBothDesc = model.description @@ -174,11 +176,13 @@ final class FluentKitTests: XCTestCase { XCTAssertEqual(db.sqlSerializers.first?.sql.starts(with: #"SELECT DISTINCT "planets"."#), true) db.reset() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try? Planet.query(on: db).unique().count(\.$name).wait() XCTAssertEqual(db.sqlSerializers.count, 1) XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT COUNT(DISTINCT "planets"."name") AS "aggregate" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) db.reset() + db.fakedRows.append([.init(["aggregate": Int?(1)])]) _ = try? Planet.query(on: db).unique().sum(\.$id).wait() XCTAssertEqual(db.sqlSerializers.count, 1) XCTAssertEqual(db.sqlSerializers.first?.sql, #"SELECT SUM(DISTINCT "planets"."id") AS "aggregate" FROM "planets" WHERE ("planets"."deleted_at" IS NULL OR "planets"."deleted_at" > $1)"#) @@ -188,7 +192,8 @@ final class FluentKitTests: XCTestCase { func testSQLSchemaCustomIndex() throws { let db = DummyDatabaseForTestSQLSerializer() try db.schema("foo").field(.custom("INDEX i_foo (foo)")).update().wait() - print(db.sqlSerializers) + XCTAssertEqual(db.sqlSerializers.count, 1) + XCTAssertEqual(db.sqlSerializers.first?.sql, #"ALTER TABLE "foo" ADD INDEX i_foo (foo)"#) } func testRequiredFieldConstraint() throws { @@ -365,11 +370,12 @@ final class FluentKitTests: XCTestCase { let json = """ {"name": "Earth"} """ - do { - _ = try JSONDecoder().decode(Planet2.self, from: Data(json.utf8)) - XCTFail("should have thrown") - } catch { - print(error) + + XCTAssertThrowsError(try JSONDecoder().decode(Planet2.self, from: Data(json.utf8))) { + guard case .typeMismatch(let type, _) = $0 as? DecodingError else { + return XCTFail("Expected DecodingError.typeMismatch but got \(String(reflecting: $0))") + } + XCTAssert(type is Int.Type) } } @@ -406,22 +412,6 @@ final class FluentKitTests: XCTestCase { ) ) - // GroupProperty - let a = tanner.$pet - print(a) - - // GroupedProperty> - let b = tanner.$pet.$toy - print(b) - - // GroupedProperty>> - let c = tanner.$pet.$toy.$foo - print(c) - - // GroupedProperty>>> - let d = tanner.$pet.$toy.$foo.$bar - print(d) - XCTAssertEqual(tanner.pet.name, "Ziz") XCTAssertEqual(tanner.$pet.$name.value, "Ziz") XCTAssertEqual(User.path(for: \.$pet.$toy.$foo.$bar).map { $0.description }, ["pet_toy_foo_bar"]) @@ -438,6 +428,7 @@ final class FluentKitTests: XCTestCase { func testPlanet2FilterPlaceholder1() throws { let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try Planet2 .query(on: db) .filter(\.$nickName != "first") @@ -452,6 +443,7 @@ final class FluentKitTests: XCTestCase { func testPlanet2FilterPlaceholder2() throws { let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try Planet2 .query(on: db) .filter(\.$nickName != nil) @@ -466,6 +458,7 @@ final class FluentKitTests: XCTestCase { func testPlanet2FilterPlaceholder3() throws { let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try Planet2 .query(on: db) .filter(\.$nickName != "first") @@ -482,6 +475,7 @@ final class FluentKitTests: XCTestCase { func testPlanet2FilterPlaceholder4() throws { let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["aggregate": 1])]) _ = try Planet2 .query(on: db) .filter(\.$nickName != "first") @@ -509,7 +503,7 @@ final class FluentKitTests: XCTestCase { enum Bar: String, Codable, Equatable { case baz } - final class EFoo: Model { + final class EFoo: Model, @unchecked Sendable { static let schema = "foos" @ID var id: UUID? @Enum(key: "bar") var bar: Bar @@ -537,7 +531,7 @@ final class FluentKitTests: XCTestCase { enum Bar: String, Codable, Equatable { case baz } - final class OEFoo: Model { + final class OEFoo: Model, @unchecked Sendable { static let schema = "foos" @ID var id: UUID? @OptionalEnum(key: "bar") var bar: Bar? @@ -563,8 +557,11 @@ final class FluentKitTests: XCTestCase { func testOptionalParentCoding() throws { let db = DummyDatabaseForTestSQLSerializer() + db.fakedRows.append([.init(["id": 1])]) let prefoo = PreFoo(boo: true); try prefoo.create(on: db).wait() + db.fakedRows.append([.init(["id": 2])]) let foo1 = AtFoo(preFoo: prefoo); try foo1.create(on: db).wait() + db.fakedRows.append([.init(["id": 3])]) let foo2 = AtFoo(preFoo: nil); try foo2.create(on: db).wait() prefoo.$foos.fromId = prefoo.id//; prefoo.$foos.value = [] @@ -618,14 +615,14 @@ final class FluentKitTests: XCTestCase { } func testGroupCoding() throws { - final class GroupedFoo: Fields { + final class GroupedFoo: Fields, @unchecked Sendable { @Field(key: "hello") var string: String init() {} } - final class GroupFoo: Model { + final class GroupFoo: Model, @unchecked Sendable { static let schema = "group_foos" @ID(key: .id) var id: UUID? @@ -659,7 +656,7 @@ final class FluentKitTests: XCTestCase { } func testDatabaseGeneratedIDOverride() throws { - final class DGOFoo: Model { + final class DGOFoo: Model, @unchecked Sendable { static let schema = "foos" @ID(custom: .id) var id: Int? init() { } @@ -701,12 +698,14 @@ final class FluentKitTests: XCTestCase { func testPaginationDoesntCrashWithNegativeNumbers() throws { let db = DummyDatabaseForTestSQLSerializer() let pageRequest1 = PageRequest(page: -1, per: 10) + db.fakedRows.append([.init(["aggregate": 1])]) XCTAssertNoThrow(try Planet2 .query(on: db) .paginate(pageRequest1) .wait()) let pageRequest2 = PageRequest(page: 1, per: -10) + db.fakedRows.append([.init(["aggregate": 1])]) XCTAssertNoThrow(try Planet2 .query(on: db) .paginate(pageRequest2) @@ -726,6 +725,7 @@ final class FluentKitTests: XCTestCase { .create() .wait() _ = try AltPlanet.query(on: db).filter(\.$name == "Earth").all().wait() + db.fakedRows.append([.init(["id": UUID()])]) try AltPlanet(name: "Nemesis").create(on: db).wait() let updateMe = AltPlanet(id: UUID(), name: "Vulcan") updateMe.$id.exists = true @@ -766,7 +766,7 @@ final class FluentKitTests: XCTestCase { } func testCreatingModelArraysWithUnsetOptionalProperties() throws { - final class Foo: Model { + final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID var id: UUID? @@ -798,7 +798,7 @@ final class FluentKitTests: XCTestCase { } } -final class User: Model { +final class User: Model, @unchecked Sendable { static let schema = "users" @ID var id: UUID? @@ -825,7 +825,7 @@ enum Animal: String, Codable { case cat, dog } -final class Pet: Fields { +final class Pet: Fields, @unchecked Sendable { @Field(key: "name") var name: String @@ -848,7 +848,7 @@ enum ToyType: String, Codable { case mouse, bone } -final class Toy: Fields { +final class Toy: Fields, @unchecked Sendable { @Field(key: "name") var name: String @@ -867,7 +867,7 @@ final class Toy: Fields { } } -final class ToyFoo: Fields { +final class ToyFoo: Fields, @unchecked Sendable { @Field(key: "bar") var bar: Int @@ -882,7 +882,7 @@ final class ToyFoo: Fields { } } -final class Planet2: Model { +final class Planet2: Model, @unchecked Sendable { static let schema = "planets" @ID(custom: "id", generatedBy: .database) @@ -906,7 +906,7 @@ final class Planet2: Model { } } -final class AltPlanet: Model { +final class AltPlanet: Model, @unchecked Sendable { public static let space: String? = "mirror_universe" public static let schema = "planets" @@ -945,7 +945,7 @@ final class AltPlanet: Model { } } -final class LotsOfFields: Model { +final class LotsOfFields: Model, @unchecked Sendable { static let schema = "never_used" @ID(custom: "id") @@ -1012,7 +1012,7 @@ final class LotsOfFields: Model { var field20: String } -final class AtFoo: Model { +final class AtFoo: Model, @unchecked Sendable { static let schema = "foos" @ID(custom: .id) var id: Int? @@ -1022,7 +1022,7 @@ final class AtFoo: Model { init(id: Int? = nil, preFoo: PreFoo?) { self.id = id; self.$preFoo.id = preFoo?.id; self.$preFoo.value = preFoo } } -final class PostFoo: Model { +final class PostFoo: Model, @unchecked Sendable { static let schema = "postfoos" @ID(custom: .id) var id: Int? @@ -1031,7 +1031,7 @@ final class PostFoo: Model { init(id: Int? = nil) { self.id = id } } -final class PreFoo: Model { +final class PreFoo: Model, @unchecked Sendable { static let schema = "prefoos" @ID(custom: .id) var id: Int? @@ -1045,10 +1045,10 @@ final class PreFoo: Model { init(id: Int? = nil, boo: Bool) { self.id = id; self.boo = boo } } -final class MidFoo: Model { +final class MidFoo: Model, @unchecked Sendable { static let schema = "midfoos" - final class IDValue: Fields, Hashable { + final class IDValue: Fields, Hashable, @unchecked Sendable { @Parent(key: "prefoo_id") var prefoo: PreFoo @Parent(key: "postfoo_id") var postfoo: PostFoo diff --git a/Tests/FluentKitTests/OptionalEnumQueryTests.swift b/Tests/FluentKitTests/OptionalEnumQueryTests.swift index 38396d9a..75a00b6b 100644 --- a/Tests/FluentKitTests/OptionalEnumQueryTests.swift +++ b/Tests/FluentKitTests/OptionalEnumQueryTests.swift @@ -4,23 +4,21 @@ import Foundation import XCTest final class OptionalEnumQueryTests: DbQueryTestCase { - override class func setUp() { - super.setUp() - XCTAssertTrue(isLoggingConfigured) - } - func testInsertNonNull() throws { + db.fakedRows.append([.init(["id": UUID()])]) _ = try Thing(id: 1, fb: .fizz).create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, 'fizz')"#) } func testInsertNull() throws { + db.fakedRows.append([.init(["id": UUID()])]) _ = try Thing(id: 1, fb: nil).create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, NULL)"#) } func testBulkUpdateDoesntOverkill() throws { let thing = Thing(id: 1, fb: .buzz) + db.fakedRows.append([.init(["id": UUID()])]) try thing.create(on: db).wait() try Thing.query(on: db).filter(\.$id != thing.id!).set(\.$id, to: 99).update().wait() assertLastQuery(db, #"UPDATE "things" SET "id" = $1 WHERE "things"."id" <> $2"#) @@ -29,17 +27,20 @@ final class OptionalEnumQueryTests: DbQueryTestCase { func testInsertAfterMutatingNullableField() throws { let thing = Thing(id: 1, fb: nil) thing.fb = .fizz + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing.create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, 'fizz')"#) let thing2 = Thing(id: 1, fb: .buzz) thing2.fb = nil + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing2.create(on: db).wait() assertLastQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, NULL)"#) } func testSaveReplacingNonNull() throws { let thing = Thing(id: 1, fb: .fizz) + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing.create(on: db).wait() thing.fb = .buzz _ = try thing.save(on: db).wait() @@ -48,6 +49,7 @@ final class OptionalEnumQueryTests: DbQueryTestCase { func testSaveReplacingNull() throws { let thing = Thing(id: 1, fb: nil) + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing.create(on: db).wait() thing.fb = .fizz _ = try thing.save(on: db).wait() @@ -57,6 +59,7 @@ final class OptionalEnumQueryTests: DbQueryTestCase { // @see https://github.com/vapor/fluent-kit/issues/444 func testSaveNullReplacingNonNull() throws { let thing = Thing(id: 1, fb: .fizz) + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing.create(on: db).wait() thing.fb = nil _ = try thing.save(on: db).wait() @@ -66,12 +69,14 @@ final class OptionalEnumQueryTests: DbQueryTestCase { func testBulkInsertWithoutNulls() throws { let things = [Thing(id: 1, fb: .fizz), Thing(id: 2, fb: .buzz)] + db.fakedRows.append([.init(["id": UUID()])]) _ = try things.create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, 'fizz'), ($2, 'buzz')"#) } func testBulkInsertWithOnlyNulls() throws { let things = [Thing(id: 1, fb: nil), Thing(id: 2, fb: nil)] + db.fakedRows.append([.init(["id": UUID()])]) _ = try things.create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, NULL), ($2, NULL)"#) } @@ -79,16 +84,18 @@ final class OptionalEnumQueryTests: DbQueryTestCase { // @see https://github.com/vapor/fluent-kit/issues/396 func testBulkInsertWithMixedNulls() throws { let things = [Thing(id: 1, fb: nil), Thing(id: 2, fb: .fizz)] + db.fakedRows.append([.init(["id": UUID()])]) _ = try things.create(on: db).wait() assertLastQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, NULL), ($2, 'fizz')"#) let things2 = [Thing(id: 3, fb: .fizz), Thing(id: 4, fb: nil)] + db.fakedRows.append([.init(["id": UUID()])]) _ = try things2.create(on: db).wait() assertLastQuery(db, #"INSERT INTO "things" ("id", "fb") VALUES ($1, 'fizz'), ($2, NULL)"#) } } -private final class Thing: Model { +private final class Thing: Model, @unchecked Sendable { enum FizzBuzz: String, Codable { case fizz case buzz diff --git a/Tests/FluentKitTests/OptionalFieldQueryTests.swift b/Tests/FluentKitTests/OptionalFieldQueryTests.swift index cd8c399c..66647876 100644 --- a/Tests/FluentKitTests/OptionalFieldQueryTests.swift +++ b/Tests/FluentKitTests/OptionalFieldQueryTests.swift @@ -4,17 +4,14 @@ import Foundation import XCTest final class OptionalFieldQueryTests: DbQueryTestCase { - override class func setUp() { - super.setUp() - XCTAssertTrue(isLoggingConfigured) - } - func testInsertNonNull() throws { + db.fakedRows.append([.init(["id": UUID()])]) _ = try Thing(id: 1, name: "Jared").create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2)"#) } func testInsertNull() throws { + db.fakedRows.append([.init(["id": UUID()])]) _ = try Thing(id: 1, name: nil).create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, NULL)"#) } @@ -22,6 +19,7 @@ final class OptionalFieldQueryTests: DbQueryTestCase { func testInsertAfterMutatingNullableField() throws { let thing = Thing(id: 1, name: nil) thing.name = "Jared" + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing.create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2)"#) @@ -29,12 +27,14 @@ final class OptionalFieldQueryTests: DbQueryTestCase { let thing2 = Thing(id: 1, name: "Jared") thing2.name = nil + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing2.create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, NULL)"#) } func testSaveReplacingNonNull() throws { let thing = Thing(id: 1, name: "Jared") + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing.create(on: db).wait() thing.name = "Bob" _ = try thing.save(on: db).wait() @@ -43,6 +43,7 @@ final class OptionalFieldQueryTests: DbQueryTestCase { func testSaveReplacingNull() throws { let thing = Thing(id: 1, name: nil) + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing.create(on: db).wait() thing.name = "Bob" _ = try thing.save(on: db).wait() @@ -51,6 +52,7 @@ final class OptionalFieldQueryTests: DbQueryTestCase { func testSaveNullReplacingNonNull() throws { let thing = Thing(id: 1, name: "Jared") + db.fakedRows.append([.init(["id": UUID()])]) _ = try thing.create(on: db).wait() thing.name = nil _ = try thing.save(on: db).wait() @@ -59,24 +61,27 @@ final class OptionalFieldQueryTests: DbQueryTestCase { func testBulkInsertWithoutNulls() throws { let things = [Thing(id: 1, name: "Jared"), Thing(id: 2, name: "Bob")] + db.fakedRows.append([.init(["id": UUID()])]) _ = try things.create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2), ($3, $4)"#) } func testBulkInsertWithOnlyNulls() throws { let things = [Thing(id: 1, name: nil), Thing(id: 2, name: nil)] + db.fakedRows.append([.init(["id": UUID()])]) _ = try things.create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, NULL), ($2, NULL)"#) } func testBulkInsertWithMixedNulls() throws { let things = [Thing(id: 1, name: "Jared"), Thing(id: 2, name: nil)] + db.fakedRows.append([.init(["id": UUID()])]) _ = try things.create(on: db).wait() assertQuery(db, #"INSERT INTO "things" ("id", "name") VALUES ($1, $2), ($3, NULL)"#) } } -private final class Thing: Model { +private final class Thing: Model, @unchecked Sendable { static let schema = "things" @ID(custom: "id", generatedBy: .user) diff --git a/Tests/FluentKitTests/QueryBuilderTests.swift b/Tests/FluentKitTests/QueryBuilderTests.swift index 238a8cf1..16e395c5 100644 --- a/Tests/FluentKitTests/QueryBuilderTests.swift +++ b/Tests/FluentKitTests/QueryBuilderTests.swift @@ -24,7 +24,7 @@ final class QueryBuilderTests: XCTestCase { let test = ArrayTestDatabase() test.append([ TestOutput([ - "id": planet.id as Any, + "id": planet.id as any Sendable, "name": planet.name, "star_id": UUID() ]) @@ -41,7 +41,7 @@ final class QueryBuilderTests: XCTestCase { let test = ArrayTestDatabase() test.append([ TestOutput([ - "id": planet.id as Any, + "id": planet.id as any Sendable, "name": planet.name, "star_id": UUID() ]), @@ -142,9 +142,14 @@ final class QueryBuilderTests: XCTestCase { // https://github.com/vapor/fluent-kit/issues/310 func testJoinOverloads() throws { - var query: DatabaseQuery? + final class UnsafeMutableTransferBox: @unchecked Sendable { + var wrappedValue: Wrapped + init(_ wrappedValue: Wrapped) { self.wrappedValue = wrappedValue } + } + + let query = UnsafeMutableTransferBox(nil) let test = CallbackTestDatabase { - query = $0 + query.wrappedValue = $0 return [] } let planets = try Planet.query(on: test.db) @@ -153,8 +158,8 @@ final class QueryBuilderTests: XCTestCase { .filter(Star.self, \.$name, .custom("ilike"), "sun") .all().wait() XCTAssertEqual(planets.count, 0) - XCTAssertNotNil(query?.filters[1]) - switch query?.filters[1] { + XCTAssertNotNil(query.wrappedValue?.filters[1]) + switch query.wrappedValue?.filters[1] { case .value(let field, let method, let value): switch field { case .path(let path, let schema): diff --git a/Tests/FluentKitTests/SQLTests.swift b/Tests/FluentKitTests/SQLTests.swift new file mode 100644 index 00000000..681b83f4 --- /dev/null +++ b/Tests/FluentKitTests/SQLTests.swift @@ -0,0 +1,254 @@ +import FluentKit +import FluentSQL +import SQLKit +import XCTest +import XCTFluent + +final class SQLTests: DbQueryTestCase { + func testFetchFluentModels() async throws { + let date1 = Date(), date2 = Date(), uuid1 = UUID(), uuid2 = UUID() + + self.db.fakedRows.append(contentsOf: [ + [ + .init([ + "id": 1, "field": "a", "optfield": 0, "bool": true, "optbool": false, + "created_at": date1, "enum": Enum1.foo.rawValue, "optenum": Enum1.bar.rawValue, + "group_groupfield1": 32 as Int32, "group_groupfield2": 64 as Int64, + ]), + .init([ + "id": 2, "field": "c", "optfield": nil, "bool": false, "optbool": nil, + "created_at": date2, "enum": Enum1.bar.rawValue, "optenum": nil, + "group_groupfield1": 64 as Int32, "group_groupfield2": 32 as Int64, + ]), + ], + [ + .init(["id": 1, "field": Data([0, 1, 2, 3]), "model1_id": 1, "othermodel1_id": 2]), + .init(["id": 2, "field": Data([4, 5, 6, 7]), "model1_id": 2, "othermodel1_id": nil]), + ], + [ + .init(["model1_id": 1, "model2_id": 1]), + ], + [ + .init(["field1": 1.0, "field2": uuid1, "pivot_model1_id": 1, "pivot_model2_id": 1, "optpivot_model1_id": 2, "optpivot_model2_id": 2]), + .init(["field1": 2.0, "field2": uuid2, "pivot_model1_id": 2, "pivot_model2_id": 2, "optpivot_model1_id": nil, "optpivot_model2_id": nil]), + ], + ]) + + let model1s = try await self.db.select().columns("*").from(Model1.schema).all(decodingFluent: Model1.self) + let model2s = try await self.db.select().columns("*").from(Model2.schema).all(decodingFluent: Model2.self) + let pivots = try await self.db.select().columns("*").from(Pivot.schema).all(decodingFluent: Pivot.self) + let fromPivots = try await self.db.select().columns("*").from(FromPivot.schema).all(decodingFluent: FromPivot.self) + + XCTAssertEqual(model1s.count, 2) + XCTAssertEqual(model2s.count, 2) + XCTAssertEqual(pivots.count, 1) + XCTAssertEqual(fromPivots.count, 2) + + let model1_1 = try XCTUnwrap(model1s.dropFirst(0).first) + let model1_2 = try XCTUnwrap(model1s.dropFirst(1).first) + let model2_1 = try XCTUnwrap(model2s.dropFirst(0).first) + let model2_2 = try XCTUnwrap(model2s.dropFirst(1).first) + let pivot = try XCTUnwrap(pivots.first) + let fromPivot1 = try XCTUnwrap(fromPivots.dropFirst(0).first) + let fromPivot2 = try XCTUnwrap(fromPivots.dropFirst(1).first) + + XCTAssertEqual(model1_1.$id.value, 1) + XCTAssertEqual(model1_1.$field.value, "a") + XCTAssertEqual(model1_1.$optField.value, .some(.some(0))) + XCTAssertEqual(model1_1.$bool.value, true) + XCTAssertEqual(model1_1.$optBool.value, .some(.some(false))) + XCTAssertEqual(model1_1.$createdAt.value, .some(.some(date1))) + XCTAssertEqual(model1_1.$enum.value, .foo) + XCTAssertEqual(model1_1.$optEnum.value, .some(.some(.bar))) + XCTAssertEqual(model1_1.$group.$groupfield1.value, 32) + XCTAssertEqual(model1_1.$group.$groupfield2.value, 64) + XCTAssertEqual(model1_1.$model2s.fromId, model1_1.$id.value) + XCTAssertEqual(model1_1.$otherModel2.fromId, model1_1.$id.value) + XCTAssertEqual(model1_1.$pivotedModels2.fromId, model1_1.$id.value) + + XCTAssertEqual(model1_2.$id.value, 2) + XCTAssertEqual(model1_2.$field.value, "c") + XCTAssertEqual(model1_2.$optField.value, .some(.none)) + XCTAssertEqual(model1_2.$bool.value, false) + XCTAssertEqual(model1_2.$optBool.value, .some(.none)) + XCTAssertEqual(model1_2.$createdAt.value, .some(.some(date2))) + XCTAssertEqual(model1_2.$enum.value, .bar) + XCTAssertEqual(model1_2.$optEnum.value, .some(.none)) + XCTAssertEqual(model1_2.$group.$groupfield1.value, 64) + XCTAssertEqual(model1_2.$group.$groupfield2.value, 32) + XCTAssertEqual(model1_2.$model2s.fromId, model1_2.$id.value) + XCTAssertEqual(model1_2.$otherModel2.fromId, model1_2.$id.value) + XCTAssertEqual(model1_2.$pivotedModels2.fromId, model1_2.$id.value) + + XCTAssertEqual(model2_1.$id.value, 1) + XCTAssertEqual(model2_1.$field.value, Data([0, 1, 2, 3])) + XCTAssertEqual(model2_1.$model1.id, 1) + XCTAssertEqual(model2_1.$otherModel1.id, 2) + + XCTAssertEqual(model2_2.$id.value, 2) + XCTAssertEqual(model2_2.$field.value, Data([4, 5, 6, 7])) + XCTAssertEqual(model2_2.$model1.id, 2) + XCTAssertEqual(model2_2.$otherModel1.id, nil) + + XCTAssertEqual(pivot.$id.$model1.id, 1) + XCTAssertEqual(pivot.$id.$model2.id, 1) + + XCTAssertEqual(fromPivot1.$id.$field1.value, 1.0) + XCTAssertEqual(fromPivot1.$id.$field2.value, uuid1) + XCTAssertEqual(fromPivot1.$pivot.id.$model1.$id.value, 1) + XCTAssertEqual(fromPivot1.$pivot.id.$model2.$id.value, 1) + XCTAssertEqual(fromPivot1.$optPivot.id?.$model1.$id.value, 2) + XCTAssertEqual(fromPivot1.$optPivot.id?.$model2.$id.value, 2) + + XCTAssertEqual(fromPivot2.$id.$field1.value, 2.0) + XCTAssertEqual(fromPivot2.$id.$field2.value, uuid2) + XCTAssertEqual(fromPivot2.$pivot.id.$model1.$id.value, 2) + XCTAssertEqual(fromPivot2.$pivot.id.$model2.$id.value, 2) + XCTAssertEqual(fromPivot2.$optPivot.id?.$model1.$id.value, nil) + XCTAssertEqual(fromPivot2.$optPivot.id?.$model2.$id.value, nil) + } + + func testInsertFluentModels() async throws { + let model1_1 = Model1(), model1_2 = Model1() + model1_1.field = "a" + model1_1.optField = 1 + model1_1.bool = true + model1_1.optBool = false + model1_1.createdAt = Date() + model1_1.enum = .foo + model1_1.optEnum = .bar + model1_1.group.groupfield1 = 32 + model1_1.group.groupfield2 = 64 + model1_2.id = 2 + model1_2.field = "b" + model1_2.optField = nil + model1_2.bool = true + model1_2.optBool = nil + model1_2.createdAt = Date() + model1_2.enum = .foo + model1_2.optEnum = nil + model1_2.group.groupfield1 = 32 + model1_2.group.groupfield2 = 64 + let model2_1 = Model2(), model2_2 = Model2() + model2_1.field = Data([0]) + model2_1.$model1.id = 1 + model2_1.$otherModel1.id = 2 + model2_2.id = 2 + model2_2.field = Data([1]) + model2_2.$model1.id = 2 + model2_2.$otherModel1.id = nil + let pivot = Pivot() + pivot.id?.$model1.id = 1 + pivot.id?.$model2.id = 1 + let fromPivot1 = FromPivot(), fromPivot2 = FromPivot() + fromPivot1.id?.field1 = 1.0 + fromPivot1.id?.field2 = UUID() + fromPivot1.$pivot.id.$model1.id = 1 + fromPivot1.$pivot.id.$model2.id = 1 + fromPivot1.$optPivot.id = .init(model1Id: 2, model2Id: 2) + fromPivot2.id?.field1 = 1.0 + fromPivot2.id?.field2 = UUID() + fromPivot2.$pivot.id.$model1.id = 2 + fromPivot2.$pivot.id.$model2.id = 2 + fromPivot2.$optPivot.id = nil + + try await self.db.insert(into: Model1.schema).fluentModels([model1_1, model1_2]).run() + try await self.db.insert(into: Model2.schema).fluentModels([model2_1, model2_2]).run() + try await self.db.insert(into: Pivot.schema).fluentModel(pivot).run() + try await self.db.insert(into: FromPivot.schema).fluentModels([fromPivot1, fromPivot2]).run() + + XCTAssertEqual(self.db.sqlSerializers.count, 4) + XCTAssertEqual(self.db.sqlSerializers.dropFirst(0).first?.sql, #"INSERT INTO "model1s" ("optfield", "id", "bool", "optbool", "group_groupfield2", "group_groupfield1", "created_at", "enum", "field", "optenum") VALUES ($1, DEFAULT, $2, $3, $4, $5, $6, 'foo', $7, 'bar'), (NULL, $8, $9, NULL, $10, $11, $12, 'foo', $13, NULL)"#) + XCTAssertEqual(self.db.sqlSerializers.dropFirst(0).first?.binds.count, 13) + XCTAssertEqual(self.db.sqlSerializers.dropFirst(1).first?.sql, #"INSERT INTO "model2s" ("id", "model1_id", "field", "othermodel1_id") VALUES (DEFAULT, $1, $2, $3), ($4, $5, $6, NULL)"#) + XCTAssertEqual(self.db.sqlSerializers.dropFirst(1).first?.binds.count, 6) + XCTAssertEqual(self.db.sqlSerializers.dropFirst(2).first?.sql, #"INSERT INTO "pivots" ("model2_id", "model1_id") VALUES ($1, $2)"#) + XCTAssertEqual(self.db.sqlSerializers.dropFirst(2).first?.binds.count, 2) + XCTAssertEqual(self.db.sqlSerializers.dropFirst(3).first?.sql, #"INSERT INTO "from_pivots" ("pivot_model2_id", "optpivot_model1_id", "pivot_model1_id", "field2", "optpivot_model2_id", "field1") VALUES ($1, $2, $3, $4, $5, $6), ($7, NULL, $8, $9, NULL, $10)"#) + XCTAssertEqual(self.db.sqlSerializers.dropFirst(3).first?.binds.count, 10) + } +} + +enum Enum1: String, Codable { + case foo, bar +} + +final class AGroup: Fields, @unchecked Sendable { + @Field(key: "groupfield1") var groupfield1: Int32 + @Field(key: "groupfield2") var groupfield2: Int64 +} + +final class Model1: Model, @unchecked Sendable { + static let schema = "model1s" + + @ID(custom: .id) var id: Int? + @Field(key: "field") var field: String + @OptionalField(key: "optfield") var optField: Int? + @Boolean(key: "bool") var bool + @OptionalBoolean(key: "optbool") var optBool + @Timestamp(key: "created_at", on: .create) var createdAt + @Enum(key: "enum") var `enum`: Enum1 + @OptionalEnum(key: "optenum") var optEnum: Enum1? + @Group(key: "group") var group: AGroup + @Children(for: \.$model1) var model2s: [Model2] + @OptionalChild(for: \.$otherModel1) var otherModel2: Model2? + @Siblings(through: Pivot.self, from: \.$id.$model1, to: \.$id.$model2) var pivotedModels2: [Model2] + + init() {} +} + +final class Model2: Model, @unchecked Sendable { + static let schema = "model2s" + + @ID(custom: .id) var id: Int? + @Field(key: "field") var field: Data + @Parent(key: "model1_id") var model1: Model1 + @OptionalParent(key: "othermodel1_id") var otherModel1: Model1? + @Siblings(through: Pivot.self, from: \.$id.$model2, to: \.$id.$model1) var pivotedModels1: [Model1] + + init() {} +} + +final class Pivot: Model, @unchecked Sendable { + static let schema = "pivots" + + final class IDValue: Fields, Hashable, @unchecked Sendable { + @Parent(key: "model1_id") var model1: Model1 + @Parent(key: "model2_id") var model2: Model2 + + init() {} + init(model1Id: Model1.IDValue, model2Id: Model2.IDValue) { (self.$model1.id, self.$model2.id) = (model1Id, model2Id) } + + static func == (lhs: IDValue, rhs: IDValue) -> Bool { lhs.$model1.id == rhs.$model1.id && lhs.$model2.id == rhs.$model2.id } + func hash(into hasher: inout Hasher) { hasher.combine(self.$model1.id); hasher.combine(self.$model2.id) } + } + + @CompositeID var id: IDValue? + @CompositeChildren(for: \.$pivot) var fromPivots: [FromPivot] + @CompositeOptionalChild(for: \.$optPivot) var fromOptPivot: FromPivot? + + init() {} + init(model1Id: Model1.IDValue, model2Id: Model2.IDValue) { self.id = .init(model1Id: model1Id, model2Id: model2Id) } +} + +final class FromPivot: Model, @unchecked Sendable { + static let schema = "from_pivots" + + final class IDValue: Fields, Hashable, @unchecked Sendable { + @Field(key: "field1") var field1: Double + @Field(key: "field2") var field2: UUID + + init() {} + init(field1: Double, field2: UUID) { (self.field1, self.field2) = (field1, field2) } + + static func == (lhs: IDValue, rhs: IDValue) -> Bool { lhs.field1 == rhs.field1 && lhs.field2 == rhs.field2 } + func hash(into hasher: inout Hasher) { hasher.combine(self.field1); hasher.combine(self.field2) } + } + + @CompositeID var id: IDValue? + @CompositeParent(prefix: "pivot", strategy: .snakeCase) var pivot: Pivot + @CompositeOptionalParent(prefix: "optpivot", strategy: .snakeCase) var optPivot: Pivot? + + init() {} + init(field1: Double, field2: UUID) { self.id = .init(field1: field1, field2: field2) } +} diff --git a/Tests/FluentKitTests/TestUtilities.swift b/Tests/FluentKitTests/TestUtilities.swift index 0403891b..8e999d14 100644 --- a/Tests/FluentKitTests/TestUtilities.swift +++ b/Tests/FluentKitTests/TestUtilities.swift @@ -4,19 +4,24 @@ import Logging class DbQueryTestCase: XCTestCase { var db = DummyDatabaseForTestSQLSerializer() + override class func setUp() { + super.setUp() + XCTAssertTrue(isLoggingConfigured) + } + override func setUp() { - db = DummyDatabaseForTestSQLSerializer() + self.db = DummyDatabaseForTestSQLSerializer() } override func tearDown() { - db.reset() + self.db.reset() } } func assertQuery( _ db: DummyDatabaseForTestSQLSerializer, _ query: String, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { XCTAssertEqual(db.sqlSerializers.count, 1, file: file, line: line) @@ -26,7 +31,7 @@ func assertQuery( func assertLastQuery( _ db: DummyDatabaseForTestSQLSerializer, _ query: String, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { XCTAssertEqual(db.sqlSerializers.last?.sql, query, file: file, line: line) @@ -37,9 +42,9 @@ func env(_ name: String) -> String? { } let isLoggingConfigured: Bool = { - LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .info + LoggingSystem.bootstrap { + var handler = StreamLogHandler.standardOutput(label: $0) + handler.logLevel = env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? .info return handler } return true