├── .editorconfig ├── .gitignore ├── .spi.yml ├── Sources └── FluentMySQLDriver │ ├── FluentMySQLError.swift │ ├── Docs.docc │ ├── index.md │ ├── theme-settings.json │ └── Resources │ │ └── vapor-fluentmysqldriver-logo.svg │ ├── MySQLError+Database.swift │ ├── Exports.swift │ ├── FluentMySQLDriver.swift │ ├── MySQLRow+Database.swift │ ├── MySQLConverterDelegate.swift │ ├── FluentMySQLDatabase.swift │ └── FluentMySQLConfiguration.swift ├── .github ├── CODEOWNERS └── workflows │ ├── api-docs.yml │ └── test.yml ├── LICENSE ├── README.md ├── Package.swift ├── .swift-format └── Tests └── FluentMySQLDriverTests └── FluentMySQLDriverTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.swift] 2 | indent_style = space 3 | indent_size = 4 4 | tab_width = 4 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | Packages 3 | *.xcodeproj 4 | .DS_Store 5 | .swift-version 6 | Package.pins 7 | Package.resolved 8 | DerivedData 9 | .swiftpm 10 | Tests/LinuxMain.swift 11 | 12 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | metadata: 3 | authors: "Maintained by the Vapor Core Team with hundreds of contributions from the Vapor Community." 4 | external_links: 5 | documentation: "https://api.vapor.codes/fluentmysqldriver/documentation/fluentmysqldriver/" 6 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/FluentMySQLError.swift: -------------------------------------------------------------------------------- 1 | /// Errors that may be thrown by this package. 2 | enum FluentMySQLError: Error { 3 | /// ``FluentMySQLConfiguration/mysql(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encoder:decoder:)`` was 4 | /// invoked with an invalid input. 5 | case invalidURL(String) 6 | } 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gwynne 2 | /.github/CONTRIBUTING.md @gwynne @0xTim 3 | /.github/workflows/*.yml @gwynne @0xTim 4 | /.github/workflows/test.yml @gwynne 5 | /.spi.yml @gwynne @0xTim 6 | /.gitignore @gwynne @0xTim 7 | /LICENSE @gwynne @0xTim 8 | /README.md @gwynne @0xTim 9 | -------------------------------------------------------------------------------- /.github/workflows/api-docs.yml: -------------------------------------------------------------------------------- 1 | name: deploy-api-docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build-and-deploy: 9 | uses: vapor/api-docs/.github/workflows/build-and-deploy-docs-workflow.yml@main 10 | secrets: inherit 11 | with: 12 | package_name: fluent-mysql-driver 13 | modules: FluentMySQLDriver 14 | pathsToInvalidate: /fluentmysqldriver/* 15 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/Docs.docc/index.md: -------------------------------------------------------------------------------- 1 | # ``FluentMySQLDriver`` 2 | 3 | FluentMySQLDriver is a [FluentKit] driver for MySQL clients. 4 | 5 | ## Overview 6 | 7 | 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. 8 | 9 | [FluentKit]: https://github.com/vapor/fluent-kit 10 | [SQLKit]: https://github.com/vapor/sql-kit 11 | [MySQLKit]: https://github.com/vapor/mysql-kit 12 | [MySQLNIO]: https://github.com/vapor/mysql-nio 13 | [AsyncKit]: https://github.com/vapor/async-kit 14 | 15 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/MySQLError+Database.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import MySQLNIO 3 | 4 | /// Conform `MySQLError` to `DatabaseError`. 5 | extension MySQLNIO.MySQLError: FluentKit.DatabaseError { 6 | // See `DatabaseError.isSyntaxError`. 7 | public var isSyntaxError: Bool { 8 | switch self { 9 | case .invalidSyntax(_): 10 | true 11 | default: 12 | false 13 | } 14 | } 15 | 16 | // See `DatabaseError.isConstraintFailure`. 17 | public var isConstraintFailure: Bool { 18 | switch self { 19 | case .duplicateEntry(_): 20 | true 21 | default: 22 | false 23 | } 24 | } 25 | 26 | // See `DatabaseError.isConnectionClosed`. 27 | public var isConnectionClosed: Bool { 28 | switch self { 29 | case .closed: 30 | true 31 | default: 32 | false 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Qutheory, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/Docs.docc/theme-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": { 3 | "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, 4 | "border-radius": "0", 5 | "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, 6 | "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, 7 | "color": { 8 | "mysql-turquoise": "#02758f", 9 | "documentation-intro-fill": "radial-gradient(circle at top, var(--color-mysql-turquoise) 30%, #000 100%)", 10 | "documentation-intro-accent": "var(--color-mysql-turquoise)", 11 | "documentation-intro-eyebrow": "white", 12 | "documentation-intro-figure": "white", 13 | "documentation-intro-title": "white", 14 | "logo-base": { "dark": "#fff", "light": "#000" }, 15 | "logo-shape": { "dark": "#000", "light": "#fff" }, 16 | "fill": { "dark": "#000", "light": "#fff" } 17 | }, 18 | "icons": { "technology": "/fluentmysqldriver/images/FluentMySQLDriver/vapor-fluentmysqldriver-logo.svg" } 19 | }, 20 | "features": { 21 | "quickNavigation": { "enable": true }, 22 | "i18n": { "enable": true } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/Exports.swift: -------------------------------------------------------------------------------- 1 | @_documentation(visibility: internal) @_exported import FluentKit 2 | 3 | @_documentation(visibility: internal) @_exported import struct Foundation.URL 4 | 5 | @_documentation(visibility: internal) @_exported import struct MySQLKit.MySQLConfiguration 6 | @_documentation(visibility: internal) @_exported import struct MySQLKit.MySQLConnectionSource 7 | @_documentation(visibility: internal) @_exported import struct MySQLKit.MySQLDataEncoder 8 | @_documentation(visibility: internal) @_exported import struct MySQLKit.MySQLDataDecoder 9 | 10 | @_documentation(visibility: internal) @_exported import class MySQLNIO.MySQLConnection 11 | @_documentation(visibility: internal) @_exported import enum MySQLNIO.MySQLError 12 | @_documentation(visibility: internal) @_exported import struct MySQLNIO.MySQLData 13 | @_documentation(visibility: internal) @_exported import protocol MySQLNIO.MySQLDatabase 14 | @_documentation(visibility: internal) @_exported import protocol MySQLNIO.MySQLDataConvertible 15 | @_documentation(visibility: internal) @_exported import struct MySQLNIO.MySQLRow 16 | 17 | @_documentation(visibility: internal) @_exported import struct NIOSSL.TLSConfiguration 18 | 19 | extension DatabaseID { 20 | /// A default `DatabaseID` to use for MySQL databases. 21 | public static var mysql: DatabaseID { 22 | .init(string: "mysql") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/FluentMySQLDriver.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import AsyncKit 2 | import FluentKit 3 | import MySQLKit 4 | import NIOCore 5 | 6 | /// An implementation of `DatabaseDriver` for MySQL . 7 | struct FluentMySQLDriver: DatabaseDriver { 8 | /// The connection pool set for this driver. 9 | let pool: EventLoopGroupConnectionPool 10 | 11 | /// A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 12 | let encoder: MySQLDataEncoder 13 | 14 | /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 15 | let decoder: MySQLDataDecoder 16 | 17 | /// A logging level used for logging queries. 18 | let sqlLogLevel: Logger.Level? 19 | 20 | // See `DatabaseDriver.makeDatabase(with:)`. 21 | func makeDatabase(with context: DatabaseContext) -> any Database { 22 | FluentMySQLDatabase( 23 | database: self.pool.pool(for: context.eventLoop).database(logger: context.logger), 24 | encoder: self.encoder, 25 | decoder: self.decoder, 26 | queryLogLevel: self.sqlLogLevel, 27 | context: context, 28 | inTransaction: false 29 | ) 30 | } 31 | 32 | // See `DatabaseDriver.shutdown()`. 33 | func shutdown() { 34 | try? self.pool.syncShutdownGracefully() 35 | } 36 | 37 | func shutdownAsync() async { 38 | try? await self.pool.shutdownAsync() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/Docs.docc/Resources/vapor-fluentmysqldriver-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/MySQLRow+Database.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import MySQLKit 3 | import MySQLNIO 4 | 5 | extension SQLRow { 6 | /// Returns a `DatabaseOutput` for this row. 7 | /// 8 | /// - Returns: A `DatabaseOutput` instance. 9 | func databaseOutput() -> any DatabaseOutput { 10 | SQLRowDatabaseOutput(row: self, schema: nil) 11 | } 12 | } 13 | 14 | /// A `DatabaseOutput` implementation for generic `SQLRow`s. This should really be in FluentSQL. 15 | private struct SQLRowDatabaseOutput: DatabaseOutput { 16 | /// The underlying row. 17 | let row: any SQLRow 18 | 19 | /// The most recently set schema value (see `DatabaseOutput.schema(_:)`). 20 | let schema: String? 21 | 22 | // See `CustomStringConvertible.description`. 23 | var description: String { 24 | String(describing: self.row) 25 | } 26 | 27 | /// Apply the current schema (if any) to the given `FieldKey` and convert to a column name. 28 | private func adjust(key: FieldKey) -> String { 29 | (self.schema.map { .prefix(.prefix(.string($0), "_"), key) } ?? key).description 30 | } 31 | 32 | // See `DatabaseOutput.schema(_:)`. 33 | func schema(_ schema: String) -> any DatabaseOutput { 34 | Self(row: self.row, schema: schema) 35 | } 36 | 37 | // See `DatabaseOutput.contains(_:)`. 38 | func contains(_ key: FieldKey) -> Bool { 39 | self.row.contains(column: self.adjust(key: key)) 40 | } 41 | 42 | // See `DatabaseOutput.decodeNil(_:)`. 43 | func decodeNil(_ key: FieldKey) throws -> Bool { 44 | try self.row.decodeNil(column: self.adjust(key: key)) 45 | } 46 | 47 | // See `DatabaseOutput.decode(_:as:)`. 48 | func decode(_ key: FieldKey, as: T.Type) throws -> T { 49 | try self.row.decode(column: self.adjust(key: key), as: T.self) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

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

12 | 13 |
14 | 15 | 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. 16 | 17 | [FluentKit]: https://github.com/vapor/fluent-kit 18 | [SQLKit]: https://github.com/vapor/sql-kit 19 | [MySQLKit]: https://github.com/vapor/mysql-kit 20 | [MySQLNIO]: https://github.com/vapor/mysql-nio 21 | [AsyncKit]: https://github.com/vapor/async-kit 22 | 23 | ### Usage 24 | 25 | Use the SPM string to easily include the dependendency in your `Package.swift` file: 26 | 27 | ```swift 28 | .package(url: "https://github.com/vapor/fluent-mysql-driver.git", from: "4.0.0") 29 | ``` 30 | 31 | For additional information, see [the Fluent documentation](https://docs.vapor.codes/fluent/overview/). 32 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "fluent-mysql-driver", 6 | platforms: [ 7 | .macOS(.v10_15), 8 | .iOS(.v13), 9 | .watchOS(.v6), 10 | .tvOS(.v13), 11 | ], 12 | products: [ 13 | .library(name: "FluentMySQLDriver", targets: ["FluentMySQLDriver"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.52.2"), 17 | .package(url: "https://github.com/vapor/mysql-kit.git", from: "4.10.0"), 18 | .package(url: "https://github.com/apple/swift-log.git", from: "1.6.4"), 19 | .package(url: "https://github.com/vapor/async-kit.git", from: "1.21.0"), 20 | ], 21 | targets: [ 22 | .target( 23 | name: "FluentMySQLDriver", 24 | dependencies: [ 25 | .product(name: "FluentKit", package: "fluent-kit"), 26 | .product(name: "FluentSQL", package: "fluent-kit"), 27 | .product(name: "Logging", package: "swift-log"), 28 | .product(name: "MySQLKit", package: "mysql-kit"), 29 | .product(name: "AsyncKit", package: "async-kit"), 30 | ], 31 | swiftSettings: swiftSettings 32 | ), 33 | .testTarget( 34 | name: "FluentMySQLDriverTests", 35 | dependencies: [ 36 | .product(name: "FluentBenchmark", package: "fluent-kit"), 37 | .target(name: "FluentMySQLDriver"), 38 | ], 39 | swiftSettings: swiftSettings 40 | ), 41 | ] 42 | ) 43 | 44 | var swiftSettings: [SwiftSetting] { [ 45 | .enableUpcomingFeature("ExistentialAny"), 46 | .enableUpcomingFeature("MemberImportVisibility"), 47 | .enableUpcomingFeature("ConciseMagicFile"), 48 | .enableUpcomingFeature("ForwardTrailingClosures"), 49 | .enableUpcomingFeature("DisableOutwardActorInference"), 50 | .enableExperimentalFeature("StrictConcurrency=complete"), 51 | ] } 52 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/MySQLConverterDelegate.swift: -------------------------------------------------------------------------------- 1 | import FluentSQL 2 | 3 | /// An implementation of `SQLConverterDelegate` for MySQL . 4 | struct MySQLConverterDelegate: SQLConverterDelegate { 5 | // See `SQLConverterDelegate.customDataType(_:)`. 6 | func customDataType(_ dataType: DatabaseSchema.DataType) -> (any SQLExpression)? { 7 | switch dataType { 8 | case .string: SQLRaw("VARCHAR(255)") 9 | case .datetime: SQLRaw("DATETIME(6)") 10 | case .uuid: SQLRaw("VARBINARY(16)") 11 | case .bool: SQLRaw("BOOL") 12 | case .array: SQLRaw("JSON") 13 | default: nil 14 | } 15 | } 16 | 17 | // See `SQLConverterDelegate.beforeConvert(_:)`. 18 | func beforeConvert(_ schema: DatabaseSchema) -> DatabaseSchema { 19 | var copy = schema 20 | // convert field foreign keys to table-level foreign keys 21 | // since mysql doesn't support the `REFERENCES` syntax 22 | // 23 | // https://stackoverflow.com/questions/14672872/difference-between-references-and-foreign-key 24 | copy.createFields = schema.createFields.map { field -> DatabaseSchema.FieldDefinition in 25 | switch field { 26 | case .definition(let name, let dataType, let constraints): 27 | return .definition( 28 | name: name, 29 | dataType: dataType, 30 | constraints: constraints.filter { constraint in 31 | switch constraint { 32 | case .foreignKey(let schema, let space, let field, let onDelete, let onUpdate): 33 | copy.createConstraints.append( 34 | .constraint( 35 | .foreignKey([name], schema, space: space, [field], onDelete: onDelete, onUpdate: onUpdate), 36 | name: nil 37 | )) 38 | return false 39 | default: 40 | return true 41 | } 42 | } 43 | ) 44 | case .custom(let any): 45 | return .custom(any) 46 | } 47 | } 48 | return copy 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "fileScopedDeclarationPrivacy" : { 3 | "accessLevel" : "private" 4 | }, 5 | "indentConditionalCompilationBlocks" : false, 6 | "indentSwitchCaseLabels" : false, 7 | "indentation" : { 8 | "spaces" : 4 9 | }, 10 | "lineBreakAroundMultilineExpressionChainComponents" : false, 11 | "lineBreakBeforeControlFlowKeywords" : false, 12 | "lineBreakBeforeEachArgument" : false, 13 | "lineBreakBeforeEachGenericRequirement" : false, 14 | "lineBreakBetweenDeclarationAttributes" : false, 15 | "lineLength" : 150, 16 | "maximumBlankLines" : 1, 17 | "multiElementCollectionTrailingCommas" : true, 18 | "noAssignmentInExpressions" : { 19 | "allowedFunctions" : [ 20 | ] 21 | }, 22 | "prioritizeKeepingFunctionOutputTogether" : false, 23 | "reflowMultilineStringLiterals" : { 24 | "never" : { 25 | } 26 | }, 27 | "respectsExistingLineBreaks" : true, 28 | "rules" : { 29 | "AllPublicDeclarationsHaveDocumentation" : false, 30 | "AlwaysUseLiteralForEmptyCollectionInit" : true, 31 | "AlwaysUseLowerCamelCase" : true, 32 | "AmbiguousTrailingClosureOverload" : true, 33 | "AvoidRetroactiveConformances" : true, 34 | "BeginDocumentationCommentWithOneLineSummary" : false, 35 | "DoNotUseSemicolons" : true, 36 | "DontRepeatTypeInStaticProperties" : true, 37 | "FileScopedDeclarationPrivacy" : true, 38 | "FullyIndirectEnum" : true, 39 | "GroupNumericLiterals" : true, 40 | "IdentifiersMustBeASCII" : true, 41 | "NeverForceUnwrap" : false, 42 | "NeverUseForceTry" : false, 43 | "NeverUseImplicitlyUnwrappedOptionals" : false, 44 | "NoAccessLevelOnExtensionDeclaration" : true, 45 | "NoAssignmentInExpressions" : true, 46 | "NoBlockComments" : true, 47 | "NoCasesWithOnlyFallthrough" : true, 48 | "NoEmptyLinesOpeningClosingBraces" : false, 49 | "NoEmptyTrailingClosureParentheses" : true, 50 | "NoLabelsInCasePatterns" : true, 51 | "NoLeadingUnderscores" : false, 52 | "NoParensAroundConditions" : true, 53 | "NoPlaygroundLiterals" : true, 54 | "NoVoidReturnOnFunctionSignature" : true, 55 | "OmitExplicitReturns" : false, 56 | "OneCasePerLine" : true, 57 | "OneVariableDeclarationPerLine" : true, 58 | "OnlyOneTrailingClosureArgument" : true, 59 | "OrderedImports" : true, 60 | "ReplaceForEachWithForLoop" : true, 61 | "ReturnVoidInsteadOfEmptyTuple" : true, 62 | "TypeNamesShouldBeCapitalized" : true, 63 | "UseEarlyExits" : true, 64 | "UseExplicitNilCheckInConditions" : true, 65 | "UseLetInEveryBoundCaseVariable" : true, 66 | "UseShorthandTypeNames" : true, 67 | "UseSingleLinePropertyGetter" : true, 68 | "UseSynthesizedInitializer" : true, 69 | "UseTripleSlashForDocumentationComments" : true, 70 | "UseWhereClausesInForLoops" : false, 71 | "ValidateDocumentationComments" : false 72 | }, 73 | "spacesAroundRangeFormationOperators" : true, 74 | "spacesBeforeEndOfLineComments" : 1, 75 | "tabWidth" : 4, 76 | "version" : 1 77 | } 78 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | concurrency: 3 | group: ${{ github.workflow }}-${{ github.ref }} 4 | cancel-in-progress: true 5 | on: 6 | pull_request: { types: [opened, reopened, synchronize, ready_for_review] } 7 | push: { branches: [ main ] } 8 | 9 | env: 10 | LOG_LEVEL: info 11 | SWIFT_DETERMINISTIC_HASHING: 1 12 | MYSQL_HOSTNAME: 'mysql-a' 13 | MYSQL_HOSTNAME_A: 'mysql-a' 14 | MYSQL_HOSTNAME_B: 'mysql-b' 15 | MYSQL_DATABASE: 'test_database' 16 | MYSQL_DATABASE_A: 'test_database' 17 | MYSQL_DATABASE_B: 'test_database' 18 | MYSQL_USERNAME: 'test_username' 19 | MYSQL_USERNAME_A: 'test_username' 20 | MYSQL_USERNAME_B: 'test_username' 21 | MYSQL_PASSWORD: 'test_password' 22 | MYSQL_PASSWORD_A: 'test_password' 23 | MYSQL_PASSWORD_B: 'test_password' 24 | 25 | jobs: 26 | api-breakage: 27 | if: ${{ github.event_name == 'pull_request' && !(github.event.pull_request.draft || false) }} 28 | runs-on: ubuntu-latest 29 | container: swift:noble 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v5 33 | with: { 'fetch-depth': 0 } 34 | - name: API breaking changes 35 | run: | 36 | git config --global --add safe.directory "${GITHUB_WORKSPACE}" 37 | swift package diagnose-api-breaking-changes origin/main 38 | 39 | linux-unit: 40 | if: ${{ !(github.event.pull_request.draft || false) }} 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | dbimage: 45 | - mysql:5.7 46 | - mysql:8.3 47 | - mysql:9.4 48 | - mariadb:10.6 49 | - mariadb:12 50 | - percona:8.0 51 | runner: 52 | # List is deliberately incomplete; we want to avoid running 50 jobs on every commit 53 | - swift:5.10-jammy 54 | - swift:6.0-noble 55 | - swift:6.1-noble 56 | container: ${{ matrix.runner }} 57 | runs-on: ubuntu-latest 58 | services: 59 | mysql-a: 60 | image: ${{ matrix.dbimage }} 61 | env: 62 | MYSQL_ALLOW_EMPTY_PASSWORD: "true" 63 | MYSQL_USER: test_username 64 | MYSQL_PASSWORD: test_password 65 | MYSQL_DATABASE: test_database 66 | mysql-b: 67 | image: ${{ matrix.dbimage }} 68 | env: 69 | MYSQL_ALLOW_EMPTY_PASSWORD: "true" 70 | MYSQL_USER: test_username 71 | MYSQL_PASSWORD: test_password 72 | MYSQL_DATABASE: test_database 73 | steps: 74 | - name: Ensure curl is available 75 | run: apt-get update -y && apt-get install -y curl 76 | - name: Check out package 77 | uses: actions/checkout@v5 78 | - name: Run local tests with coverage 79 | run: swift test --enable-code-coverage 80 | - name: Submit coverage report to Codecov.io 81 | uses: vapor/swift-codecov-action@v0.3 82 | with: 83 | codecov_token: ${{ secrets.CODECOV_TOKEN }} 84 | 85 | macos-unit: 86 | if: ${{ !(github.event.pull_request.draft || false) }} 87 | strategy: 88 | fail-fast: false 89 | matrix: 90 | include: 91 | - macos-version: macos-15 92 | xcode-version: latest-stable 93 | runs-on: ${{ matrix.macos-version }} 94 | steps: 95 | - name: Select latest available Xcode 96 | uses: maxim-lobanov/setup-xcode@v1 97 | with: 98 | xcode-version: ${{ matrix.xcode-version }} 99 | - name: Install MySQL server from Homebrew 100 | run: brew install mysql && brew link --force mysql 101 | - name: Start MySQL server 102 | run: brew services start mysql 103 | - name: Wait for MySQL server to be ready 104 | run: until echo | mysql -uroot; do sleep 1; done 105 | timeout-minutes: 5 106 | - name: Set up MySQL databases and privileges 107 | run: | 108 | mysql -uroot --batch <<-'SQL' 109 | CREATE USER test_username@localhost IDENTIFIED BY 'test_password'; 110 | CREATE DATABASE test_database_a; GRANT ALL PRIVILEGES ON test_database_a.* TO test_username@localhost; 111 | CREATE DATABASE test_database_b; GRANT ALL PRIVILEGES ON test_database_b.* TO test_username@localhost; 112 | SQL 113 | - name: Check out code 114 | uses: actions/checkout@v5 115 | - name: Run tests 116 | run: swift test 117 | env: 118 | MYSQL_HOSTNAME_A: '127.0.0.1' 119 | MYSQL_HOSTNAME_B: '127.0.0.1' 120 | MYSQL_DATABASE_A: test_database_a 121 | MYSQL_DATABASE_B: test_database_b 122 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/FluentMySQLDatabase.swift: -------------------------------------------------------------------------------- 1 | import AsyncKit 2 | import FluentSQL 3 | import MySQLKit 4 | @preconcurrency import MySQLNIO 5 | 6 | /// A wrapper for a `MySQLDatabase` which provides `Database`, `SQLDatabase`, and forwarding `MySQLDatabase` 7 | /// conformances. 8 | struct FluentMySQLDatabase: Database, SQLDatabase, MySQLDatabase { 9 | /// The underlying database connection. 10 | let database: any MySQLDatabase 11 | 12 | /// A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 13 | let encoder: MySQLDataEncoder 14 | 15 | /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 16 | let decoder: MySQLDataDecoder 17 | 18 | /// A logging level used for logging queries. 19 | let queryLogLevel: Logger.Level? 20 | 21 | /// The `DatabaseContext` associated with this connection. 22 | let context: DatabaseContext 23 | 24 | /// Whether this is a transaction-specific connection. 25 | let inTransaction: Bool 26 | 27 | // See `Database.execute(query:onOutput:)`. 28 | func execute( 29 | query: DatabaseQuery, 30 | onOutput: @escaping @Sendable (any DatabaseOutput) -> Void 31 | ) -> EventLoopFuture { 32 | let expression = SQLQueryConverter(delegate: MySQLConverterDelegate()).convert(query) 33 | 34 | guard case .create = query.action, query.customIDKey != .string("") else { 35 | return self.execute(sql: expression, { onOutput($0.databaseOutput()) }) 36 | } 37 | // We can't access the query metadata if we route through SQLKit, so we have to duplicate MySQLKit's logic 38 | // in order to get the last insert ID without running an extra query. 39 | let (sql, binds) = self.serialize(expression) 40 | 41 | if let queryLogLevel = self.queryLogLevel { self.logger.log(level: queryLogLevel, "\(sql) \(binds)") } 42 | do { 43 | return try self.query( 44 | sql, binds.map { try self.encoder.encode($0) }, 45 | onRow: self.ignoreRow(_:), 46 | onMetadata: { onOutput(LastInsertRow(lastInsertID: $0.lastInsertID, customIDKey: query.customIDKey)) } 47 | ) 48 | } catch { 49 | return self.eventLoop.makeFailedFuture(error) 50 | } 51 | } 52 | 53 | /// This is here because it allows for full test coverage; it serves no actual purpose functionally. 54 | @Sendable /*private*/ func ignoreRow(_: MySQLRow) {} 55 | 56 | /// This is here because it allows for full test coverage; it serves no actual purpose functionally. 57 | @Sendable /*private*/ func ignoreRow(_: any SQLRow) {} 58 | 59 | // See `Database.execute(schema:)`. 60 | func execute(schema: DatabaseSchema) -> EventLoopFuture { 61 | let expression = SQLSchemaConverter(delegate: MySQLConverterDelegate()).convert(schema) 62 | 63 | return self.execute(sql: expression, self.ignoreRow(_:)) 64 | } 65 | 66 | // See `Database.execute(enum:)`. 67 | func execute(enum: DatabaseEnum) -> EventLoopFuture { 68 | self.eventLoop.makeSucceededVoidFuture() 69 | } 70 | 71 | // See `Database.transaction(_:)`. 72 | func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { 73 | self.inTransaction ? closure(self) : self.eventLoop.makeFutureWithTask { try await self.transaction { try await closure($0).get() } } 74 | } 75 | 76 | // See `Database.transaction(_:)`. 77 | func transaction(_ closure: @escaping @Sendable (any Database) async throws -> T) async throws -> T { 78 | guard !self.inTransaction else { 79 | return try await closure(self) 80 | } 81 | 82 | return try await self.withConnection { conn in 83 | conn.eventLoop.makeFutureWithTask { 84 | let db = FluentMySQLDatabase( 85 | database: conn, 86 | encoder: self.encoder, 87 | decoder: self.decoder, 88 | queryLogLevel: self.queryLogLevel, 89 | context: self.context, 90 | inTransaction: true 91 | ) 92 | 93 | // N.B.: We cannot route the transaction start/finish queries through the SQLKit interface due to 94 | // the limitations of MySQLNIO, so we have to use the MySQLNIO interface and log the queries manually. 95 | if let queryLogLevel = db.queryLogLevel { 96 | db.logger.log(level: queryLogLevel, "Executing query", metadata: ["sql": "START TRANSACTION", "binds": []]) 97 | } 98 | _ = try await conn.simpleQuery("START TRANSACTION").get() 99 | do { 100 | let result = try await closure(db) 101 | 102 | if let queryLogLevel = db.queryLogLevel { 103 | db.logger.log(level: queryLogLevel, "Executing query", metadata: ["sql": "COMMIT", "binds": []]) 104 | } 105 | _ = try await conn.simpleQuery("COMMIT").get() 106 | return result 107 | } catch { 108 | if let queryLogLevel = db.queryLogLevel { 109 | db.logger.log(level: queryLogLevel, "Executing query", metadata: ["sql": "ROLLBACK", "binds": []]) 110 | } 111 | _ = try? await conn.simpleQuery("ROLLBACK").get() 112 | throw error 113 | } 114 | } 115 | }.get() 116 | } 117 | 118 | // See `Database.withConnection(_:)`. 119 | func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { 120 | self.withConnection { 121 | closure( 122 | FluentMySQLDatabase( 123 | database: $0, 124 | encoder: self.encoder, 125 | decoder: self.decoder, 126 | queryLogLevel: self.queryLogLevel, 127 | context: self.context, 128 | inTransaction: self.inTransaction 129 | )) 130 | } 131 | } 132 | 133 | // See `SQLDatabase.dialect`. 134 | var dialect: any SQLDialect { 135 | self.sql(encoder: self.encoder, decoder: self.decoder, queryLogLevel: self.queryLogLevel).dialect 136 | } 137 | 138 | // See `SQLDatabase.execute(sql:_:)`. 139 | func execute( 140 | sql query: any SQLExpression, 141 | _ onRow: @escaping @Sendable (any SQLRow) -> Void 142 | ) -> EventLoopFuture { 143 | self.sql(encoder: self.encoder, decoder: self.decoder, queryLogLevel: self.queryLogLevel).execute(sql: query, onRow) 144 | } 145 | 146 | // See `SQLDatabase.withSession(_:)`. 147 | func withSession(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> R) async throws -> R { 148 | try await self.withConnection { (conn: MySQLConnection) in 149 | conn.eventLoop.makeFutureWithTask { 150 | try await closure(conn.sql(encoder: self.encoder, decoder: self.decoder, queryLogLevel: self.queryLogLevel)) 151 | } 152 | }.get() 153 | } 154 | 155 | // See `MySQLDatabase.send(_:logger:)`. 156 | func send(_ command: any MySQLCommand, logger: Logger) -> EventLoopFuture { 157 | self.database.send(command, logger: logger) 158 | } 159 | 160 | // See `MySQLDatabase.withConnection(_:)`. 161 | func withConnection(_ closure: @escaping (MySQLConnection) -> EventLoopFuture) -> EventLoopFuture { 162 | self.database.withConnection(closure) 163 | } 164 | } 165 | 166 | /// A `DatabaseOutput` used to provide last insert IDs from query metadata to the Fluent layer. 167 | /*private*/ struct LastInsertRow: DatabaseOutput { 168 | // See `CustomStringConvertible.description`. 169 | var description: String { self.lastInsertID.map { "\($0)" } ?? "nil" } 170 | 171 | /// The last inserted ID as of the creation of this row. 172 | let lastInsertID: UInt64? 173 | 174 | /// If specified by the original query, an alternative to `FieldKey.id` to be considered valid. 175 | let customIDKey: FieldKey? 176 | 177 | // See `DatabaseOutput.schema(_:)`. 178 | func schema(_ schema: String) -> any DatabaseOutput { self } 179 | 180 | // See `DatabaseOutput.decodeNil(_:)`. 181 | func decodeNil(_ key: FieldKey) throws -> Bool { false } 182 | 183 | // See `DatabaseOutput.contains(_:)`. 184 | func contains(_ key: FieldKey) -> Bool { key == .id || key == self.customIDKey } 185 | 186 | // See `DatabaseOutput.decode(_:as:)`. 187 | func decode(_ key: FieldKey, as type: T.Type) throws -> T { 188 | guard let lIDType = T.self as? any LastInsertIDInitializable.Type else { 189 | throw DecodingError.typeMismatch(T.self, .init(codingPath: [], debugDescription: "\(T.self) is not valid as a last insert ID")) 190 | } 191 | guard self.contains(key) else { 192 | throw DecodingError.keyNotFound( 193 | SomeCodingKey(stringValue: key.description), .init(codingPath: [], debugDescription: "Metadata doesn't contain key \(key)")) 194 | } 195 | guard let lastInsertID = self.lastInsertID else { 196 | throw DecodingError.valueNotFound(T.self, .init(codingPath: [], debugDescription: "Metadata had no last insert ID")) 197 | } 198 | return lIDType.init(lastInsertID: lastInsertID) as! T 199 | } 200 | } 201 | 202 | /// A trivial protocol which identifies types that may be returned by MySQL as "last insert ID" values. 203 | protocol LastInsertIDInitializable { 204 | /// Create an instance of `Self` from a given unsigned 64-bit integer ID value. 205 | init(lastInsertID: UInt64) 206 | } 207 | 208 | extension LastInsertIDInitializable where Self: FixedWidthInteger { 209 | /// Default implementation of ``init(lastInsertID:)`` for `FixedWidthInteger`s. 210 | init(lastInsertID: UInt64) { self = numericCast(lastInsertID) } 211 | } 212 | 213 | /// `UInt64` is a valid last inserted ID value type. 214 | extension UInt64: LastInsertIDInitializable {} 215 | 216 | /// `UInt` is a valid last inserted ID value type. 217 | extension UInt: LastInsertIDInitializable {} 218 | 219 | /// `Int` is a valid last inserted ID value type. 220 | extension Int: LastInsertIDInitializable {} 221 | 222 | /// `Int64` is a valid last inserted ID value type. 223 | extension Int64: LastInsertIDInitializable {} 224 | -------------------------------------------------------------------------------- /Sources/FluentMySQLDriver/FluentMySQLConfiguration.swift: -------------------------------------------------------------------------------- 1 | import AsyncKit 2 | import FluentKit 3 | import Logging 4 | import MySQLKit 5 | import struct NIO.TimeAmount 6 | 7 | extension DatabaseConfigurationFactory { 8 | /// Create a database configuration factory for connecting to a server through a UNIX domain socket. 9 | /// 10 | /// - Parameters: 11 | /// - unixDomainSocketPath: The path to the UNIX domain socket to connect through. 12 | /// - username: The username to use for the connection. 13 | /// - password: The password (empty string for none) to use for the connection. 14 | /// - database: The default database for the connection, if any. 15 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 16 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 17 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 18 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 19 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 20 | /// - Returns: An appropriate configuration factory. 21 | public static func mysql( 22 | unixDomainSocketPath: String, 23 | username: String, 24 | password: String, 25 | database: String? = nil, 26 | maxConnectionsPerEventLoop: Int = 1, 27 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 28 | encoder: MySQLDataEncoder = .init(), 29 | decoder: MySQLDataDecoder = .init(), 30 | sqlLogLevel: Logger.Level? = .debug 31 | ) throws -> Self { 32 | try .mysql( 33 | unixDomainSocketPath: unixDomainSocketPath, 34 | username: username, 35 | password: password, 36 | database: database, 37 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 38 | connectionPoolTimeout: connectionPoolTimeout, 39 | pruneInterval: nil, 40 | encoder: encoder, 41 | decoder: decoder, 42 | sqlLogLevel: sqlLogLevel 43 | ) 44 | } 45 | 46 | /// Create a database configuration factory from an appropriately formatted URL string. 47 | /// 48 | /// - Parameters: 49 | /// - url: A URL-formatted MySQL connection string. See `MySQLConfiguration` in MySQLKit for details of 50 | /// accepted URL formats. 51 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 52 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 53 | /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`. 54 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 55 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 56 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 57 | /// - Returns: An appropriate configuration factory. 58 | public static func mysql( 59 | url urlString: String, 60 | maxConnectionsPerEventLoop: Int = 1, 61 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 62 | encoder: MySQLDataEncoder = .init(), 63 | decoder: MySQLDataDecoder = .init(), 64 | sqlLogLevel: Logger.Level? = .debug 65 | ) throws -> Self { 66 | try .mysql( 67 | url: urlString, 68 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 69 | connectionPoolTimeout: connectionPoolTimeout, 70 | pruneInterval: nil, 71 | encoder: encoder, 72 | decoder: decoder, 73 | sqlLogLevel: sqlLogLevel 74 | ) 75 | } 76 | 77 | /// Create a database configuration factory from an appropriately formatted URL string. 78 | /// 79 | /// - Parameters: 80 | /// - url: A `URL` containing MySQL connection parameters. See `MySQLConfiguration` in MySQLKit for details of 81 | /// accepted URL formats. 82 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 83 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 84 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 85 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 86 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 87 | /// - Returns: An appropriate configuration factory. 88 | public static func mysql( 89 | url: URL, 90 | maxConnectionsPerEventLoop: Int = 1, 91 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 92 | encoder: MySQLDataEncoder = .init(), 93 | decoder: MySQLDataDecoder = .init(), 94 | sqlLogLevel: Logger.Level? = .debug 95 | ) throws -> Self { 96 | try .mysql( 97 | url: url, 98 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 99 | connectionPoolTimeout: connectionPoolTimeout, 100 | pruneInterval: nil, 101 | encoder: encoder, 102 | decoder: decoder, 103 | sqlLogLevel: sqlLogLevel 104 | ) 105 | } 106 | 107 | /// Create a database configuration factory for connecting to a server with a hostname and optional port. 108 | /// 109 | /// - Parameters: 110 | /// - hostname: The hostname to connect to. 111 | /// - port: A TCP port number to connect on. Defaults to the IANA-assigned MySQL port number (3306). 112 | /// - username: The username to use for the connection. 113 | /// - password: The password (empty string for none) to use for the connection. 114 | /// - database: The default database for the connection, if any. 115 | /// - tlsConfiguration: An optional `TLSConfiguration` specifying encryption for the connection. 116 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 117 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 118 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 119 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 120 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 121 | /// - Returns: An appropriate configuration factory. 122 | public static func mysql( 123 | hostname: String, 124 | port: Int = 3306, 125 | username: String, 126 | password: String, 127 | database: String? = nil, 128 | tlsConfiguration: TLSConfiguration? = .makeClientConfiguration(), 129 | maxConnectionsPerEventLoop: Int = 1, 130 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 131 | encoder: MySQLDataEncoder = .init(), 132 | decoder: MySQLDataDecoder = .init(), 133 | sqlLogLevel: Logger.Level? = .debug 134 | ) -> Self { 135 | .mysql( 136 | hostname: hostname, 137 | port: port, 138 | username: username, 139 | password: password, 140 | database: database, 141 | tlsConfiguration: tlsConfiguration, 142 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 143 | connectionPoolTimeout: connectionPoolTimeout, 144 | pruneInterval: nil, 145 | encoder: encoder, 146 | decoder: decoder, 147 | sqlLogLevel: sqlLogLevel 148 | ) 149 | } 150 | 151 | /// Create a database configuration factory for connecting to a server with a given `MySQLConfiguration`. 152 | /// 153 | /// - Parameters: 154 | /// - configuration: A connection configuration. 155 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 156 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 157 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 158 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 159 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 160 | /// - Returns: An appropriate configuration factory. 161 | public static func mysql( 162 | configuration: MySQLConfiguration, 163 | maxConnectionsPerEventLoop: Int = 1, 164 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 165 | encoder: MySQLDataEncoder = .init(), 166 | decoder: MySQLDataDecoder = .init(), 167 | sqlLogLevel: Logger.Level? = .debug 168 | ) -> Self { 169 | .mysql( 170 | configuration: configuration, 171 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 172 | connectionPoolTimeout: connectionPoolTimeout, 173 | pruneInterval: nil, 174 | encoder: encoder, 175 | decoder: decoder, 176 | sqlLogLevel: sqlLogLevel 177 | ) 178 | } 179 | } 180 | 181 | extension DatabaseConfigurationFactory { 182 | /// Create a database configuration factory for connecting to a server through a UNIX domain socket. 183 | /// 184 | /// - Parameters: 185 | /// - unixDomainSocketPath: The path to the UNIX domain socket to connect through. 186 | /// - username: The username to use for the connection. 187 | /// - password: The password (empty string for none) to use for the connection. 188 | /// - database: The default database for the connection, if any. 189 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 190 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 191 | /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default), 192 | /// no pruning is performed. 193 | /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled. 194 | /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`. 195 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 196 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 197 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 198 | /// - Returns: An appropriate configuration factory. 199 | public static func mysql( 200 | unixDomainSocketPath: String, 201 | username: String, 202 | password: String, 203 | database: String? = nil, 204 | maxConnectionsPerEventLoop: Int = 1, 205 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 206 | pruneInterval: TimeAmount?, 207 | maxIdleTimeBeforePruning: TimeAmount = .seconds(120), 208 | encoder: MySQLDataEncoder = .init(), 209 | decoder: MySQLDataDecoder = .init(), 210 | sqlLogLevel: Logger.Level? = .debug 211 | ) throws -> Self { 212 | let configuration = MySQLConfiguration( 213 | unixDomainSocketPath: unixDomainSocketPath, 214 | username: username, 215 | password: password, 216 | database: database 217 | ) 218 | return .mysql( 219 | configuration: configuration, 220 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 221 | connectionPoolTimeout: connectionPoolTimeout, 222 | pruneInterval: pruneInterval, 223 | maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, 224 | encoder: encoder, 225 | decoder: decoder, 226 | sqlLogLevel: sqlLogLevel 227 | ) 228 | } 229 | 230 | /// Create a database configuration factory from an appropriately formatted URL string. 231 | /// 232 | /// - Parameters: 233 | /// - url: A URL-formatted MySQL connection string. See `MySQLConfiguration` in MySQLKit for details of 234 | /// accepted URL formats. 235 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 236 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 237 | /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default), 238 | /// no pruning is performed. 239 | /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled. 240 | /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`. 241 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 242 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 243 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 244 | /// - Returns: An appropriate configuration factory. 245 | public static func mysql( 246 | url urlString: String, 247 | maxConnectionsPerEventLoop: Int = 1, 248 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 249 | pruneInterval: TimeAmount?, 250 | maxIdleTimeBeforePruning: TimeAmount = .seconds(120), 251 | encoder: MySQLDataEncoder = .init(), 252 | decoder: MySQLDataDecoder = .init(), 253 | sqlLogLevel: Logger.Level? = .debug 254 | ) throws -> Self { 255 | guard let url = URL(string: urlString) else { 256 | throw FluentMySQLError.invalidURL(urlString) 257 | } 258 | return try .mysql( 259 | url: url, 260 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 261 | connectionPoolTimeout: connectionPoolTimeout, 262 | pruneInterval: pruneInterval, 263 | maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, 264 | encoder: encoder, 265 | decoder: decoder, 266 | sqlLogLevel: sqlLogLevel 267 | ) 268 | } 269 | 270 | /// Create a database configuration factory from an appropriately formatted URL string. 271 | /// 272 | /// - Parameters: 273 | /// - url: A `URL` containing MySQL connection parameters. See `MySQLConfiguration` in MySQLKit for details of 274 | /// accepted URL formats. 275 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 276 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 277 | /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default), 278 | /// no pruning is performed. 279 | /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled. 280 | /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`. 281 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 282 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 283 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 284 | /// - Returns: An appropriate configuration factory. 285 | public static func mysql( 286 | url: URL, 287 | maxConnectionsPerEventLoop: Int = 1, 288 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 289 | pruneInterval: TimeAmount?, 290 | maxIdleTimeBeforePruning: TimeAmount = .seconds(120), 291 | encoder: MySQLDataEncoder = .init(), 292 | decoder: MySQLDataDecoder = .init(), 293 | sqlLogLevel: Logger.Level? = .debug 294 | ) throws -> Self { 295 | guard let configuration = MySQLConfiguration(url: url) else { 296 | throw FluentMySQLError.invalidURL(url.absoluteString) 297 | } 298 | return .mysql( 299 | configuration: configuration, 300 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 301 | connectionPoolTimeout: connectionPoolTimeout, 302 | pruneInterval: pruneInterval, 303 | maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, 304 | encoder: encoder, 305 | decoder: decoder, 306 | sqlLogLevel: sqlLogLevel 307 | ) 308 | } 309 | 310 | /// Create a database configuration factory for connecting to a server with a hostname and optional port. 311 | /// 312 | /// - Parameters: 313 | /// - hostname: The hostname to connect to. 314 | /// - port: A TCP port number to connect on. Defaults to the IANA-assigned MySQL port number (3306). 315 | /// - username: The username to use for the connection. 316 | /// - password: The password (empty string for none) to use for the connection. 317 | /// - database: The default database for the connection, if any. 318 | /// - tlsConfiguration: An optional `TLSConfiguration` specifying encryption for the connection. 319 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 320 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 321 | /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default), 322 | /// no pruning is performed. 323 | /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled. 324 | /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`. 325 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 326 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 327 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 328 | /// - Returns: An appropriate configuration factory. 329 | public static func mysql( 330 | hostname: String, 331 | port: Int = 3306, 332 | username: String, 333 | password: String, 334 | database: String? = nil, 335 | tlsConfiguration: TLSConfiguration? = .makeClientConfiguration(), 336 | maxConnectionsPerEventLoop: Int = 1, 337 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 338 | pruneInterval: TimeAmount?, 339 | maxIdleTimeBeforePruning: TimeAmount = .seconds(120), 340 | encoder: MySQLDataEncoder = .init(), 341 | decoder: MySQLDataDecoder = .init(), 342 | sqlLogLevel: Logger.Level? = .debug 343 | ) -> Self { 344 | .mysql( 345 | configuration: .init( 346 | hostname: hostname, 347 | port: port, 348 | username: username, 349 | password: password, 350 | database: database, 351 | tlsConfiguration: tlsConfiguration 352 | ), 353 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 354 | connectionPoolTimeout: connectionPoolTimeout, 355 | pruneInterval: pruneInterval, 356 | maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, 357 | encoder: encoder, 358 | decoder: decoder, 359 | sqlLogLevel: sqlLogLevel 360 | ) 361 | } 362 | 363 | /// Create a database configuration factory for connecting to a server with a given `MySQLConfiguration`. 364 | /// 365 | /// - Parameters: 366 | /// - configuration: A connection configuration. 367 | /// - maxConnectionsPerEventLoop: The maximum number of database connections to add to each event loop's pool. 368 | /// - connectionPoolTimeout: The timeout for queries on the connection pool's wait list. 369 | /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default), 370 | /// no pruning is performed. 371 | /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled. 372 | /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`. 373 | /// - encoder: A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 374 | /// - decoder: A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 375 | /// - sqlLogLevel: The log level to use for logging serialized queries issued to databases using this configuration. 376 | /// - Returns: An appropriate configuration factory. 377 | public static func mysql( 378 | configuration: MySQLConfiguration, 379 | maxConnectionsPerEventLoop: Int = 1, 380 | connectionPoolTimeout: NIO.TimeAmount = .seconds(10), 381 | pruneInterval: TimeAmount?, 382 | maxIdleTimeBeforePruning: TimeAmount = .seconds(120), 383 | encoder: MySQLDataEncoder = .init(), 384 | decoder: MySQLDataDecoder = .init(), 385 | sqlLogLevel: Logger.Level? = .debug 386 | ) -> Self { 387 | Self { 388 | FluentMySQLConfiguration( 389 | configuration: configuration, 390 | maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, 391 | connectionPoolTimeout: connectionPoolTimeout, 392 | pruningInterval: pruneInterval, 393 | maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, 394 | encoder: encoder, 395 | decoder: decoder, 396 | sqlLogLevel: sqlLogLevel, 397 | middleware: [] 398 | ) 399 | } 400 | } 401 | } 402 | 403 | /// An implementation of `DatabaseConfiguration` for MySQL configurations. 404 | struct FluentMySQLConfiguration: DatabaseConfiguration { 405 | /// The underlying `MySQLConfiguration`. 406 | let configuration: MySQLConfiguration 407 | 408 | /// The maximum number of database connections to add to each event loop's pool. 409 | let maxConnectionsPerEventLoop: Int 410 | 411 | /// The timeout for queries on the connection pool's wait list. 412 | let connectionPoolTimeout: TimeAmount 413 | 414 | /// The idle pruning interval for the connection pool. 415 | let pruningInterval: TimeAmount? 416 | 417 | /// The connection idle timeout for the connection pool. 418 | let maxIdleTimeBeforePruning: TimeAmount 419 | 420 | /// A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. 421 | let encoder: MySQLDataEncoder 422 | 423 | /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. 424 | let decoder: MySQLDataDecoder 425 | 426 | /// A logging level used for logging queries. 427 | let sqlLogLevel: Logger.Level? 428 | 429 | // See `DatabaseConfiguration.middleware`. 430 | var middleware: [any AnyModelMiddleware] 431 | 432 | // See `DatabaseConfiguration.makeDriver(for:)`. 433 | func makeDriver(for databases: Databases) -> any DatabaseDriver { 434 | let db = MySQLConnectionSource( 435 | configuration: self.configuration 436 | ) 437 | let pool = EventLoopGroupConnectionPool( 438 | source: db, 439 | maxConnectionsPerEventLoop: self.maxConnectionsPerEventLoop, 440 | requestTimeout: self.connectionPoolTimeout, 441 | pruneInterval: self.pruningInterval, 442 | maxIdleTimeBeforePruning: self.maxIdleTimeBeforePruning, 443 | on: databases.eventLoopGroup 444 | ) 445 | return FluentMySQLDriver( 446 | pool: pool, 447 | encoder: self.encoder, 448 | decoder: self.decoder, 449 | sqlLogLevel: self.sqlLogLevel 450 | ) 451 | } 452 | } 453 | -------------------------------------------------------------------------------- /Tests/FluentMySQLDriverTests/FluentMySQLDriverTests.swift: -------------------------------------------------------------------------------- 1 | import FluentBenchmark 2 | import FluentSQL 3 | import Logging 4 | import MySQLKit 5 | @preconcurrency import MySQLNIO 6 | import NIO 7 | import NIOSSL 8 | import SQLKit 9 | import XCTest 10 | 11 | @testable import FluentMySQLDriver 12 | 13 | func XCTAssertEqualAsync( 14 | _ expression1: @autoclosure () async throws -> T, 15 | _ expression2: @autoclosure () async throws -> T, 16 | _ message: @autoclosure () -> String = "", 17 | file: StaticString = #filePath, line: UInt = #line 18 | ) async where T: Equatable { 19 | do { 20 | let expr1 = try await expression1() 21 | let expr2 = try await expression2() 22 | return XCTAssertEqual(expr1, expr2, message(), file: file, line: line) 23 | } catch { 24 | return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) 25 | } 26 | } 27 | 28 | func XCTAssertNilAsync( 29 | _ expression: @autoclosure () async throws -> Any?, 30 | _ message: @autoclosure () -> String = "", 31 | file: StaticString = #filePath, line: UInt = #line 32 | ) async { 33 | do { 34 | let result = try await expression() 35 | return XCTAssertNil(result, message(), file: file, line: line) 36 | } catch { 37 | return XCTAssertNil(try { throw error }(), message(), file: file, line: line) 38 | } 39 | } 40 | 41 | func XCTAssertThrowsErrorAsync( 42 | _ expression: @autoclosure () async throws -> T, 43 | _ message: @autoclosure () -> String = "", 44 | file: StaticString = #filePath, line: UInt = #line, 45 | _ callback: (any Error) -> Void = { _ in } 46 | ) async { 47 | do { 48 | _ = try await expression() 49 | XCTAssertThrowsError({}(), message(), file: file, line: line, callback) 50 | } catch { 51 | XCTAssertThrowsError(try { throw error }(), message(), file: file, line: line, callback) 52 | } 53 | } 54 | 55 | final class FluentMySQLDriverTests: XCTestCase { 56 | func testAggregate() throws { try self.benchmarker.testAggregate() } 57 | func testArray() throws { try self.benchmarker.testArray() } 58 | func testBatch() throws { try self.benchmarker.testBatch() } 59 | func testChild() throws { try self.benchmarker.testChild() } 60 | func testChildren() throws { try self.benchmarker.testChildren() } 61 | func testCodable() throws { try self.benchmarker.testCodable() } 62 | func testChunk() throws { try self.benchmarker.testChunk() } 63 | func testCompositeID() throws { try self.benchmarker.testCompositeID() } 64 | func testCRUD() throws { try self.benchmarker.testCRUD() } 65 | func testEagerLoad() throws { try self.benchmarker.testEagerLoad() } 66 | func testEnum() throws { try self.benchmarker.testEnum() } 67 | func testFilter() throws { try self.benchmarker.testFilter() } 68 | func testGroup() throws { try self.benchmarker.testGroup() } 69 | func testID() throws { try self.benchmarker.testID() } 70 | func testJoin() throws { try self.benchmarker.testJoin() } 71 | func testMiddleware() throws { try self.benchmarker.testMiddleware() } 72 | func testMigrator() throws { try self.benchmarker.testMigrator() } 73 | func testModel() throws { try self.benchmarker.testModel() } 74 | func testOptionalParent() throws { try self.benchmarker.testOptionalParent() } 75 | func testPagination() throws { try self.benchmarker.testPagination() } 76 | func testParent() throws { try self.benchmarker.testParent() } 77 | func testPerformance() throws { try self.benchmarker.testPerformance() } 78 | func testRange() throws { try self.benchmarker.testRange() } 79 | func testSchema() throws { try self.benchmarker.testSchema() } 80 | func testSet() throws { try self.benchmarker.testSet() } 81 | func testSiblings() throws { try self.benchmarker.testSiblings() } 82 | func testSoftDelete() throws { try self.benchmarker.testSoftDelete() } 83 | func testSort() throws { try self.benchmarker.testSort() } 84 | func testSQL() throws { try self.benchmarker.testSQL() } 85 | func testTimestamp() throws { try self.benchmarker.testTimestamp() } 86 | func testTransaction() throws { try self.benchmarker.testTransaction() } 87 | func testUnique() throws { try self.benchmarker.testUnique() } 88 | 89 | func testDatabaseError() async throws { 90 | let sql = (self.db as! any SQLDatabase) 91 | await XCTAssertThrowsErrorAsync(try await sql.raw("asdf").run()) { 92 | XCTAssertTrue(($0 as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: $0))") 93 | XCTAssertFalse(($0 as? any DatabaseError)?.isConstraintFailure ?? true, "\(String(reflecting: $0))") 94 | XCTAssertFalse(($0 as? any DatabaseError)?.isConnectionClosed ?? true, "\(String(reflecting: $0))") 95 | } 96 | try await sql.drop(table: "foo").ifExists().run() 97 | try await sql.create(table: "foo").column("name", type: .text, .unique).run() 98 | try await sql.insert(into: "foo").columns("name").values("bar").run() 99 | await XCTAssertThrowsErrorAsync(try await sql.insert(into: "foo").columns("name").values("bar").run()) { 100 | XCTAssertTrue(($0 as? any DatabaseError)?.isConstraintFailure ?? false, "\(String(reflecting: $0))") 101 | XCTAssertFalse(($0 as? any DatabaseError)?.isSyntaxError ?? true, "\(String(reflecting: $0))") 102 | XCTAssertFalse(($0 as? any DatabaseError)?.isConnectionClosed ?? true, "\(String(reflecting: $0))") 103 | } 104 | await XCTAssertThrowsErrorAsync( 105 | try await self.mysql.withConnection { conn in 106 | conn.close().flatMap { 107 | conn.sql().insert(into: "foo").columns("name").values("bar").run() 108 | } 109 | }.get() 110 | ) { 111 | XCTAssertTrue(($0 as? any DatabaseError)?.isConnectionClosed ?? false, "\(String(reflecting: $0))") 112 | XCTAssertFalse(($0 as? any DatabaseError)?.isSyntaxError ?? true, "\(String(reflecting: $0))") 113 | XCTAssertFalse(($0 as? any DatabaseError)?.isConstraintFailure ?? true, "\(String(reflecting: $0))") 114 | } 115 | } 116 | 117 | func testClarityModel() async throws { 118 | final class Clarity: Model, @unchecked Sendable { 119 | static let schema = "clarities" 120 | 121 | @ID(custom: .id, generatedBy: .database) var id: Int? 122 | @Field(key: "at") var at: Date 123 | @Field(key: "cloud_condition") var cloudCondition: Int 124 | @Field(key: "wind_condition") var windCondition: Int 125 | @Field(key: "rain_condition") var rainCondition: Int 126 | @Field(key: "day_condition") var daylightCondition: Int 127 | @Field(key: "sky_temperature") var skyTemperature: Double? 128 | @Field(key: "sensor_temperature") var sensorTemperature: Double? 129 | @Field(key: "ambient_temperature") var ambientTemperature: Double 130 | @Field(key: "dewpoint_temperature") var dewpointTemperature: Double 131 | @Field(key: "wind_speed") var windSpeed: Double? 132 | @Field(key: "humidity") var humidity: Double 133 | @Field(key: "daylight") var daylight: Int 134 | @Field(key: "rain") var rain: Bool 135 | @Field(key: "wet") var wet: Bool 136 | @Field(key: "heater") var heater: Double 137 | @Field(key: "close_requested") var closeRequested: Bool 138 | 139 | init() {} 140 | } 141 | 142 | struct CreateClarity: AsyncMigration { 143 | func prepare(on database: any Database) async throws { 144 | try await database.schema("clarities") 145 | .field("id", .int, .identifier(auto: true)) 146 | .field("at", .datetime, .required) 147 | .field("cloud_condition", .int, .required) 148 | .field("wind_condition", .int, .required) 149 | .field("rain_condition", .int, .required) 150 | .field("day_condition", .int, .required) 151 | .field("sky_temperature", .float) 152 | .field("sensor_temperature", .float) 153 | .field("ambient_temperature", .float, .required) 154 | .field("dewpoint_temperature", .float, .required) 155 | .field("wind_speed", .float) 156 | .field("humidity", .double, .required) 157 | .field("daylight", .int, .required) 158 | .field("rain", .bool, .required) 159 | .field("wet", .bool, .required) 160 | .field("heater", .float, .required) 161 | .field("close_requested", .bool, .required) 162 | .create() 163 | } 164 | 165 | func revert(on database: any Database) async throws { 166 | try await database.schema("clarities").delete() 167 | } 168 | } 169 | 170 | try await CreateClarity().prepare(on: self.db) 171 | 172 | do { 173 | let now = Date() 174 | let clarity = Clarity() 175 | clarity.at = now 176 | clarity.cloudCondition = 1 177 | clarity.windCondition = 2 178 | clarity.rainCondition = 3 179 | clarity.daylightCondition = 4 180 | clarity.skyTemperature = nil 181 | clarity.sensorTemperature = nil 182 | clarity.ambientTemperature = 20.0 183 | clarity.dewpointTemperature = -3.0 184 | clarity.windSpeed = nil 185 | clarity.humidity = 59.1 186 | clarity.daylight = 12 187 | clarity.rain = false 188 | clarity.wet = true 189 | clarity.heater = 10 190 | clarity.closeRequested = false 191 | try await clarity.create(on: self.db) 192 | 193 | let dbClarity = try await Clarity.query(on: self.db).first() 194 | XCTAssertEqual(dbClarity?.at.description, now.description) 195 | XCTAssertEqual(dbClarity?.cloudCondition, 1) 196 | XCTAssertEqual(dbClarity?.windCondition, 2) 197 | XCTAssertEqual(dbClarity?.rainCondition, 3) 198 | XCTAssertEqual(dbClarity?.daylightCondition, 4) 199 | XCTAssertEqual(dbClarity?.skyTemperature, nil) 200 | XCTAssertEqual(dbClarity?.sensorTemperature, nil) 201 | XCTAssertEqual(dbClarity?.ambientTemperature, 20.0) 202 | XCTAssertEqual(dbClarity?.dewpointTemperature, -3.0) 203 | XCTAssertEqual(dbClarity?.windSpeed, nil) 204 | XCTAssertEqual(dbClarity?.humidity, 59.1) 205 | XCTAssertEqual(dbClarity?.daylight, 12) 206 | XCTAssertEqual(dbClarity?.rain, false) 207 | XCTAssertEqual(dbClarity?.wet, true) 208 | XCTAssertEqual(dbClarity?.heater, 10) 209 | XCTAssertEqual(dbClarity?.closeRequested, false) 210 | } catch { 211 | try? await CreateClarity().revert(on: self.db) 212 | throw error 213 | } 214 | try await CreateClarity().revert(on: self.db) 215 | } 216 | 217 | func testBoolFilter() async throws { 218 | final class Clarity: Model, @unchecked Sendable { 219 | static let schema = "clarities" 220 | 221 | @ID(custom: .id, generatedBy: .database) 222 | var id: Int? 223 | 224 | @Field(key: "rain") 225 | var rain: Bool 226 | 227 | init() {} 228 | 229 | init(rain: Bool) { 230 | self.rain = rain 231 | } 232 | } 233 | 234 | struct CreateClarity: AsyncMigration { 235 | func prepare(on database: any Database) async throws { 236 | try await database.schema("clarities") 237 | .field("id", .int, .identifier(auto: true)) 238 | .field("rain", .bool, .required) 239 | .create() 240 | } 241 | 242 | func revert(on database: any Database) async throws { 243 | try await database.schema("clarities").delete() 244 | } 245 | } 246 | 247 | try await CreateClarity().prepare(on: self.db) 248 | 249 | do { 250 | let trueValue = Clarity(rain: true) 251 | let falseValue = Clarity(rain: false) 252 | 253 | try await trueValue.save(on: self.db) 254 | try await falseValue.save(on: self.db) 255 | 256 | await XCTAssertEqualAsync(try await Clarity.query(on: self.db).count(), 2) 257 | await XCTAssertEqualAsync(try await Clarity.query(on: self.db).filter(\.$rain == true).first()?.id, trueValue.id) 258 | await XCTAssertEqualAsync(try await Clarity.query(on: self.db).filter(\.$rain == false).first()?.id, falseValue.id) 259 | } catch { 260 | try? await CreateClarity().revert(on: self.db) 261 | throw error 262 | } 263 | try await CreateClarity().revert(on: self.db) 264 | } 265 | 266 | func testDateDecoding() async throws { 267 | final class Clarity: Model, @unchecked Sendable { 268 | static let schema = "clarities" 269 | 270 | @ID(custom: .id, generatedBy: .database) 271 | var id: Int? 272 | 273 | @Field(key: "date") 274 | var date: Date 275 | 276 | init() {} 277 | 278 | init(date: Date) { 279 | self.date = date 280 | } 281 | } 282 | 283 | struct CreateClarity: AsyncMigration { 284 | func prepare(on database: any Database) async throws { 285 | try await database.schema("clarities") 286 | .field("id", .int, .identifier(auto: true)) 287 | .field("date", .date, .required) 288 | .create() 289 | } 290 | 291 | func revert(on database: any Database) async throws { 292 | try await database.schema("clarities").delete() 293 | } 294 | } 295 | 296 | try await CreateClarity().prepare(on: self.db) 297 | 298 | do { 299 | let formatter = DateFormatter() 300 | formatter.timeZone = TimeZone(secondsFromGMT: 0)! 301 | formatter.dateFormat = "yyyy-MM-dd" 302 | 303 | let firstDate = formatter.date(from: "2020-01-01")! 304 | let secondDate = formatter.date(from: "1994-05-23")! 305 | let trueValue = Clarity(date: firstDate) 306 | let falseValue = Clarity(date: secondDate) 307 | 308 | try await trueValue.save(on: self.db) 309 | try await falseValue.save(on: self.db) 310 | 311 | let receivedModels = try await Clarity.query(on: self.db).all() 312 | XCTAssertEqual(receivedModels.count, 2) 313 | XCTAssertEqual(receivedModels[0].date, firstDate) 314 | XCTAssertEqual(receivedModels[1].date, secondDate) 315 | } catch { 316 | try? await CreateClarity().revert(on: self.db) 317 | throw error 318 | } 319 | try await CreateClarity().revert(on: self.db) 320 | } 321 | 322 | func testChar36UUID() async throws { 323 | final class Foo: Model, @unchecked Sendable { 324 | static let schema = "foos" 325 | 326 | @ID(key: .id) 327 | var id: UUID? 328 | 329 | @Field(key: "bar") 330 | var _bar: String 331 | 332 | var bar: UUID { 333 | get { UUID(uuidString: self._bar)! } 334 | set { self._bar = newValue.uuidString } 335 | } 336 | 337 | init() {} 338 | 339 | init(id: UUID? = nil, bar: UUID) { 340 | self.id = id 341 | self.bar = bar 342 | } 343 | } 344 | 345 | try await self.mysql.sql().drop(table: "foos").ifExists().run() 346 | try await self.db.schema("foos") 347 | .id() 348 | .field("bar", .sql(unsafeRaw: "CHAR(36)"), .required) 349 | .create() 350 | 351 | do { 352 | let foo = Foo(bar: .init()) 353 | try await foo.create(on: self.db) 354 | XCTAssertNotNil(foo.id) 355 | 356 | let fetched = try await Foo.find(foo.id, on: self.db) 357 | XCTAssertEqual(fetched?.bar, foo.bar) 358 | } catch { 359 | try? await self.db.schema("foos").delete() 360 | } 361 | try await self.db.schema("foos").delete() 362 | } 363 | 364 | func testBindingEncodeFailures() async throws { 365 | struct FailingDataType: Codable, MySQLDataConvertible, Equatable { 366 | init() {} 367 | init?(mysqlData: MySQLData) { nil } 368 | var mysqlData: MySQLData? { nil } 369 | } 370 | final class M: Model, @unchecked Sendable { 371 | static let schema = "s" 372 | @ID var id 373 | @Field(key: "f") var f: FailingDataType 374 | init() { self.f = .init() } 375 | } 376 | await XCTAssertThrowsErrorAsync(try await M.query(on: self.db).filter(\.$f == .init()).all()) { 377 | XCTAssertNotNil($0 as? EncodingError, String(reflecting: $0)) 378 | } 379 | 380 | await XCTAssertThrowsErrorAsync(try await M().create(on: self.db)) { 381 | XCTAssertNotNil($0 as? EncodingError, String(reflecting: $0)) 382 | } 383 | 384 | await XCTAssertThrowsErrorAsync(try await self.db.schema("s").field("f", .custom(SQLBind(FailingDataType()))).create()) { 385 | XCTAssertNotNil($0 as? EncodingError, String(reflecting: $0)) 386 | } 387 | } 388 | 389 | func testMiscSQLDatabaseSupport() async throws { 390 | XCTAssertEqual((self.db as? any SQLDatabase)?.queryLogLevel, .debug) 391 | await XCTAssertEqualAsync( 392 | try await (self.db as? any SQLDatabase)?.withSession { $0.dialect.name }, (self.db as? any SQLDatabase)?.dialect.name) 393 | 394 | final class M: Model, @unchecked Sendable { 395 | static let schema = "s" 396 | @ID var id 397 | @OptionalField(key: "k") var k: Int? 398 | init() {} 399 | } 400 | try await (self.db as? any SQLDatabase)?.drop(table: M.schema).ifExists().run() 401 | try await (self.db as? any SQLDatabase)?.create(table: M.schema).column( 402 | "id", type: .custom(SQLRaw("varbinary(16)")), .primaryKey(autoIncrement: false) 403 | ).run() 404 | await XCTAssertNilAsync(try await (self.db as? any SQLDatabase)?.select().column("id").from(M.schema).first(decodingFluent: M.self)?.k) 405 | try await (self.db as? any SQLDatabase)?.drop(table: M.schema).run() 406 | } 407 | 408 | func testLastInsertRow() { 409 | XCTAssertNotNil(LastInsertRow(lastInsertID: 0, customIDKey: nil).description) 410 | let row = LastInsertRow(lastInsertID: nil, customIDKey: nil) 411 | XCTAssertNotNil(row.description) 412 | XCTAssertEqual(row.schema("").description, row.description) 413 | XCTAssertFalse(try row.decodeNil(.id)) 414 | XCTAssertThrowsError(try row.decode(.id, as: String.self)) { 415 | guard case .typeMismatch(_, _) = $0 as? DecodingError else { 416 | return XCTFail("Expected DecodingError.typeMismatch, but got \(String(reflecting: $0))") 417 | } 418 | } 419 | XCTAssertThrowsError(try row.decode("foo", as: Int.self)) { 420 | guard case .keyNotFound(let key, _) = $0 as? DecodingError else { 421 | return XCTFail("Expected DecodingError.keyNotFound, but got \(String(reflecting: $0))") 422 | } 423 | XCTAssertEqual(key.stringValue, "foo") 424 | } 425 | XCTAssertThrowsError(try row.decode(.id, as: Int.self)) { 426 | guard case .valueNotFound(_, _) = $0 as? DecodingError else { 427 | return XCTFail("Expected DecodingError.valueNotFound, but got \(String(reflecting: $0))") 428 | } 429 | } 430 | } 431 | 432 | func testNeverInvokedDatabaseOutputCodePath() async throws { 433 | final class M: Model, @unchecked Sendable { 434 | static let schema = "s" 435 | @ID var id 436 | init() {} 437 | } 438 | try await (self.db as? any SQLDatabase)?.drop(table: M.schema).ifExists().run() 439 | try await self.db.schema(M.schema).id().create() 440 | do { 441 | try await M().create(on: self.db) 442 | try await self.db.execute( 443 | query: M.query(on: self.db).field(\.$id).query, onOutput: { XCTAssert((try? $0.decodeNil("not a real key")) ?? false) } 444 | ).get() 445 | } catch { 446 | try? await self.db.schema(M.schema).delete() 447 | throw error 448 | } 449 | try await self.db.schema(M.schema).delete() 450 | } 451 | 452 | func testMiscConfigMethods() { 453 | XCTAssertNotNil(try DatabaseConfigurationFactory.mysql(unixDomainSocketPath: "/", username: "", password: "")) 454 | XCTAssertNoThrow(try DatabaseConfigurationFactory.mysql(url: "mysql://user@host/db")) 455 | XCTAssertThrowsError(try DatabaseConfigurationFactory.mysql(url: "notmysql://foo@bar")) 456 | XCTAssertThrowsError(try DatabaseConfigurationFactory.mysql(url: "not$a$valid$url://")) 457 | XCTAssertNoThrow(try DatabaseConfigurationFactory.mysql(url: .init(string: "mysql://user@host/db")!)) 458 | XCTAssertThrowsError(try DatabaseConfigurationFactory.mysql(url: .init(string: "notmysql://foo@bar")!)) 459 | XCTAssertEqual(DatabaseID.mysql.string, "mysql") 460 | } 461 | 462 | func testNestedTransaction() async throws { 463 | await XCTAssertEqualAsync(try await self.db.transaction { try await ($0 as! FluentMySQLDatabase).transaction { _ in 1 } }, 1) 464 | } 465 | 466 | func testCoverageOfConfigMethods() throws { 467 | XCTAssertTrue(try DatabaseConfigurationFactory.mysql(unixDomainSocketPath: "/", username: "", password: "", database: "", maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, pruneInterval: nil, maxIdleTimeBeforePruning: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 468 | XCTAssertTrue(try DatabaseConfigurationFactory.mysql(unixDomainSocketPath: "/", username: "", password: "", database: "", maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 469 | 470 | XCTAssertTrue(try DatabaseConfigurationFactory.mysql(url: "mysql://u:p@h:1/d", maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, pruneInterval: nil, maxIdleTimeBeforePruning: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 471 | XCTAssertTrue(try DatabaseConfigurationFactory.mysql(url: "mysql://u:p@h:1/d", maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 472 | 473 | XCTAssertTrue(try DatabaseConfigurationFactory.mysql(url: URL(string: "mysql://u:p@h:1/d")!, maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, pruneInterval: nil, maxIdleTimeBeforePruning: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 474 | XCTAssertTrue(try DatabaseConfigurationFactory.mysql(url: URL(string: "mysql://u:p@h:1/d")!, maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 475 | 476 | XCTAssertTrue(DatabaseConfigurationFactory.mysql(hostname: "", port: 0, username: "", password: "", database: "", tlsConfiguration: nil, maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, pruneInterval: nil, maxIdleTimeBeforePruning: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 477 | XCTAssertTrue(DatabaseConfigurationFactory.mysql(hostname: "", port: 0, username: "", password: "", database: "", tlsConfiguration: nil, maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 478 | 479 | XCTAssertTrue(DatabaseConfigurationFactory.mysql(configuration: .init(hostname: "", port: 0, username: "", password: "", database: "", tlsConfiguration: nil), maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, pruneInterval: nil, maxIdleTimeBeforePruning: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 480 | XCTAssertTrue(DatabaseConfigurationFactory.mysql(configuration: .init(hostname: "", port: 0, username: "", password: "", database: "", tlsConfiguration: nil), maxConnectionsPerEventLoop: 0, connectionPoolTimeout: .zero, encoder: .init(), decoder: .init(), sqlLogLevel: .debug).make().middleware.isEmpty) 481 | } 482 | 483 | var benchmarker: FluentBenchmarker { .init(databases: self.dbs) } 484 | var eventLoopGroup: any EventLoopGroup { MultiThreadedEventLoopGroup.singleton } 485 | var threadPool: NIOThreadPool { NIOThreadPool.singleton } 486 | var dbs: Databases! 487 | var db: any Database { self.benchmarker.database } 488 | var mysql: any MySQLDatabase { self.db as! any MySQLDatabase } 489 | 490 | override func setUpWithError() throws { 491 | try super.setUpWithError() 492 | 493 | XCTAssert(isLoggingConfigured) 494 | self.dbs = Databases(threadPool: self.threadPool, on: self.eventLoopGroup) 495 | 496 | var tls = TLSConfiguration.makeClientConfiguration() 497 | tls.certificateVerification = .none 498 | let databaseA = env("MYSQL_DATABASE_A") ?? "test_database" 499 | let databaseB = env("MYSQL_DATABASE_B") ?? "test_database" 500 | self.dbs.use( 501 | .mysql( 502 | hostname: env("MYSQL_HOSTNAME_A") ?? "localhost", 503 | port: env("MYSQL_PORT_A").flatMap(Int.init) ?? 3306, 504 | username: env("MYSQL_USERNAME_A") ?? "test_username", 505 | password: env("MYSQL_PASSWORD_A") ?? "test_password", 506 | database: databaseA, 507 | tlsConfiguration: tls, 508 | connectionPoolTimeout: .seconds(10), 509 | pruneInterval: .seconds(30), 510 | maxIdleTimeBeforePruning: .seconds(60) 511 | ), as: .a) 512 | 513 | self.dbs.use( 514 | .mysql( 515 | hostname: env("MYSQL_HOSTNAME_B") ?? "localhost", 516 | port: env("MYSQL_PORT_B").flatMap(Int.init) ?? 3306, 517 | username: env("MYSQL_USERNAME_B") ?? "test_username", 518 | password: env("MYSQL_PASSWORD_B") ?? "test_password", 519 | database: databaseB, 520 | tlsConfiguration: tls, 521 | connectionPoolTimeout: .seconds(10), 522 | pruneInterval: .seconds(30), 523 | maxIdleTimeBeforePruning: .seconds(60) 524 | ), as: .b) 525 | } 526 | 527 | override func tearDown() async throws { 528 | await self.dbs.shutdownAsync() 529 | 530 | try await super.tearDown() 531 | } 532 | } 533 | 534 | extension DatabaseID { 535 | static let a = DatabaseID(string: "mysql_a") 536 | static let b = DatabaseID(string: "mysql_b") 537 | } 538 | 539 | func env(_ name: String) -> String? { 540 | ProcessInfo.processInfo.environment[name] 541 | } 542 | 543 | let isLoggingConfigured: Bool = { 544 | LoggingSystem.bootstrap { label in 545 | var handler = StreamLogHandler.standardOutput(label: label) 546 | handler.logLevel = env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? .info 547 | return handler 548 | } 549 | return true 550 | }() 551 | --------------------------------------------------------------------------------