From 93e725419decad6dbad48613e7423deaa2808a7c Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Wed, 29 May 2024 14:01:52 -0500 Subject: [PATCH] Actually support query logging (#225) * Support query log levels. Also fix some Sendable stuff. * Use logging metadata for logging the transaction control queries --- Package.swift | 2 +- Package@swift-5.9.swift | 2 +- .../Docs.docc/theme-settings.json | 2 +- .../FluentMySQLConfiguration.swift | 39 ++-- .../FluentMySQLDatabase.swift | 192 ++++++++---------- .../FluentMySQLDriver/FluentMySQLDriver.swift | 8 +- .../MySQLError+Database.swift | 2 +- .../FluentMySQLDriver/MySQLRow+Database.swift | 70 +++---- .../FluentMySQLDriverTests.swift | 16 +- 9 files changed, 159 insertions(+), 174 deletions(-) diff --git a/Package.swift b/Package.swift index f699721..506615d 100644 --- a/Package.swift +++ b/Package.swift @@ -14,7 +14,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.48.4"), - .package(url: "https://github.com/vapor/mysql-kit.git", from: "4.8.0"), + .package(url: "https://github.com/vapor/mysql-kit.git", from: "4.9.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), ], targets: [ diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index 79b4678..33677e4 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -14,7 +14,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.48.4"), - .package(url: "https://github.com/vapor/mysql-kit.git", from: "4.8.0"), + .package(url: "https://github.com/vapor/mysql-kit.git", from: "4.9.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), ], targets: [ diff --git a/Sources/FluentMySQLDriver/Docs.docc/theme-settings.json b/Sources/FluentMySQLDriver/Docs.docc/theme-settings.json index d01225e..922c6b4 100644 --- a/Sources/FluentMySQLDriver/Docs.docc/theme-settings.json +++ b/Sources/FluentMySQLDriver/Docs.docc/theme-settings.json @@ -12,7 +12,7 @@ "logo-shape": { "dark": "#000", "light": "#fff" }, "fill": { "dark": "#000", "light": "#fff" } }, - "icons": { "technology": "/mysqlkit/images/vapor-fluentmysqldriver-logo.svg" } + "icons": { "technology": "/fluentmysqldriver/images/vapor-fluentmysqldriver-logo.svg" } }, "features": { "quickNavigation": { "enable": true }, diff --git a/Sources/FluentMySQLDriver/FluentMySQLConfiguration.swift b/Sources/FluentMySQLDriver/FluentMySQLConfiguration.swift index eea98fd..755e153 100644 --- a/Sources/FluentMySQLDriver/FluentMySQLConfiguration.swift +++ b/Sources/FluentMySQLDriver/FluentMySQLConfiguration.swift @@ -2,6 +2,7 @@ import AsyncKit import struct NIO.TimeAmount import FluentKit import MySQLKit +import Logging extension DatabaseConfigurationFactory { /// Create a database configuration factory for connecting to a server through a UNIX domain socket. @@ -24,7 +25,8 @@ extension DatabaseConfigurationFactory { maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: NIO.TimeAmount = .seconds(10), encoder: MySQLDataEncoder = .init(), - decoder: MySQLDataDecoder = .init() + decoder: MySQLDataDecoder = .init(), + sqlLogLevel: Logger.Level? = .debug ) throws -> Self { let configuration = MySQLConfiguration( unixDomainSocketPath: unixDomainSocketPath, @@ -37,7 +39,8 @@ extension DatabaseConfigurationFactory { maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, encoder: encoder, - decoder: decoder + decoder: decoder, + sqlLogLevel: sqlLogLevel ) } @@ -56,17 +59,19 @@ extension DatabaseConfigurationFactory { maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: NIO.TimeAmount = .seconds(10), encoder: MySQLDataEncoder = .init(), - decoder: MySQLDataDecoder = .init() + decoder: MySQLDataDecoder = .init(), + sqlLogLevel: Logger.Level? = .debug ) throws -> Self { guard let url = URL(string: urlString) else { throw FluentMySQLError.invalidURL(urlString) } - return try self.mysql( + return try .mysql( url: url, maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, encoder: encoder, - decoder: decoder + decoder: decoder, + sqlLogLevel: sqlLogLevel ) } @@ -85,7 +90,8 @@ extension DatabaseConfigurationFactory { maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: NIO.TimeAmount = .seconds(10), encoder: MySQLDataEncoder = .init(), - decoder: MySQLDataDecoder = .init() + decoder: MySQLDataDecoder = .init(), + sqlLogLevel: Logger.Level? = .debug ) throws -> Self { guard let configuration = MySQLConfiguration(url: url) else { throw FluentMySQLError.invalidURL(url.absoluteString) @@ -95,7 +101,8 @@ extension DatabaseConfigurationFactory { maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, encoder: encoder, - decoder: decoder + decoder: decoder, + sqlLogLevel: sqlLogLevel ) } @@ -123,7 +130,8 @@ extension DatabaseConfigurationFactory { maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: NIO.TimeAmount = .seconds(10), encoder: MySQLDataEncoder = .init(), - decoder: MySQLDataDecoder = .init() + decoder: MySQLDataDecoder = .init(), + sqlLogLevel: Logger.Level? = .debug ) -> Self { .mysql( configuration: .init( @@ -137,7 +145,8 @@ extension DatabaseConfigurationFactory { maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, encoder: encoder, - decoder: decoder + decoder: decoder, + sqlLogLevel: sqlLogLevel ) } @@ -155,7 +164,8 @@ extension DatabaseConfigurationFactory { maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: NIO.TimeAmount = .seconds(10), encoder: MySQLDataEncoder = .init(), - decoder: MySQLDataDecoder = .init() + decoder: MySQLDataDecoder = .init(), + sqlLogLevel: Logger.Level? = .debug ) -> Self { Self { FluentMySQLConfiguration( @@ -164,6 +174,7 @@ extension DatabaseConfigurationFactory { connectionPoolTimeout: connectionPoolTimeout, encoder: encoder, decoder: decoder, + sqlLogLevel: sqlLogLevel, middleware: [] ) } @@ -187,6 +198,9 @@ struct FluentMySQLConfiguration: DatabaseConfiguration { /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. let decoder: MySQLDataDecoder + /// A logging level used for logging queries. + let sqlLogLevel: Logger.Level? + // See `DatabaseConfiguration.middleware`. var middleware: [any AnyModelMiddleware] @@ -201,10 +215,11 @@ struct FluentMySQLConfiguration: DatabaseConfiguration { requestTimeout: self.connectionPoolTimeout, on: databases.eventLoopGroup ) - return _FluentMySQLDriver( + return FluentMySQLDriver( pool: pool, encoder: self.encoder, - decoder: self.decoder + decoder: self.decoder, + sqlLogLevel: self.sqlLogLevel ) } } diff --git a/Sources/FluentMySQLDriver/FluentMySQLDatabase.swift b/Sources/FluentMySQLDriver/FluentMySQLDatabase.swift index 7a47552..87f6884 100644 --- a/Sources/FluentMySQLDriver/FluentMySQLDatabase.swift +++ b/Sources/FluentMySQLDriver/FluentMySQLDatabase.swift @@ -1,16 +1,13 @@ import FluentSQL import MySQLKit -import MySQLNIO +@preconcurrency import MySQLNIO import AsyncKit /// 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 } - +struct FluentMySQLDatabase: Database, SQLDatabase, MySQLDatabase { /// The underlying database connection. - let database: FakeSendable + let database: any MySQLDatabase /// A `MySQLDataEncoder` used to translate bound query parameters into `MySQLData` values. let encoder: MySQLDataEncoder @@ -18,109 +15,111 @@ struct _FluentMySQLDatabase: Database, SQLDatabase, MySQLDatabase { /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. let decoder: MySQLDataDecoder + /// A logging level used for logging queries. + let queryLogLevel: Logger.Level? + /// 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 - } - // See `Database.execute(query:onOutput:)`. func execute( query: DatabaseQuery, onOutput: @escaping @Sendable (any DatabaseOutput) -> () ) -> EventLoopFuture { - let expression = SQLQueryConverter(delegate: MySQLConverterDelegate()) - .convert(query) - let (sql, binds) = self.serialize(expression) - do { - return try self.query( - sql, binds.map { try self.encoder.encode($0) }, - onRow: { row in - onOutput(row.databaseOutput(decoder: self.decoder)) - }, - onMetadata: { metadata in - switch query.action { - case .create where query.customIDKey != .string(""): - let row = LastInsertRow( - lastInsertID: metadata.lastInsertID, - customIDKey: query.customIDKey - ) - onOutput(row) - default: - break - } - }) - } catch { - return self.eventLoop.makeFailedFuture(error) + let expression = SQLQueryConverter(delegate: MySQLConverterDelegate()).convert(query) + + if case .create = query.action, query.customIDKey != .string("") { + // We can't access the query metadata if we route through SQLKit, so we have to duplicate MySQLKit's logic + // in order to get the last insert ID without running an extra query. + let (sql, binds) = self.serialize(expression) + + if let queryLogLevel = self.queryLogLevel { self.logger.log(level: queryLogLevel, "\(sql) \(binds)") } + do { + return try self.query( + sql, binds.map { try self.encoder.encode($0) }, + onRow: self.ignoreRow(_:), + onMetadata: { onOutput(LastInsertRow(lastInsertID: $0.lastInsertID, customIDKey: query.customIDKey)) } + ) + } catch { + return self.eventLoop.makeFailedFuture(error) + } + } else { + return self.execute(sql: expression, { onOutput($0.databaseOutput()) }) } } /// This is here because it allows for full test coverage; it serves no actual purpose functionally. - /*private*/ func ignoreRow(_: MySQLRow) throws {} + @Sendable /*private*/ func ignoreRow(_: MySQLRow) {} + + /// This is here because it allows for full test coverage; it serves no actual purpose functionally. + @Sendable /*private*/ func ignoreRow(_: any SQLRow) {} // See `Database.execute(schema:)`. func execute(schema: DatabaseSchema) -> EventLoopFuture { - let expression = SQLSchemaConverter(delegate: MySQLConverterDelegate()) - .convert(schema) - let (sql, binds) = self.serialize(expression) - do { - // 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) - } + let expression = SQLSchemaConverter(delegate: MySQLConverterDelegate()).convert(schema) + + return self.execute(sql: expression, self.ignoreRow(_:)) } // See `Database.execute(enum:)`. func execute(enum: DatabaseEnum) -> EventLoopFuture { - self.eventLoop.makeSucceededFuture(()) + self.eventLoop.makeSucceededVoidFuture() } // See `Database.transaction(_:)`. func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { + self.inTransaction ? + closure(self) : + self.eventLoop.makeFutureWithTask { try await self.transaction { try await closure($0).get() } } + } + + // See `Database.transaction(_:)`. + func transaction(_ closure: @escaping @Sendable (any Database) async throws -> T) async throws -> T { guard !self.inTransaction else { - return closure(self) + return try await closure(self) } - return self.database.value.withConnection { conn in - conn.simpleQuery("START TRANSACTION").flatMap { _ in - let db = _FluentMySQLDatabase( + + return try await self.withConnection { conn in + conn.eventLoop.makeFutureWithTask { + let db = FluentMySQLDatabase( database: conn, encoder: self.encoder, decoder: self.decoder, + queryLogLevel: self.queryLogLevel, context: self.context, inTransaction: true ) - return closure(db).flatMap { result in - conn.simpleQuery("COMMIT").and(value: result).map { _, result in - result - } - }.flatMapError { error in - conn.simpleQuery("ROLLBACK").flatMapThrowing { _ in - throw error - } + + // N.B.: We cannot route the transaction start/finish queries through the SQLKit interface due to + // the limitations of MySQLNIO, so we have to use the MySQLNIO interface and log the queries manually. + if let queryLogLevel = db.queryLogLevel { db.logger.log(level: queryLogLevel, "Executing query", metadata: ["sql": "START TRANSACTION", "binds": []]) } + _ = try await conn.simpleQuery("START TRANSACTION").get() + do { + let result = try await closure(db) + + if let queryLogLevel = db.queryLogLevel { db.logger.log(level: queryLogLevel, "Executing query", metadata: ["sql": "COMMIT", "binds": []]) } + _ = try await conn.simpleQuery("COMMIT").get() + return result + } catch { + if let queryLogLevel = db.queryLogLevel { db.logger.log(level: queryLogLevel, "Executing query", metadata: ["sql": "ROLLBACK", "binds": []]) } + _ = try? await conn.simpleQuery("ROLLBACK").get() + throw error } } - } + }.get() } // See `Database.withConnection(_:)`. func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { - self.database.value.withConnection { - closure(_FluentMySQLDatabase( + self.withConnection { + closure(FluentMySQLDatabase( database: $0, encoder: self.encoder, decoder: self.decoder, + queryLogLevel: self.queryLogLevel, context: self.context, inTransaction: self.inTransaction )) @@ -129,12 +128,7 @@ struct _FluentMySQLDatabase: Database, SQLDatabase, MySQLDatabase { // 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 + self.sql(encoder: self.encoder, decoder: self.decoder, queryLogLevel: self.queryLogLevel).dialect } // See `SQLDatabase.execute(sql:_:)`. @@ -142,35 +136,33 @@ struct _FluentMySQLDatabase: Database, SQLDatabase, MySQLDatabase { sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> () ) -> EventLoopFuture { - self.sql(encoder: self.encoder, decoder: self.decoder).execute(sql: query, onRow) + self.sql(encoder: self.encoder, decoder: self.decoder, queryLogLevel: self.queryLogLevel).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)) + try await closure(conn.sql(encoder: self.encoder, decoder: self.decoder, queryLogLevel: self.queryLogLevel)) } }.get() } // See `MySQLDatabase.send(_:logger:)`. func send(_ command: any MySQLCommand, logger: Logger) -> EventLoopFuture { - self.database.value.send(command, logger: logger) + self.database.send(command, logger: logger) } // See `MySQLDatabase.withConnection(_:)`. func withConnection(_ closure: @escaping (MySQLConnection) -> EventLoopFuture) -> EventLoopFuture { - self.database.value.withConnection(closure) + self.database.withConnection(closure) } } /// 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.lastInsertID.map { "\($0)" } ?? "nil")" - } + var description: String { self.lastInsertID.map { "\($0)" } ?? "nil" } /// The last inserted ID as of the creation of this row. let lastInsertID: UInt64? @@ -179,43 +171,29 @@ struct _FluentMySQLDatabase: Database, SQLDatabase, MySQLDatabase { let customIDKey: FieldKey? // See `DatabaseOutput.schema(_:)`. - func schema(_ schema: String) -> any DatabaseOutput { - self - } + func schema(_ schema: String) -> any DatabaseOutput { self } // See `DatabaseOutput.decodeNil(_:)`. - func decodeNil(_ key: FieldKey) throws -> Bool { - false - } + func decodeNil(_ key: FieldKey) throws -> Bool { false } // See `DatabaseOutput.contains(_:)`. - func contains(_ key: FieldKey) -> Bool { - key == .id || key == self.customIDKey - } + func contains(_ key: FieldKey) -> Bool { key == .id || key == self.customIDKey } // 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 let lIDType = T.self as? any LastInsertIDInitializable.Type else { + throw DecodingError.typeMismatch(T.self, .init(codingPath: [], debugDescription: "\(T.self) is not valid as a last insert ID")) } guard self.contains(key) else { - throw DecodingError.keyNotFound(SomeCodingKey(stringValue: key.description), .init(codingPath: [], debugDescription: "LastInsertRow doesn't contain key \(key)")) + throw DecodingError.keyNotFound(SomeCodingKey(stringValue: key.description), .init(codingPath: [], debugDescription: "Metadata doesn't contain key \(key)")) } guard let lastInsertID = self.lastInsertID else { - throw DecodingError.valueNotFound(T.self, .init(codingPath: [], debugDescription: "LastInsertRow received metadata with no last insert ID")) + throw DecodingError.valueNotFound(T.self, .init(codingPath: [], debugDescription: "Metadata had no last insert ID")) } - return lastInsertIDInitializable.init(lastInsertID: lastInsertID) as! T + return lIDType.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. @@ -224,19 +202,17 @@ protocol LastInsertIDInitializable { extension LastInsertIDInitializable where Self: FixedWidthInteger { /// Default implementation of ``init(lastInsertID:)`` for `FixedWidthInteger`s. - init(lastInsertID: UInt64) { - self = numericCast(lastInsertID) - } + init(lastInsertID: UInt64) { self = numericCast(lastInsertID) } } /// `UInt64` is a valid last inserted ID value type. -extension UInt64: LastInsertIDInitializable { } +extension UInt64: LastInsertIDInitializable {} /// `UInt` is a valid last inserted ID value type. -extension UInt: LastInsertIDInitializable { } +extension UInt: LastInsertIDInitializable {} /// `Int` is a valid last inserted ID value type. -extension Int: LastInsertIDInitializable { } +extension Int: LastInsertIDInitializable {} /// `Int64` is a valid last inserted ID value type. -extension Int64: LastInsertIDInitializable { } +extension Int64: LastInsertIDInitializable {} diff --git a/Sources/FluentMySQLDriver/FluentMySQLDriver.swift b/Sources/FluentMySQLDriver/FluentMySQLDriver.swift index e545919..3bc27f6 100644 --- a/Sources/FluentMySQLDriver/FluentMySQLDriver.swift +++ b/Sources/FluentMySQLDriver/FluentMySQLDriver.swift @@ -4,7 +4,7 @@ import MySQLKit import NIOCore /// An implementation of `DatabaseDriver` for MySQL . -struct _FluentMySQLDriver: DatabaseDriver { +struct FluentMySQLDriver: DatabaseDriver { /// The connection pool set for this driver. let pool: EventLoopGroupConnectionPool @@ -14,12 +14,16 @@ struct _FluentMySQLDriver: DatabaseDriver { /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values in `SQLRow`s. let decoder: MySQLDataDecoder + /// A logging level used for logging queries. + let sqlLogLevel: Logger.Level? + // See `DatabaseDriver.makeDatabase(with:)`. func makeDatabase(with context: DatabaseContext) -> any Database { - _FluentMySQLDatabase( + FluentMySQLDatabase( database: self.pool.pool(for: context.eventLoop).database(logger: context.logger), encoder: self.encoder, decoder: self.decoder, + queryLogLevel: self.sqlLogLevel, context: context, inTransaction: false ) diff --git a/Sources/FluentMySQLDriver/MySQLError+Database.swift b/Sources/FluentMySQLDriver/MySQLError+Database.swift index f018286..3b8dc9c 100644 --- a/Sources/FluentMySQLDriver/MySQLError+Database.swift +++ b/Sources/FluentMySQLDriver/MySQLError+Database.swift @@ -2,7 +2,7 @@ import MySQLNIO import FluentKit /// Conform `MySQLError` to `DatabaseError`. -extension MySQLError: DatabaseError { +extension MySQLNIO.MySQLError: FluentKit.DatabaseError { // See `DatabaseError.isSyntaxError`. public var isSyntaxError: Bool { switch self { diff --git a/Sources/FluentMySQLDriver/MySQLRow+Database.swift b/Sources/FluentMySQLDriver/MySQLRow+Database.swift index f1ebd04..c351e2e 100644 --- a/Sources/FluentMySQLDriver/MySQLRow+Database.swift +++ b/Sources/FluentMySQLDriver/MySQLRow+Database.swift @@ -2,68 +2,50 @@ import MySQLNIO import MySQLKit import FluentKit -extension MySQLRow { +extension SQLRow { /// 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) + func databaseOutput() -> any DatabaseOutput { + SQLRowDatabaseOutput(row: self, schema: nil) } } -/// A `DatabaseOutput` implementation for `MySQLRow`s. -private struct _MySQLDatabaseOutput: DatabaseOutput { +/// A `DatabaseOutput` implementation for generic `SQLRow`s. This should really be in FluentSQL. +private struct SQLRowDatabaseOutput: DatabaseOutput { /// The underlying row. - let row: MySQLRow - - /// A `MySQLDataDecoder` used to translate `MySQLData` values into output values. - let decoder: MySQLDataDecoder - + let row: any SQLRow + /// The most recently set schema value (see `DatabaseOutput.schema(_:)`). let schema: String? - - // See `DatabaseOutput.description`. + + // See `CustomStringConvertible.description`. var description: String { - self.row.description + String(describing: self.row) } + /// Apply the current schema (if any) to the given `FieldKey` and convert to a column name. + private func adjust(key: FieldKey) -> String { + (self.schema.map { .prefix(.prefix(.string($0), "_"), key) } ?? key).description + } + + // See `DatabaseOutput.schema(_:)`. + func schema(_ schema: String) -> any DatabaseOutput { + Self(row: self.row, schema: schema) + } + // See `DatabaseOutput.contains(_:)`. func contains(_ key: FieldKey) -> Bool { - self.row.column(self.columnName(key)) != nil + self.row.contains(column: self.adjust(key: key)) } - + // See `DatabaseOutput.decodeNil(_:)`. func decodeNil(_ key: FieldKey) throws -> Bool { - if let data = self.row.column((self.columnName(key))) { - return data.buffer == nil - } else { - return true - } - } - - // See `DatabaseOutput.schema(_:)`. - func schema(_ schema: String) -> any DatabaseOutput { - _MySQLDatabaseOutput( - row: self.row, - decoder: self.decoder, - schema: schema - ) + try self.row.decodeNil(column: self.adjust(key: key)) } - + // 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)" - } else { - return key.description - } + func decode(_ key: FieldKey, as: T.Type) throws -> T { + try self.row.decode(column: self.adjust(key: key), as: T.self) } } diff --git a/Tests/FluentMySQLDriverTests/FluentMySQLDriverTests.swift b/Tests/FluentMySQLDriverTests/FluentMySQLDriverTests.swift index d21acae..6a82dad 100644 --- a/Tests/FluentMySQLDriverTests/FluentMySQLDriverTests.swift +++ b/Tests/FluentMySQLDriverTests/FluentMySQLDriverTests.swift @@ -6,7 +6,7 @@ import SQLKit import XCTest import Logging import MySQLKit -import MySQLNIO +@preconcurrency import MySQLNIO import NIOSSL func XCTAssertEqualAsync( @@ -341,7 +341,7 @@ final class FluentMySQLDriverTests: XCTestCase { try await self.mysql.sql().drop(table: "foos").ifExists().run() try await self.db.schema("foos") .id() - .field("bar", .sql(raw: "CHAR(36)"), .required) + .field("bar", .sql(unsafeRaw: "CHAR(36)"), .required) .create() do { @@ -372,6 +372,10 @@ final class FluentMySQLDriverTests: XCTestCase { await XCTAssertThrowsErrorAsync(try await M.query(on: self.db).filter(\.$f == .init()).all()) { XCTAssertNotNil($0 as? EncodingError, String(reflecting: $0)) } + + await XCTAssertThrowsErrorAsync(try await M().create(on: self.db)) { + 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)) @@ -441,10 +445,14 @@ final class FluentMySQLDriverTests: XCTestCase { 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")!)) + XCTAssertNoThrow(try DatabaseConfigurationFactory.mysql(url: .init(string: "mysql://user@host/db")!)) + XCTAssertThrowsError(try DatabaseConfigurationFactory.mysql(url: .init(string: "notmysql://foo@bar")!)) XCTAssertEqual(DatabaseID.mysql.string, "mysql") } + + func testNestedTransaction() async throws { + await XCTAssertEqualAsync(try await self.db.transaction { try await ($0 as! FluentMySQLDatabase).transaction { _ in 1 } }, 1) + } var benchmarker: FluentBenchmarker { .init(databases: self.dbs) } var eventLoopGroup: any EventLoopGroup { MultiThreadedEventLoopGroup.singleton }