diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index 4fd472f..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,66 +0,0 @@ -# Contributing to Fluent's MySQL Driver - -👋 Welcome to the Vapor team! - -## Database - -In order to build and test against Postgres, you will need a database running. The easiest way to do this is using Docker and the included `docker-compose.yml` file. - -If you have Docker installed on your computer, all you will need to do is: - -```fish -docker-compose mysql-a mysql-b -# or for mariadb -docker-compose mariadb-a mariadb-b -``` - -This will start the two databases required for running this package's unit tests. One of the databases will run on MySQL's default port 3306 and the other on 3307. - -### Environment Variables - -You will need to set the following environment variable to run the tests: - -``` -MYSQL_PORT_B=3307 -``` - -This tells the tests that the second database is running on a different port. - -You may also set the log level variable to increase or decrease logging: - -``` -LOG_LEVEL=notice -``` - -In Xcode, edit the `fluent-mysql-driver` scheme to add env variables. In the terminal, use `export`. - -## Xcode - -To open the project in Xcode: - -- Clone the repo to your computer -- Drag and drop the folder onto Xcode - -You can then run the unit tests in Xcode by pressing `CMD+U`. - -## SPM - -To develop using SPM, open the code in your favorite code editor. Use the following commands from within the project's root folder to build and test. - -```sh -swift build -swift test -``` - -## SemVer - -Vapor follows [SemVer](https://semver.org). This means that any changes to the source code that can cause -existing code to stop compiling _must_ wait until the next major version to be included. - -Code that is only additive and will not break any existing code can be included in the next minor release. - ----------- - -Join us on Discord if you have any questions: [vapor.team](http://vapor.team). - -— Thanks! 🙌 diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index 3013058..81c71aa 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -11,4 +11,4 @@ jobs: with: package_name: fluent-mysql-driver modules: FluentMySQLDriver - pathsToInvalidate: /fluentmysqldriver + pathsToInvalidate: /fluentmysqldriver/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a3333b9..8260660 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,15 +24,17 @@ env: jobs: api-breakage: - if: ${{ !(github.event.pull_request.draft || false) }} + if: ${{ github.event_name == 'pull_request' && !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest - container: swift:5.8-jammy + container: swift:jammy steps: - - name: Check out package - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 with: { 'fetch-depth': 0 } - - name: Run API breakage check action - uses: vapor/ci/.github/actions/ci-swift-check-api-breakage@reusable-workflows + - name: API breaking changes + run: | + git config --global --add safe.directory "${GITHUB_WORKSPACE}" + swift package diagnose-api-breaking-changes origin/main linux-unit: if: ${{ !(github.event.pull_request.draft || false) }} @@ -42,17 +44,17 @@ jobs: dbimage: - mysql:5.7 - mysql:8.0 + - mysql:8.3 - mariadb:10.4 - mariadb:11 - percona:8.0 runner: - - swift:5.6-focal - - swift:5.7-jammy - - swift:5.8-jammy - - swiftlang/swift:nightly-5.9-jammy - - swiftlang/swift:nightly-main-jammy - runs-on: ubuntu-latest + # List is deliberately incomplete; we want to avoid running 50 jobs on every commit + - swift:5.8-focal + - swift:5.10-jammy + - swiftlang/swift:nightly-6.0-jammy container: ${{ matrix.runner }} + runs-on: ubuntu-latest services: mysql-a: image: ${{ matrix.dbimage }} @@ -69,23 +71,14 @@ jobs: MYSQL_PASSWORD: test_password MYSQL_DATABASE: test_database steps: - - name: Display versions - shell: bash - run: | - echo MYSQL_VERSION='${{ matrix.dbimage }}' >> $GITHUB_ENV - if [[ '${{ contains(matrix.container, 'nightly') }}' == 'true' ]]; then - SWIFT_PLATFORM="$(source /etc/os-release && echo "${ID}${VERSION_ID}")" SWIFT_VERSION="$(cat /.swift_tag)" - printf 'SWIFT_PLATFORM=%s\nSWIFT_VERSION=%s\n' "${SWIFT_PLATFORM}" "${SWIFT_VERSION}" >>"${GITHUB_ENV}" - fi - printf 'OS: %s\nTag: %s\nVersion:\n' "${SWIFT_PLATFORM}-${RUNNER_ARCH}" "${SWIFT_VERSION}" && swift --version - name: Check out package - uses: actions/checkout@v3 - - name: Run tests with Thread Sanitizer and coverage - run: swift test --sanitize=thread --enable-code-coverage + uses: actions/checkout@v4 + - name: Run local tests with coverage and TSan + run: swift test --enable-code-coverage --sanitize=thread - name: Submit coverage report to Codecov.io - uses: vapor/swift-codecov-action@v0.2 + uses: vapor/swift-codecov-action@v0.3 with: - cc_env_vars: 'SWIFT_VERSION,SWIFT_PLATFORM,RUNNER_OS,RUNNER_ARCH,MYSQL_VERSION' + codecov_token: ${{ secrets.CODECOV_TOKEN }} macos-unit: if: ${{ !(github.event.pull_request.draft || false) }} @@ -93,19 +86,20 @@ jobs: fail-fast: false matrix: include: - - dbimage: mysql@8.0 - macos: macos-13 - xcode: latest-stable - runs-on: ${{ matrix.macos }} + - macos-version: macos-13 + xcode-version: '~14.3' + - macos-version: macos-14 + xcode-version: latest + runs-on: ${{ matrix.macos-version }} steps: - name: Select latest available Xcode uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.xcode }} + with: + xcode-version: ${{ matrix.xcode-version }} - name: Install MySQL server from Homebrew - run: brew install ${{ matrix.dbimage }} && brew link --force ${{ matrix.dbimage }} + run: brew install mysql && brew link --force mysql - name: Start MySQL server - run: brew services start ${{ matrix.dbimage }} + run: brew services start mysql - name: Wait for MySQL server to be ready run: until echo | mysql -uroot; do sleep 1; done timeout-minutes: 5 @@ -117,7 +111,7 @@ jobs: CREATE DATABASE test_database_b; GRANT ALL PRIVILEGES ON test_database_b.* TO test_username@localhost; SQL - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Run tests with Thread Sanitizer run: swift test --sanitize=thread env: diff --git a/Package.swift b/Package.swift index 38db65c..98404c7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.8 import PackageDescription let package = Package( @@ -13,20 +13,35 @@ let package = Package( .library(name: "FluentMySQLDriver", targets: ["FluentMySQLDriver"]), ], dependencies: [ - .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.43.0"), + .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.48.1"), .package(url: "https://github.com/vapor/mysql-kit.git", from: "4.7.1"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.5.2"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), ], targets: [ - .target(name: "FluentMySQLDriver", dependencies: [ - .product(name: "FluentKit", package: "fluent-kit"), - .product(name: "FluentSQL", package: "fluent-kit"), - .product(name: "Logging", package: "swift-log"), - .product(name: "MySQLKit", package: "mysql-kit"), - ]), - .testTarget(name: "FluentMySQLDriverTests", dependencies: [ - .product(name: "FluentBenchmark", package: "fluent-kit"), - .target(name: "FluentMySQLDriver"), - ]), + .target( + name: "FluentMySQLDriver", + dependencies: [ + .product(name: "FluentKit", package: "fluent-kit"), + .product(name: "FluentSQL", package: "fluent-kit"), + .product(name: "Logging", package: "swift-log"), + .product(name: "MySQLKit", package: "mysql-kit"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "FluentMySQLDriverTests", + dependencies: [ + .product(name: "FluentBenchmark", package: "fluent-kit"), + .target(name: "FluentMySQLDriver"), + ], + swiftSettings: swiftSettings + ), ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableExperimentalFeature("StrictConcurrency=complete"), +] } diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 0000000..b52199f --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,48 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "fluent-mysql-driver", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + ], + products: [ + .library(name: "FluentMySQLDriver", targets: ["FluentMySQLDriver"]), + ], + dependencies: [ + .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.48.1"), + .package(url: "https://github.com/vapor/mysql-kit.git", from: "4.7.1"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), + ], + targets: [ + .target( + name: "FluentMySQLDriver", + dependencies: [ + .product(name: "FluentKit", package: "fluent-kit"), + .product(name: "FluentSQL", package: "fluent-kit"), + .product(name: "Logging", package: "swift-log"), + .product(name: "MySQLKit", package: "mysql-kit"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "FluentMySQLDriverTests", + dependencies: [ + .product(name: "FluentBenchmark", package: "fluent-kit"), + .target(name: "FluentMySQLDriver"), + ], + swiftSettings: swiftSettings + ), + ] +) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableExperimentalFeature("StrictConcurrency=complete"), +] } diff --git a/README.md b/README.md index 3870cf4..fce6a78 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,35 @@

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

+ +
+ +FluentMySQLDriver is a [FluentKit] driver for MySQL clients. It provides support for using the Fluent ORM with MySQL databases, and uses [MySQLKit] to provide [SQLKit] driver services, [MySQLNIO] to connect and communicate with the database server asynchronously, and [AsyncKit] to provide connection pooling. + +[FluentKit]: https://github.com/vapor/fluent-kit +[SQLKit]: https://github.com/vapor/sql-kit +[MySQLKit]: https://github.com/vapor/mysql-kit +[MySQLNIO]: https://github.com/vapor/mysql-nio +[AsyncKit]: https://github.com/vapor/async-kit + +### Usage + +Use the SPM string to easily include the dependendency in your `Package.swift` file: + +```swift +.package(url: "https://github.com/vapor/fluent-mysql-driver.git", from: "4.0.0") +``` + +For additional information, see [the Fluent documentation](https://docs.vapor.codes/fluent/overview/). diff --git a/Sources/FluentMySQLDriver/Docs.docc/Resources/vapor-fluentmysqldriver-logo.svg b/Sources/FluentMySQLDriver/Docs.docc/Resources/vapor-fluentmysqldriver-logo.svg new file mode 100644 index 0000000..a144562 --- /dev/null +++ b/Sources/FluentMySQLDriver/Docs.docc/Resources/vapor-fluentmysqldriver-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/FluentMySQLDriver/Docs.docc/index.md b/Sources/FluentMySQLDriver/Docs.docc/index.md index 8ef17c8..187ad16 100644 --- a/Sources/FluentMySQLDriver/Docs.docc/index.md +++ b/Sources/FluentMySQLDriver/Docs.docc/index.md @@ -1,3 +1,14 @@ # ``FluentMySQLDriver`` -FluentMySQLDriver is a package to integrate MySQLNIO and and MySQLKit with FluentKit to make it easy to use and write MySQL database operations in Swift. \ No newline at end of file +FluentMySQLDriver is a [FluentKit] driver for MySQL clients. + +## Overview + +FluentMySQLDriver provides support for using the Fluent ORM with MySQL databases. It uses [MySQLKit] to provide [SQLKit] driver services, [MySQLNIO] to connect and communicate with the database server asynchronously, and [AsyncKit] to provide connection pooling. + +[FluentKit]: https://github.com/vapor/fluent-kit +[SQLKit]: https://github.com/vapor/sql-kit +[MySQLKit]: https://github.com/vapor/mysql-kit +[MySQLNIO]: https://github.com/vapor/mysql-nio +[AsyncKit]: https://github.com/vapor/async-kit + diff --git a/Sources/FluentMySQLDriver/Docs.docc/theme-settings.json b/Sources/FluentMySQLDriver/Docs.docc/theme-settings.json new file mode 100644 index 0000000..d01225e --- /dev/null +++ b/Sources/FluentMySQLDriver/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": { + "mysql-turquoise": "#02758f", + "documentation-intro-fill": "radial-gradient(circle at top, var(--color-mysql-turquoise) 30%, #000 100%)", + "documentation-intro-accent": "var(--color-mysql-turquoise)", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "fill": { "dark": "#000", "light": "#fff" } + }, + "icons": { "technology": "/mysqlkit/images/vapor-fluentmysqldriver-logo.svg" } + }, + "features": { + "quickNavigation": { "enable": true }, + "i18n": { "enable": true } + } +} diff --git a/Sources/FluentMySQLDriver/Exports.swift b/Sources/FluentMySQLDriver/Exports.swift index 213b419..da47a8f 100644 --- a/Sources/FluentMySQLDriver/Exports.swift +++ b/Sources/FluentMySQLDriver/Exports.swift @@ -1,5 +1,3 @@ -#if swift(>=5.8) - @_documentation(visibility: internal) @_exported import FluentKit @_documentation(visibility: internal) @_exported import struct Foundation.URL @@ -18,30 +16,9 @@ @_documentation(visibility: internal) @_exported import struct NIOSSL.TLSConfiguration -#else - -@_exported import FluentKit - -@_exported import struct Foundation.URL - -@_exported import struct MySQLKit.MySQLConfiguration -@_exported import struct MySQLKit.MySQLConnectionSource -@_exported import struct MySQLKit.MySQLDataEncoder -@_exported import struct MySQLKit.MySQLDataDecoder - -@_exported import class MySQLNIO.MySQLConnection -@_exported import enum MySQLNIO.MySQLError -@_exported import struct MySQLNIO.MySQLData -@_exported import protocol MySQLNIO.MySQLDatabase -@_exported import protocol MySQLNIO.MySQLDataConvertible -@_exported import struct MySQLNIO.MySQLRow - -@_exported import struct NIOSSL.TLSConfiguration - -#endif - extension DatabaseID { + /// A default `DatabaseID` to use for MySQL databases. public static var mysql: DatabaseID { - return .init(string: "mysql") + .init(string: "mysql") } } diff --git a/Sources/FluentMySQLDriver/FluentMySQLConfiguration.swift b/Sources/FluentMySQLDriver/FluentMySQLConfiguration.swift index 4b0b8d7..eea98fd 100644 --- a/Sources/FluentMySQLDriver/FluentMySQLConfiguration.swift +++ b/Sources/FluentMySQLDriver/FluentMySQLConfiguration.swift @@ -4,6 +4,18 @@ import FluentKit import MySQLKit extension DatabaseConfigurationFactory { + /// Create a database configuration factory for connecting to a server through a UNIX domain socket. + /// + /// - Parameters: + /// - unixDomainSocketPath: The path to the UNIX domain socket to connect through. + /// - username: The username to use for the connection. + /// - password: The password (empty string for none) to use for the connection. + /// - database: The default database for the connection, if any. + /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. + /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. + /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. + /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. + /// - Returns: An appropriate configuration factory. public static func mysql( unixDomainSocketPath: String, username: String, @@ -28,6 +40,17 @@ extension DatabaseConfigurationFactory { decoder: decoder ) } + + /// Create a database configuration factory from an appropriately formatted URL string. + /// + /// - Parameters: + /// - url: A URL-formatted MySQL connection string. See `MySQLConfiguration` in MySQLKit for details of + /// accepted URL formats. + /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. + /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. + /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. + /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. + /// - Returns: An appropriate configuration factory. public static func mysql( url urlString: String, maxConnectionsPerEventLoop: Int = 1, @@ -47,6 +70,16 @@ extension DatabaseConfigurationFactory { ) } + /// Create a database configuration factory from an appropriately formatted URL string. + /// + /// - Parameters: + /// - url: A `URL` containing MySQL connection parameters. See `MySQLConfiguration` in MySQLKit for details of + /// accepted URL formats. + /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. + /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. + /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. + /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. + /// - Returns: An appropriate configuration factory. public static func mysql( url: URL, maxConnectionsPerEventLoop: Int = 1, @@ -66,6 +99,20 @@ extension DatabaseConfigurationFactory { ) } + /// Create a database configuration factory for connecting to a server with a hostname and optional port. + /// + /// - Parameters: + /// - hostname: The hostname to connect to. + /// - port: A TCP port number to connect on. Defaults to the IANA-assigned MySQL port number (3306). + /// - username: The username to use for the connection. + /// - password: The password (empty string for none) to use for the connection. + /// - database: The default database for the connection, if any. + /// - tlsConfiguration: An optional `TLSConfiguration` specifying encryption for the connection. + /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. + /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. + /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. + /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. + /// - Returns: An appropriate configuration factory. public static func mysql( hostname: String, port: Int = 3306, @@ -78,7 +125,7 @@ extension DatabaseConfigurationFactory { encoder: MySQLDataEncoder = .init(), decoder: MySQLDataDecoder = .init() ) -> Self { - return .mysql( + .mysql( configuration: .init( hostname: hostname, port: port, @@ -94,6 +141,15 @@ extension DatabaseConfigurationFactory { ) } + /// Create a database configuration factory for connecting to a server with a given `MySQLConfiguration`. + /// + /// - Parameters: + /// - configuration: A connection configuration. + /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. + /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. + /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. + /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. + /// - Returns: An appropriate configuration factory. public static func mysql( configuration: MySQLConfiguration, maxConnectionsPerEventLoop: Int = 1, @@ -101,7 +157,7 @@ extension DatabaseConfigurationFactory { encoder: MySQLDataEncoder = .init(), decoder: MySQLDataDecoder = .init() ) -> Self { - return Self { + Self { FluentMySQLConfiguration( configuration: configuration, maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, @@ -114,15 +170,28 @@ extension DatabaseConfigurationFactory { } } +/// An implementation of `DatabaseConfiguration` for MySQL configurations. struct FluentMySQLConfiguration: DatabaseConfiguration { + /// The underlying `MySQLConfiguration`. let configuration: MySQLConfiguration + + /// The maximum number of database connections to add to each event loop's pool. let maxConnectionsPerEventLoop: Int + + /// The timeout for queries on the connection pool's wait list. let connectionPoolTimeout: TimeAmount + + /// A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. let encoder: MySQLDataEncoder - let decoder: MySQLDataDecoder - var middleware: [AnyModelMiddleware] - func makeDriver(for databases: Databases) -> DatabaseDriver { + /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. + let decoder: MySQLDataDecoder + + // See `DatabaseConfiguration.middleware`. + var middleware: [any AnyModelMiddleware] + + // See `DatabaseConfiguration.makeDriver(for:)`. + func makeDriver(for databases: Databases) -> any DatabaseDriver { let db = MySQLConnectionSource( configuration: self.configuration ) diff --git a/Sources/FluentMySQLDriver/FluentMySQLDatabase.swift b/Sources/FluentMySQLDriver/FluentMySQLDatabase.swift index ddbbc46..7a47552 100644 --- a/Sources/FluentMySQLDriver/FluentMySQLDatabase.swift +++ b/Sources/FluentMySQLDriver/FluentMySQLDatabase.swift @@ -3,18 +3,40 @@ import MySQLKit import MySQLNIO import AsyncKit -struct _FluentMySQLDatabase { - let database: MySQLDatabase +/// A wrapper for a `MySQLDatabase` which provides `Database`, `SQLDatabase`, and forwarding `MySQLDatabase` +/// conformances. +struct _FluentMySQLDatabase: Database, SQLDatabase, MySQLDatabase { + /// A trivial wrapper type to work around Sendable warnings due to MySQLNIO not being Sendable-correct. + struct FakeSendable: @unchecked Sendable { let value: T } + + /// The underlying database connection. + let database: FakeSendable + + /// A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. let encoder: MySQLDataEncoder + + /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. let decoder: MySQLDataDecoder + + /// The `DatabaseContext` associated with this connection. let context: DatabaseContext + + /// Whether this is a transaction-specific connection. let inTransaction: Bool -} + + /// Create a ``_FluentMySQLDatabase``. + init(database: any MySQLDatabase, encoder: MySQLDataEncoder, decoder: MySQLDataDecoder, context: DatabaseContext, inTransaction: Bool) { + self.database = .init(value: database) + self.encoder = encoder + self.decoder = decoder + self.context = context + self.inTransaction = inTransaction + } -extension _FluentMySQLDatabase: Database { + // See `Database.execute(query:onOutput:)`. func execute( query: DatabaseQuery, - onOutput: @escaping (DatabaseOutput) -> () + onOutput: @escaping @Sendable (any DatabaseOutput) -> () ) -> EventLoopFuture { let expression = SQLQueryConverter(delegate: MySQLConverterDelegate()) .convert(query) @@ -29,7 +51,7 @@ extension _FluentMySQLDatabase: Database { switch query.action { case .create where query.customIDKey != .string(""): let row = LastInsertRow( - metadata: metadata, + lastInsertID: metadata.lastInsertID, customIDKey: query.customIDKey ) onOutput(row) @@ -41,29 +63,36 @@ extension _FluentMySQLDatabase: Database { return self.eventLoop.makeFailedFuture(error) } } - + + /// This is here because it allows for full test coverage; it serves no actual purpose functionally. + /*private*/ func ignoreRow(_: MySQLRow) throws {} + + // See `Database.execute(schema:)`. func execute(schema: DatabaseSchema) -> EventLoopFuture { let expression = SQLSchemaConverter(delegate: MySQLConverterDelegate()) .convert(schema) let (sql, binds) = self.serialize(expression) do { - return try self.query(sql, binds.map { try MySQLDataEncoder().encode($0) }, onRow: { - fatalError("unexpected row: \($0)") - }) + // Again, this is here purely for the benefit of coverage. It optimizes out as a no-op even in debug. + try? self.ignoreRow(.init(format: .binary, columnDefinitions: [], values: [])) + + return try self.query(sql, binds.map { try MySQLDataEncoder().encode($0) }, onRow: self.ignoreRow(_:)) } catch { return self.eventLoop.makeFailedFuture(error) } } + // See `Database.execute(enum:)`. func execute(enum: DatabaseEnum) -> EventLoopFuture { self.eventLoop.makeSucceededFuture(()) } - func transaction(_ closure: @escaping (Database) -> EventLoopFuture) -> EventLoopFuture { + // See `Database.transaction(_:)`. + func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { guard !self.inTransaction else { return closure(self) } - return self.database.withConnection { conn in + return self.database.value.withConnection { conn in conn.simpleQuery("START TRANSACTION").flatMap { _ in let db = _FluentMySQLDatabase( database: conn, @@ -73,7 +102,7 @@ extension _FluentMySQLDatabase: Database { inTransaction: true ) return closure(db).flatMap { result in - conn.simpleQuery("COMMIT").map { _ in + conn.simpleQuery("COMMIT").and(value: result).map { _, result in result } }.flatMapError { error in @@ -85,8 +114,9 @@ extension _FluentMySQLDatabase: Database { } } - func withConnection(_ closure: @escaping (Database) -> EventLoopFuture) -> EventLoopFuture { - self.database.withConnection { + // See `Database.withConnection(_:)`. + func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { + self.database.value.withConnection { closure(_FluentMySQLDatabase( database: $0, encoder: self.encoder, @@ -96,76 +126,117 @@ extension _FluentMySQLDatabase: Database { )) } } -} - -extension _FluentMySQLDatabase: SQLDatabase { - var dialect: SQLDialect { - MySQLDialect() + + // See `SQLDatabase.dialect`. + var dialect: any SQLDialect { + self.sql(encoder: self.encoder, decoder: self.decoder).dialect + } + + // See `SQLDatabase.queryLogLevel`. + var queryLogLevel: Logger.Level? { + self.sql(encoder: self.encoder, decoder: self.decoder).queryLogLevel } - public func execute( - sql query: SQLExpression, - _ onRow: @escaping (SQLRow) -> () + // See `SQLDatabase.execute(sql:_:)`. + func execute( + sql query: any SQLExpression, + _ onRow: @escaping @Sendable (any SQLRow) -> () ) -> EventLoopFuture { - self.sql().execute(sql: query, onRow) + self.sql(encoder: self.encoder, decoder: self.decoder).execute(sql: query, onRow) + } + + // See `SQLDatabase.withSession(_:)`. + func withSession(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> R) async throws -> R { + try await self.withConnection { (conn: MySQLConnection) in + conn.eventLoop.makeFutureWithTask { + try await closure(conn.sql(encoder: self.encoder, decoder: self.decoder)) + } + }.get() } -} -extension _FluentMySQLDatabase: MySQLDatabase { - func send(_ command: MySQLCommand, logger: Logger) -> EventLoopFuture { - self.database.send(command, logger: logger) + // See `MySQLDatabase.send(_:logger:)`. + func send(_ command: any MySQLCommand, logger: Logger) -> EventLoopFuture { + self.database.value.send(command, logger: logger) } + // See `MySQLDatabase.withConnection(_:)`. func withConnection(_ closure: @escaping (MySQLConnection) -> EventLoopFuture) -> EventLoopFuture { - self.database.withConnection(closure) + self.database.value.withConnection(closure) } } -private struct LastInsertRow: DatabaseOutput { +/// A `DatabaseOutput` used to provide last insert IDs from query metadata to the Fluent layer. +/*private*/ struct LastInsertRow: DatabaseOutput { + // See `CustomStringConvertible.description`. var description: String { - "\(self.metadata)" + "\(self.lastInsertID.map { "\($0)" } ?? "nil")" } - - let metadata: MySQLQueryMetadata + + /// The last inserted ID as of the creation of this row. + let lastInsertID: UInt64? + + /// If specified by the original query, an alternative to `FieldKey.id` to be considered valid. let customIDKey: FieldKey? - - func schema(_ schema: String) -> DatabaseOutput { + + // See `DatabaseOutput.schema(_:)`. + func schema(_ schema: String) -> any DatabaseOutput { self } + // See `DatabaseOutput.decodeNil(_:)`. func decodeNil(_ key: FieldKey) throws -> Bool { false } + // See `DatabaseOutput.contains(_:)`. func contains(_ key: FieldKey) -> Bool { key == .id || key == self.customIDKey } - func decode(_ key: FieldKey, as type: T.Type) throws -> T - where T: Decodable - { + // See `DatabaseOutput.decode(_:as:)`. + func decode(_ key: FieldKey, as type: T.Type) throws -> T { + guard let lastInsertIDInitializable = T.self as? any LastInsertIDInitializable.Type else { + throw DecodingError.typeMismatch(T.self, .init(codingPath: [SomeCodingKey(stringValue: key.description)], debugDescription: "\(T.self) is not valid as a last insert ID")) + } guard self.contains(key) else { - fatalError("Cannot decode field from last insert row: \(key).") + throw DecodingError.keyNotFound(SomeCodingKey(stringValue: key.description), .init(codingPath: [], debugDescription: "LastInsertRow doesn't contain key \(key)")) } - if let lastInsertIDInitializable = T.self as? LastInsertIDInitializable.Type { - return lastInsertIDInitializable.init(lastInsertID: self.metadata.lastInsertID!) as! T - } else { - fatalError("Unsupported database generated identifier type: \(T.self).") + guard let lastInsertID = self.lastInsertID else { + throw DecodingError.valueNotFound(T.self, .init(codingPath: [], debugDescription: "LastInsertRow received metadata with no last insert ID")) } + return lastInsertIDInitializable.init(lastInsertID: lastInsertID) as! T } } +/// Retroactive conformance to `Sendable` for `MySQLConnection`, which happens to actually be `Senable`-correct +/// but not annotated as such. +extension MySQLNIO.MySQLConnection: @unchecked Swift.Sendable {} + +/// Retroactive conformance to `Sendable` for `MySQLQueryMetadata`, which happens to actually be `Senable`-correct +/// but not annotated as such. +extension MySQLNIO.MySQLQueryMetadata: @unchecked Swift.Sendable {} + +/// A trivial protocol which identifies types that may be returned by MySQL as "last insert ID" values. protocol LastInsertIDInitializable { + /// Create an instance of `Self` from a given unsigned 64-bit integer ID value. init(lastInsertID: UInt64) } extension LastInsertIDInitializable where Self: FixedWidthInteger { + /// Default implementation of ``init(lastInsertID:)`` for `FixedWidthInteger`s. init(lastInsertID: UInt64) { self = numericCast(lastInsertID) } } +/// `UInt64` is a valid last inserted ID value type. extension UInt64: LastInsertIDInitializable { } + +/// `UInt` is a valid last inserted ID value type. extension UInt: LastInsertIDInitializable { } + +/// `Int` is a valid last inserted ID value type. extension Int: LastInsertIDInitializable { } + +/// `Int64` is a valid last inserted ID value type. extension Int64: LastInsertIDInitializable { } diff --git a/Sources/FluentMySQLDriver/FluentMySQLDriver.swift b/Sources/FluentMySQLDriver/FluentMySQLDriver.swift index 59f6d58..d7bd91b 100644 --- a/Sources/FluentMySQLDriver/FluentMySQLDriver.swift +++ b/Sources/FluentMySQLDriver/FluentMySQLDriver.swift @@ -1,18 +1,21 @@ -import AsyncKit +@preconcurrency import AsyncKit import FluentKit import MySQLKit import NIOCore +/// An implementation of `DatabaseDriver` for MySQL . struct _FluentMySQLDriver: DatabaseDriver { + /// The connection pool set for this driver. let pool: EventLoopGroupConnectionPool + + /// A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. let encoder: MySQLDataEncoder + + /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. let decoder: MySQLDataDecoder - var eventLoopGroup: EventLoopGroup { - self.pool.eventLoopGroup - } - - func makeDatabase(with context: DatabaseContext) -> Database { + // See `DatabaseDriver.makeDatabase(with:)`. + func makeDatabase(with context: DatabaseContext) -> any Database { _FluentMySQLDatabase( database: self.pool.pool(for: context.eventLoop).database(logger: context.logger), encoder: self.encoder, @@ -22,6 +25,7 @@ struct _FluentMySQLDriver: DatabaseDriver { ) } + // See `DatabaseDriver.shutdown()`. func shutdown() { self.pool.shutdown() } diff --git a/Sources/FluentMySQLDriver/FluentMySQLError.swift b/Sources/FluentMySQLDriver/FluentMySQLError.swift index 4ccb630..57cec92 100644 --- a/Sources/FluentMySQLDriver/FluentMySQLError.swift +++ b/Sources/FluentMySQLDriver/FluentMySQLError.swift @@ -1,3 +1,6 @@ +/// Errors that may be thrown by this package. enum FluentMySQLError: Error { + /// ``FluentMySQLConfiguration/mysql(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encoder:decoder:)`` was + /// invoked with an invalid input. case invalidURL(String) } diff --git a/Sources/FluentMySQLDriver/MySQLConverterDelegate.swift b/Sources/FluentMySQLDriver/MySQLConverterDelegate.swift index 981ddb7..dd64a52 100644 --- a/Sources/FluentMySQLDriver/MySQLConverterDelegate.swift +++ b/Sources/FluentMySQLDriver/MySQLConverterDelegate.swift @@ -1,7 +1,9 @@ import FluentSQL +/// An implementation of `SQLConverterDelegate` for MySQL . struct MySQLConverterDelegate: SQLConverterDelegate { - func customDataType(_ dataType: DatabaseSchema.DataType) -> SQLExpression? { + // See `SQLConverterDelegate.customDataType(_:)`. + func customDataType(_ dataType: DatabaseSchema.DataType) -> (any SQLExpression)? { switch dataType { case .string: return SQLRaw("VARCHAR(255)") case .datetime: return SQLRaw("DATETIME(6)") @@ -12,6 +14,7 @@ struct MySQLConverterDelegate: SQLConverterDelegate { } } + // See `SQLConverterDelegate.beforeConvert(_:)`. func beforeConvert(_ schema: DatabaseSchema) -> DatabaseSchema { var copy = schema // convert field foreign keys to table-level foreign keys diff --git a/Sources/FluentMySQLDriver/MySQLDatabase.swift b/Sources/FluentMySQLDriver/MySQLDatabase.swift deleted file mode 100644 index 07f33aa..0000000 --- a/Sources/FluentMySQLDriver/MySQLDatabase.swift +++ /dev/null @@ -1,2 +0,0 @@ -import SQLKit -import AsyncKit diff --git a/Sources/FluentMySQLDriver/MySQLError+Database.swift b/Sources/FluentMySQLDriver/MySQLError+Database.swift index e23e116..f018286 100644 --- a/Sources/FluentMySQLDriver/MySQLError+Database.swift +++ b/Sources/FluentMySQLDriver/MySQLError+Database.swift @@ -1,7 +1,9 @@ import MySQLNIO import FluentKit +/// Conform `MySQLError` to `DatabaseError`. extension MySQLError: DatabaseError { + // See `DatabaseError.isSyntaxError`. public var isSyntaxError: Bool { switch self { case .invalidSyntax(_): @@ -11,6 +13,7 @@ extension MySQLError: DatabaseError { } } + // See `DatabaseError.isConstraintFailure`. public var isConstraintFailure: Bool { switch self { case .duplicateEntry(_): @@ -20,6 +23,7 @@ extension MySQLError: DatabaseError { } } + // See `DatabaseError.isConnectionClosed`. public var isConnectionClosed: Bool { switch self { case .closed: diff --git a/Sources/FluentMySQLDriver/MySQLRow+Database.swift b/Sources/FluentMySQLDriver/MySQLRow+Database.swift index 3d55b1a..f1ebd04 100644 --- a/Sources/FluentMySQLDriver/MySQLRow+Database.swift +++ b/Sources/FluentMySQLDriver/MySQLRow+Database.swift @@ -3,24 +3,37 @@ import MySQLKit import FluentKit extension MySQLRow { - internal func databaseOutput(decoder: MySQLDataDecoder) -> DatabaseOutput { + /// Returns a `DatabaseOutput` for this row. + /// + /// - Parameter decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values. + /// - Returns: A `DatabaseOutput` instance. + func databaseOutput(decoder: MySQLDataDecoder) -> any DatabaseOutput { _MySQLDatabaseOutput(row: self, decoder: decoder, schema: nil) } } +/// A `DatabaseOutput` implementation for `MySQLRow`s. private struct _MySQLDatabaseOutput: DatabaseOutput { + /// The underlying row. let row: MySQLRow + + /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values. let decoder: MySQLDataDecoder + + /// The most recently set schema value (see `DatabaseOutput.schema(_:)`). let schema: String? + // See `DatabaseOutput.description`. var description: String { self.row.description } + // See `DatabaseOutput.contains(_:)`. func contains(_ key: FieldKey) -> Bool { self.row.column(self.columnName(key)) != nil } + // See `DatabaseOutput.decodeNil(_:)`. func decodeNil(_ key: FieldKey) throws -> Bool { if let data = self.row.column((self.columnName(key))) { return data.buffer == nil @@ -29,7 +42,8 @@ private struct _MySQLDatabaseOutput: DatabaseOutput { } } - func schema(_ schema: String) -> DatabaseOutput { + // See `DatabaseOutput.schema(_:)`. + func schema(_ schema: String) -> any DatabaseOutput { _MySQLDatabaseOutput( row: self.row, decoder: self.decoder, @@ -37,15 +51,14 @@ private struct _MySQLDatabaseOutput: DatabaseOutput { ) } - func decode(_ key: FieldKey, as type: T.Type) throws -> T - where T: Decodable - { + // See `DatabaseOutput.decode(_:as:)`. + func decode(_ key: FieldKey, as type: T.Type) throws -> T { try self.row .sql(decoder: self.decoder) .decode(column: self.columnName(key), as: T.self) } - + /// Translates a given `FieldKey` into a column name, accounting for the current schema, if any. private func columnName(_ key: FieldKey) -> String { if let schema = self.schema { return "\(schema)_\(key.description)" diff --git a/Tests/FluentMySQLDriverTests/FluentMySQLDriverTests.swift b/Tests/FluentMySQLDriverTests/FluentMySQLDriverTests.swift index ddc757f..d21acae 100644 --- a/Tests/FluentMySQLDriverTests/FluentMySQLDriverTests.swift +++ b/Tests/FluentMySQLDriverTests/FluentMySQLDriverTests.swift @@ -1,6 +1,7 @@ import NIO import FluentBenchmark -import FluentMySQLDriver +import FluentSQL +@testable import FluentMySQLDriver import SQLKit import XCTest import Logging @@ -8,11 +9,52 @@ import MySQLKit import MySQLNIO import NIOSSL +func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Equatable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertNilAsync( + _ expression: @autoclosure () async throws -> Any?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await expression() + return XCTAssertNil(result, message(), file: file, line: line) + } catch { + return XCTAssertNil(try { throw error }(), message(), file: file, line: line) + } +} + +func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line, + _ callback: (any Error) -> Void = { _ in } +) async { + do { + _ = try await expression() + XCTAssertThrowsError({}(), message(), file: file, line: line, callback) + } catch { + XCTAssertThrowsError(try { throw error }(), message(), file: file, line: line, callback) + } +} + final class FluentMySQLDriverTests: XCTestCase { -// func testAll() throws { try self.benchmarker.testAll() } func testAggregate() throws { try self.benchmarker.testAggregate() } func testArray() throws { try self.benchmarker.testArray() } func testBatch() throws { try self.benchmarker.testBatch() } + func testChild() throws { try self.benchmarker.testChild() } func testChildren() throws { try self.benchmarker.testChildren() } func testCodable() throws { try self.benchmarker.testCodable() } func testChunk() throws { try self.benchmarker.testChunk() } @@ -42,99 +84,60 @@ final class FluentMySQLDriverTests: XCTestCase { func testTransaction() throws { try self.benchmarker.testTransaction() } func testUnique() throws { try self.benchmarker.testUnique() } - func testDatabaseError() throws { - let sql = (self.db as! SQLDatabase) - do { - try sql.raw("asdf").run().wait() - } catch let error as DatabaseError where error.isSyntaxError { - // pass - } catch { - XCTFail("\(error)") + func testDatabaseError() async throws { + let sql = (self.db as! any SQLDatabase) + await XCTAssertThrowsErrorAsync(try await sql.raw("asdf").run()) { + XCTAssertTrue(($0 as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: $0))") + XCTAssertFalse(($0 as? any DatabaseError)?.isConstraintFailure ?? true, "\(String(reflecting: $0))") + XCTAssertFalse(($0 as? any DatabaseError)?.isConnectionClosed ?? true, "\(String(reflecting: $0))") } - do { - try sql.create(table: "foo").column("name", type: .text, .unique).run().wait() - try sql.insert(into: "foo").columns("name").values("bar").run().wait() - try sql.insert(into: "foo").columns("name").values("bar").run().wait() - } catch let error as DatabaseError where error.isConstraintFailure { - // pass - } catch { - XCTFail("\(error)") + try await sql.drop(table: "foo").ifExists().run() + try await sql.create(table: "foo").column("name", type: .text, .unique).run() + try await sql.insert(into: "foo").columns("name").values("bar").run() + await XCTAssertThrowsErrorAsync(try await sql.insert(into: "foo").columns("name").values("bar").run()) { + XCTAssertTrue(($0 as? any DatabaseError)?.isConstraintFailure ?? false, "\(String(reflecting: $0))") + XCTAssertFalse(($0 as? any DatabaseError)?.isSyntaxError ?? true, "\(String(reflecting: $0))") + XCTAssertFalse(($0 as? any DatabaseError)?.isConnectionClosed ?? true, "\(String(reflecting: $0))") } - do { - try self.mysql.withConnection { conn in - conn.close().flatMap { - conn.sql().insert(into: "foo").columns("name").values("bar").run() - } - }.wait() - } catch let error as DatabaseError where error.isConnectionClosed { - // pass - } catch let error { - let error = error - XCTFail("\(error)") + await XCTAssertThrowsErrorAsync(try await self.mysql.withConnection { conn in + conn.close().flatMap { + conn.sql().insert(into: "foo").columns("name").values("bar").run() + } + }.get()) { + XCTAssertTrue(($0 as? any DatabaseError)?.isConnectionClosed ?? false, "\(String(reflecting: $0))") + XCTAssertFalse(($0 as? any DatabaseError)?.isSyntaxError ?? true, "\(String(reflecting: $0))") + XCTAssertFalse(($0 as? any DatabaseError)?.isConstraintFailure ?? true, "\(String(reflecting: $0))") } } - func testClarityModel() throws { - final class Clarity: Model { + func testClarityModel() async throws { + final class Clarity: Model, @unchecked Sendable { static let schema = "clarities" - @ID(custom: .id, generatedBy: .database) - var id: Int? - - @Field(key: "at") - var at: Date - - @Field(key: "cloud_condition") - var cloudCondition: Int - - @Field(key: "wind_condition") - var windCondition: Int - - @Field(key: "rain_condition") - var rainCondition: Int - - @Field(key: "day_condition") - var daylightCondition: Int - - @Field(key: "sky_temperature") - var skyTemperature: Double? - - @Field(key: "sensor_temperature") - var sensorTemperature: Double? - - @Field(key: "ambient_temperature") - var ambientTemperature: Double - - @Field(key: "dewpoint_temperature") - var dewpointTemperature: Double - - @Field(key: "wind_speed") - var windSpeed: Double? - - @Field(key: "humidity") - var humidity: Double - - @Field(key: "daylight") - var daylight: Int - - @Field(key: "rain") - var rain: Bool - - @Field(key: "wet") - var wet: Bool - - @Field(key: "heater") - var heater: Double - - @Field(key: "close_requested") - var closeRequested: Bool - - init() { } + @ID(custom: .id, generatedBy: .database) var id: Int? + @Field(key: "at") var at: Date + @Field(key: "cloud_condition") var cloudCondition: Int + @Field(key: "wind_condition") var windCondition: Int + @Field(key: "rain_condition") var rainCondition: Int + @Field(key: "day_condition") var daylightCondition: Int + @Field(key: "sky_temperature") var skyTemperature: Double? + @Field(key: "sensor_temperature") var sensorTemperature: Double? + @Field(key: "ambient_temperature") var ambientTemperature: Double + @Field(key: "dewpoint_temperature") var dewpointTemperature: Double + @Field(key: "wind_speed") var windSpeed: Double? + @Field(key: "humidity") var humidity: Double + @Field(key: "daylight") var daylight: Int + @Field(key: "rain") var rain: Bool + @Field(key: "wet") var wet: Bool + @Field(key: "heater") var heater: Double + @Field(key: "close_requested") var closeRequested: Bool + + init() {} } - struct CreateClarity: Migration { - func prepare(on database: Database) -> EventLoopFuture { - return database.schema("clarities") + struct CreateClarity: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema("clarities") .field("id", .int, .identifier(auto: true)) .field("at", .datetime, .required) .field("cloud_condition", .int, .required) @@ -155,16 +158,15 @@ final class FluentMySQLDriverTests: XCTestCase { .create() } - func revert(on database: Database) -> EventLoopFuture { - return database.schema("clarities").delete() + func revert(on database: any Database) async throws { + try await database.schema("clarities").delete() } } - defer { try? CreateClarity().revert(on: self.db).wait() } - try CreateClarity().prepare(on: self.db).wait() + try await CreateClarity().prepare(on: self.db) - let now = Date() do { + let now = Date() let clarity = Clarity() clarity.at = now clarity.cloudCondition = 1 @@ -182,31 +184,34 @@ final class FluentMySQLDriverTests: XCTestCase { clarity.wet = true clarity.heater = 10 clarity.closeRequested = false - try clarity.create(on: self.db).wait() - } - do { - let clarity = try Clarity.query(on: self.db).first().wait()! - XCTAssertEqual(clarity.at.description, now.description) - XCTAssertEqual(clarity.cloudCondition, 1) - XCTAssertEqual(clarity.windCondition, 2) - XCTAssertEqual(clarity.rainCondition, 3) - XCTAssertEqual(clarity.daylightCondition, 4) - XCTAssertEqual(clarity.skyTemperature, nil) - XCTAssertEqual(clarity.sensorTemperature, nil) - XCTAssertEqual(clarity.ambientTemperature, 20.0) - XCTAssertEqual(clarity.dewpointTemperature, -3.0) - XCTAssertEqual(clarity.windSpeed, nil) - XCTAssertEqual(clarity.humidity, 59.1) - XCTAssertEqual(clarity.daylight, 12) - XCTAssertEqual(clarity.rain, false) - XCTAssertEqual(clarity.wet, true) - XCTAssertEqual(clarity.heater, 10) - XCTAssertEqual(clarity.closeRequested, false) + try await clarity.create(on: self.db) + + let dbClarity = try await Clarity.query(on: self.db).first() + XCTAssertEqual(dbClarity?.at.description, now.description) + XCTAssertEqual(dbClarity?.cloudCondition, 1) + XCTAssertEqual(dbClarity?.windCondition, 2) + XCTAssertEqual(dbClarity?.rainCondition, 3) + XCTAssertEqual(dbClarity?.daylightCondition, 4) + XCTAssertEqual(dbClarity?.skyTemperature, nil) + XCTAssertEqual(dbClarity?.sensorTemperature, nil) + XCTAssertEqual(dbClarity?.ambientTemperature, 20.0) + XCTAssertEqual(dbClarity?.dewpointTemperature, -3.0) + XCTAssertEqual(dbClarity?.windSpeed, nil) + XCTAssertEqual(dbClarity?.humidity, 59.1) + XCTAssertEqual(dbClarity?.daylight, 12) + XCTAssertEqual(dbClarity?.rain, false) + XCTAssertEqual(dbClarity?.wet, true) + XCTAssertEqual(dbClarity?.heater, 10) + XCTAssertEqual(dbClarity?.closeRequested, false) + } catch { + try? await CreateClarity().revert(on: self.db) + throw error } + try await CreateClarity().revert(on: self.db) } - func testBoolFilter() throws { - final class Clarity: Model { + func testBoolFilter() async throws { + final class Clarity: Model, @unchecked Sendable { static let schema = "clarities" @ID(custom: .id, generatedBy: .database) @@ -215,48 +220,47 @@ final class FluentMySQLDriverTests: XCTestCase { @Field(key: "rain") var rain: Bool - init() { } + init() {} init(rain: Bool) { self.rain = rain } } - struct CreateClarity: Migration { - func prepare(on database: Database) -> EventLoopFuture { - return database.schema("clarities") + struct CreateClarity: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema("clarities") .field("id", .int, .identifier(auto: true)) .field("rain", .bool, .required) .create() } - func revert(on database: Database) -> EventLoopFuture { - return database.schema("clarities").delete() + func revert(on database: any Database) async throws { + try await database.schema("clarities").delete() } } - defer { try? CreateClarity().revert(on: self.db).wait() } - try CreateClarity().prepare(on: self.db).wait() + try await CreateClarity().prepare(on: self.db) - let trueValue = Clarity(rain: true) - let falseValue = Clarity(rain: false) + do { + let trueValue = Clarity(rain: true) + let falseValue = Clarity(rain: false) - try trueValue.save(on: self.db).wait() - try falseValue.save(on: self.db).wait() + try await trueValue.save(on: self.db) + try await falseValue.save(on: self.db) - try XCTAssertEqual(Clarity.query(on: self.db).count().wait(), 2) - try XCTAssertEqual( - Clarity.query(on: self.db).filter(\.$rain == true).first().wait()?.id, - trueValue.id - ) - try XCTAssertEqual( - Clarity.query(on: self.db).filter(\.$rain == false).first().wait()?.id, - falseValue.id - ) + await XCTAssertEqualAsync(try await Clarity.query(on: self.db).count(), 2) + await XCTAssertEqualAsync(try await Clarity.query(on: self.db).filter(\.$rain == true).first()?.id, trueValue.id) + await XCTAssertEqualAsync(try await Clarity.query(on: self.db).filter(\.$rain == false).first()?.id, falseValue.id) + } catch { + try? await CreateClarity().revert(on: self.db) + throw error + } + try await CreateClarity().revert(on: self.db) } - func testDateDecoding() throws { - final class Clarity: Model { + func testDateDecoding() async throws { + final class Clarity: Model, @unchecked Sendable { static let schema = "clarities" @ID(custom: .id, generatedBy: .database) @@ -265,49 +269,54 @@ final class FluentMySQLDriverTests: XCTestCase { @Field(key: "date") var date: Date - init() { } + init() {} init(date: Date) { self.date = date } } - struct CreateClarity: Migration { - func prepare(on database: Database) -> EventLoopFuture { - return database.schema("clarities") + struct CreateClarity: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema("clarities") .field("id", .int, .identifier(auto: true)) .field("date", .date, .required) .create() } - func revert(on database: Database) -> EventLoopFuture { - return database.schema("clarities").delete() + func revert(on database: any Database) async throws { + try await database.schema("clarities").delete() } } - defer { try? CreateClarity().revert(on: self.db).wait() } - try CreateClarity().prepare(on: self.db).wait() - - let formatter = DateFormatter() - formatter.timeZone = TimeZone(secondsFromGMT: 0)! - formatter.dateFormat = "yyyy-MM-dd" - - let firstDate = formatter.date(from: "2020-01-01")! - let secondDate = formatter.date(from: "1994-05-23")! - let trueValue = Clarity(date: firstDate) - let falseValue = Clarity(date: secondDate) - - try trueValue.save(on: self.db).wait() - try falseValue.save(on: self.db).wait() - - let receivedModels = try Clarity.query(on: self.db).all().wait() - XCTAssertEqual(receivedModels.count, 2) - XCTAssertEqual(receivedModels[0].date, firstDate) - XCTAssertEqual(receivedModels[1].date, secondDate) + try await CreateClarity().prepare(on: self.db) + + do { + let formatter = DateFormatter() + formatter.timeZone = TimeZone(secondsFromGMT: 0)! + formatter.dateFormat = "yyyy-MM-dd" + + let firstDate = formatter.date(from: "2020-01-01")! + let secondDate = formatter.date(from: "1994-05-23")! + let trueValue = Clarity(date: firstDate) + let falseValue = Clarity(date: secondDate) + + try await trueValue.save(on: self.db) + try await falseValue.save(on: self.db) + + let receivedModels = try await Clarity.query(on: self.db).all() + XCTAssertEqual(receivedModels.count, 2) + XCTAssertEqual(receivedModels[0].date, firstDate) + XCTAssertEqual(receivedModels[1].date, secondDate) + } catch { + try? await CreateClarity().revert(on: self.db) + throw error + } + try await CreateClarity().revert(on: self.db) } - func testChar36UUID() throws { - final class Foo: Model { + func testChar36UUID() async throws { + final class Foo: Model, @unchecked Sendable { static let schema = "foos" @ID(key: .id) @@ -317,59 +326,138 @@ final class FluentMySQLDriverTests: XCTestCase { var _bar: String var bar: UUID { - get { - UUID(uuidString: self._bar)! - } - set { - self._bar = newValue.uuidString - } + get { UUID(uuidString: self._bar)! } + set { self._bar = newValue.uuidString } } - init() { } + init() {} init(id: UUID? = nil, bar: UUID) { self.id = id self.bar = bar } } - - try self.db.schema("foos") + + try await self.mysql.sql().drop(table: "foos").ifExists().run() + try await self.db.schema("foos") .id() .field("bar", .sql(raw: "CHAR(36)"), .required) .create() - .wait() - defer { - try! self.db.schema("foos").delete().wait() - } - let foo = Foo(bar: .init()) - try foo.create(on: self.db).wait() - XCTAssertNotNil(foo.id) + do { + let foo = Foo(bar: .init()) + try await foo.create(on: self.db) + XCTAssertNotNil(foo.id) - let fetched = try Foo.find(foo.id, on: self.db).wait() - XCTAssertEqual(fetched?.bar, foo.bar) + let fetched = try await Foo.find(foo.id, on: self.db) + XCTAssertEqual(fetched?.bar, foo.bar) + } catch { + try? await self.db.schema("foos").delete() + } + try await self.db.schema("foos").delete() } - - var benchmarker: FluentBenchmarker { - return .init(databases: self.dbs) + + func testBindingEncodeFailures() async throws { + struct FailingDataType: Codable, MySQLDataConvertible, Equatable { + init() {} + init?(mysqlData: MySQLData) { nil } + var mysqlData: MySQLData? { nil } + } + final class M: Model, @unchecked Sendable { + static let schema = "s" + @ID var id + @Field(key: "f") var f: FailingDataType + init() { self.f = .init() } + } + await XCTAssertThrowsErrorAsync(try await M.query(on: self.db).filter(\.$f == .init()).all()) { + XCTAssertNotNil($0 as? EncodingError, String(reflecting: $0)) + } + + await XCTAssertThrowsErrorAsync(try await self.db.schema("s").field("f", .custom(SQLBind(FailingDataType()))).create()) { + XCTAssertNotNil($0 as? EncodingError, String(reflecting: $0)) + } } - var eventLoopGroup: EventLoopGroup! - var threadPool: NIOThreadPool! - var dbs: Databases! - var db: Database { - self.benchmarker.database + + func testMiscSQLDatabaseSupport() async throws { + XCTAssertEqual((self.db as? any SQLDatabase)?.queryLogLevel, .debug) + await XCTAssertEqualAsync(try await (self.db as? any SQLDatabase)?.withSession { $0.dialect.name }, (self.db as? any SQLDatabase)?.dialect.name) + + final class M: Model, @unchecked Sendable { + static let schema = "s" + @ID var id + @OptionalField(key: "k") var k: Int? + init() {} + } + try await (self.db as? any SQLDatabase)?.drop(table: M.schema).ifExists().run() + try await (self.db as? any SQLDatabase)?.create(table: M.schema).column("id", type: .custom(SQLRaw("varbinary(16)")), .primaryKey(autoIncrement: false)).run() + await XCTAssertNilAsync(try await (self.db as? any SQLDatabase)?.select().column("id").from(M.schema).first(decodingFluent: M.self)?.k) + try await (self.db as? any SQLDatabase)?.drop(table: M.schema).run() } - var mysql: MySQLDatabase { - self.db as! MySQLDatabase + + func testLastInsertRow() { + XCTAssertNotNil(LastInsertRow(lastInsertID: 0, customIDKey: nil).description) + let row = LastInsertRow(lastInsertID: nil, customIDKey: nil) + XCTAssertNotNil(row.description) + XCTAssertEqual(row.schema("").description, row.description) + XCTAssertFalse(try row.decodeNil(.id)) + XCTAssertThrowsError(try row.decode(.id, as: String.self)) { + guard case .typeMismatch(_, _) = $0 as? DecodingError else { + return XCTFail("Expected DecodingError.typeMismatch, but got \(String(reflecting: $0))") + } + } + XCTAssertThrowsError(try row.decode("foo", as: Int.self)) { + guard case .keyNotFound(let key, _) = $0 as? DecodingError else { + return XCTFail("Expected DecodingError.keyNotFound, but got \(String(reflecting: $0))") + } + XCTAssertEqual(key.stringValue, "foo") + } + XCTAssertThrowsError(try row.decode(.id, as: Int.self)) { + guard case .valueNotFound(_, _) = $0 as? DecodingError else { + return XCTFail("Expected DecodingError.valueNotFound, but got \(String(reflecting: $0))") + } + } } + + func testNeverInvokedDatabaseOutputCodePath() async throws { + final class M: Model, @unchecked Sendable { + static let schema = "s" + @ID var id + init() {} + } + try await (self.db as? any SQLDatabase)?.drop(table: M.schema).ifExists().run() + try await self.db.schema(M.schema).id().create() + do { + try await M().create(on: self.db) + try await self.db.execute(query: M.query(on: self.db).field(\.$id).query, onOutput: { XCTAssert((try? $0.decodeNil("not a real key")) ?? false) }).get() + } catch { + try? await self.db.schema(M.schema).delete() + throw error + } + try await self.db.schema(M.schema).delete() + } + + func testMiscConfigMethods() { + XCTAssertNotNil(try DatabaseConfigurationFactory.mysql(unixDomainSocketPath: "/", username: "", password: "")) + XCTAssertNoThrow(try DatabaseConfigurationFactory.mysql(url: "mysql://user@host/db")) + XCTAssertThrowsError(try DatabaseConfigurationFactory.mysql(url: "notmysql://foo@bar")) + XCTAssertThrowsError(try DatabaseConfigurationFactory.mysql(url: "not$a$valid$url://")) + XCTAssertNoThrow(try DatabaseConfigurationFactory.mysql(url: URL(string: "mysql://user@host/db")!)) + XCTAssertThrowsError(try DatabaseConfigurationFactory.mysql(url: URL(string: "notmysql://foo@bar")!)) + XCTAssertEqual(DatabaseID.mysql.string, "mysql") + } + + var benchmarker: FluentBenchmarker { .init(databases: self.dbs) } + var eventLoopGroup: any EventLoopGroup { MultiThreadedEventLoopGroup.singleton } + var threadPool: NIOThreadPool { NIOThreadPool.singleton } + var dbs: Databases! + var db: any Database { self.benchmarker.database } + var mysql: any MySQLDatabase { self.db as! any MySQLDatabase } override func setUpWithError() throws { try super.setUpWithError() XCTAssert(isLoggingConfigured) - self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - self.threadPool = NIOThreadPool(numberOfThreads: System.coreCount) - self.dbs = Databases(threadPool: threadPool, on: self.eventLoopGroup) + self.dbs = Databases(threadPool: self.threadPool, on: self.eventLoopGroup) var tls = TLSConfiguration.makeClientConfiguration() tls.certificateVerification = .none @@ -398,8 +486,6 @@ final class FluentMySQLDriverTests: XCTestCase { override func tearDownWithError() throws { self.dbs.shutdown() - try self.threadPool.syncShutdownGracefully() - try self.eventLoopGroup.syncShutdownGracefully() try super.tearDownWithError() } @@ -411,13 +497,13 @@ extension DatabaseID { } func env(_ name: String) -> String? { - return ProcessInfo.processInfo.environment[name] + ProcessInfo.processInfo.environment[name] } let isLoggingConfigured: Bool = { LoggingSystem.bootstrap { label in var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .debug + handler.logLevel = env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? .info return handler } return true diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 401c38e..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,40 +0,0 @@ -version: '3' - -services: - mariadb-a: - image: mariadb - environment: - MYSQL_ALLOW_EMPTY_PASSWORD: "true" - MYSQL_DATABASE: vapor_database - MYSQL_USER: vapor_username - MYSQL_PASSWORD: vapor_password - ports: - - 3306:3306 - mariadb-b: - image: mariadb - environment: - MYSQL_ALLOW_EMPTY_PASSWORD: "true" - MYSQL_DATABASE: vapor_database - MYSQL_USER: vapor_username - MYSQL_PASSWORD: vapor_password - ports: - - 3307:3306 - mysql-a: - image: mysql:8.0 - environment: - MYSQL_ALLOW_EMPTY_PASSWORD: "true" - MYSQL_DATABASE: vapor_database - MYSQL_USER: vapor_username - MYSQL_PASSWORD: vapor_password - ports: - - 3306:3306 - mysql-b: - image: mysql:8.0 - environment: - MYSQL_ALLOW_EMPTY_PASSWORD: "true" - MYSQL_DATABASE: vapor_database - MYSQL_USER: vapor_username - MYSQL_PASSWORD: vapor_password - ports: - - 3307:3306 -