diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b92547029..097521060f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ERROR 1064` editing a MySQL 8 column whose expression default holds a quoted string. - `ERROR 1067` saving a column made NOT NULL while its default was NULL. - NULL default on a MySQL `TEXT`, `BLOB`, `JSON` or `GEOMETRY` column saved as the expression `(NULL)`. +- Backslashes and line breaks mangled in MySQL defaults, comments, enum values and passwords under `NO_BACKSLASH_ESCAPES`. +- Form feed in a MySQL comment, default or SQL export saved as the letter `f`. - Unresponsive app and a dropped keystroke when typing in the row inspector's JSON field. (#3051) - Raw Oracle driver error in the schema switch failure dialog. (#3053) - Oracle health check closing a connection a statement was still running on. (#3053) diff --git a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift index e1eb593f9d..133232e17b 100644 --- a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift +++ b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift @@ -11,6 +11,8 @@ internal func mysqlQuoteIdentifier(_ name: String) -> String { return "`\(escaped)`" } +/// A form feed is written as itself: MySQL and MariaDB have no `\f` escape and read one as the +/// letter `f`, measured on MySQL 8.4 and MariaDB 13. internal func mysqlEscapeStringLiteral(_ value: String) -> String { var result = value result = result.replacingOccurrences(of: "\\", with: "\\\\") @@ -20,7 +22,6 @@ internal func mysqlEscapeStringLiteral(_ value: String) -> String { result = result.replacingOccurrences(of: "\t", with: "\\t") result = result.replacingOccurrences(of: "\0", with: "\\0") result = result.replacingOccurrences(of: "\u{08}", with: "\\b") - result = result.replacingOccurrences(of: "\u{0C}", with: "\\f") result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z") return result } diff --git a/Plugins/MySQLDriverPlugin/MySQLLiteralSpelling.swift b/Plugins/MySQLDriverPlugin/MySQLLiteralSpelling.swift new file mode 100644 index 0000000000..d028e67680 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLLiteralSpelling.swift @@ -0,0 +1,171 @@ +// +// MySQLLiteralSpelling.swift +// MySQLDriverPlugin +// + +import Foundation + +/// How a quoted string literal is spelled for the session that reads it. +/// +/// A backslash escapes the character after it unless the session's `sql_mode` holds +/// `NO_BACKSLASH_ESCAPES`, where it is an ordinary character. No spelling of a backslash reads the +/// same both ways, measured on MySQL 8.4 and MariaDB 13: `'x\\y'` stores `x\y` in one mode and +/// `x\\y` in the other, and `COMMENT` rejects the hex literal that would sidestep it. The server +/// prints SQL with backslash escapes whatever the session's mode, in `SHOW CREATE TABLE`, a column's +/// type and MariaDB's catalog defaults alike, so that is how the plugin spells every literal it +/// holds. A statement it runs is re-spelled once, for the session that will read it, from the same +/// status flag `mysql_real_escape_string` reads to make the same choice. +internal enum MySQLLiteralSpelling: Equatable, Sendable { + case backslashEscapes + case quoteDoubling + + init(noBackslashEscapes: Bool?) { + self = noBackslashEscapes == true ? .quoteDoubling : .backslashEscapes + } + + /// The body of a single-quoted literal that reads back as `value`. + func escaped(_ value: String) -> String { + switch self { + case .backslashEscapes: + return mysqlEscapeStringLiteral(value) + case .quoteDoubling: + return value.replacingOccurrences(of: "'", with: "''") + } + } + + /// `sql`, spelled with backslash escapes, with every single-quoted literal in it spelled for + /// this session instead. Identifiers, double-quoted text and comments are copied unchanged: + /// a double-quoted span is an identifier under `ANSI_QUOTES`, and nothing the plugin writes + /// puts a string there. + func respelled(_ sql: String) -> String { + guard self == .quoteDoubling else { return sql } + let scalars = Array(sql.unicodeScalars) + var output = String.UnicodeScalarView() + output.reserveCapacity(scalars.count) + var index = 0 + while index < scalars.count { + let scalar = scalars[index] + switch scalar { + case "'": + guard let literal = Self.backslashLiteral(in: scalars, openingAt: index) else { + output.append(contentsOf: scalars[index...]) + index = scalars.count + continue + } + output.append("'") + output.append(contentsOf: escaped(literal.value).unicodeScalars) + output.append("'") + index = literal.end + continue + case "`", "\"": + let end = Self.endOfQuoted(in: scalars, openingAt: index) + output.append(contentsOf: scalars[index.. (value: String, end: Int)? { + var value = String.UnicodeScalarView() + var index = start + 1 + while index < scalars.count { + let scalar = scalars[index] + if scalar == "\\", index + 1 < scalars.count { + value.append(contentsOf: unescaped(scalars[index + 1])) + index += 2 + continue + } + if scalar == "'" { + guard index + 1 < scalars.count, scalars[index + 1] == "'" else { + return (String(value), index + 1) + } + value.append("'") + index += 2 + continue + } + value.append(scalar) + index += 1 + } + return nil + } + + private static func unescaped(_ scalar: Unicode.Scalar) -> [Unicode.Scalar] { + switch scalar { + case "0": return ["\u{00}"] + case "b": return ["\u{08}"] + case "n": return ["\n"] + case "r": return ["\r"] + case "t": return ["\t"] + case "Z": return ["\u{1A}"] + case "%", "_": return ["\\", scalar] + default: return [scalar] + } + } + + private static func endOfQuoted(in scalars: [Unicode.Scalar], openingAt start: Int) -> Int { + let quote = scalars[start] + var index = start + 1 + while index < scalars.count { + let scalar = scalars[index] + if scalar == "\\", quote != "`" { + index += 2 + continue + } + index += 1 + guard scalar == quote else { continue } + guard index < scalars.count, scalars[index] == quote else { return index } + index += 1 + } + return scalars.count + } + + private static func opensDashComment(_ scalars: [Unicode.Scalar], at index: Int) -> Bool { + guard index + 1 < scalars.count, scalars[index + 1] == "-" else { return false } + guard index + 2 < scalars.count else { return true } + let next = scalars[index + 2] + return next == " " || next == "\t" || next == "\n" || next == "\r" + } + + private static func endOfLine(in scalars: [Unicode.Scalar], from start: Int) -> Int { + var index = start + while index < scalars.count, scalars[index] != "\n" { + index += 1 + } + return index + } + + private static func endOfBlockComment(in scalars: [Unicode.Scalar], from start: Int) -> Int { + var index = start + 2 + while index + 1 < scalars.count { + if scalars[index] == "*", scalars[index + 1] == "/" { return index + 2 } + index += 1 + } + return scalars.count + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift index 112a26d39d..6432f50835 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift @@ -47,7 +47,7 @@ extension MySQLPluginDriver { ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX """ - let result = try await execute(query: query) + let result = try await execute(ownStatement: query) let rows = result.rows.compactMap { row -> MySQLIndexRow? in guard let table = row[safe: 0]?.asText, let index = row[safe: 1]?.asText, diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CatalogFallback.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CatalogFallback.swift index 4ba82f602c..fcce035533 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CatalogFallback.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CatalogFallback.swift @@ -57,7 +57,7 @@ internal extension MySQLPluginDriver { private func catalogTableCount(database: String) async throws -> MySQLCatalogCount { do { let query = MySQLObjectQueries.catalogTableCount(schema: database) - let result = try await execute(query: query) + let result = try await execute(ownStatement: query) guard let count = result.rows.first?[safe: 0]?.asText.flatMap(Int.init) else { return .noRow } return .counted(count) } catch let error as MariaDBPluginError diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift index cb04e3a32a..f862c54c81 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift @@ -54,7 +54,7 @@ extension MySQLPluginDriver { database: String, table: String? ) async throws -> [String: [PluginForeignKeyInfo]] { - let columns = try await execute(query: MySQLObjectQueries.foreignKeyColumns(schema: database, table: table)) + let columns = try await execute(ownStatement: MySQLObjectQueries.foreignKeyColumns(schema: database, table: table)) let columnRows = columns.rows.compactMap { row -> MySQLForeignKeyCatalog.ColumnRow? in guard let tableName = row[safe: 0]?.asText, let constraint = row[safe: 1]?.asText, @@ -73,7 +73,7 @@ extension MySQLPluginDriver { } guard !columnRows.isEmpty else { return [:] } - let actions = try await execute(query: MySQLObjectQueries.referentialActions(schema: database, table: table)) + let actions = try await execute(ownStatement: MySQLObjectQueries.referentialActions(schema: database, table: table)) let actionRows = actions.rows.compactMap { row -> MySQLForeignKeyCatalog.ActionRow? in guard let tableName = row[safe: 0]?.asText, let constraint = row[safe: 1]?.asText diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift index 7c825983df..ed7d2d1ec9 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift @@ -73,7 +73,7 @@ internal extension MySQLPluginDriver { private func baseTableNames(among tables: Set, schema: String?) async throws -> Set { let names = tables.sorted().map { "'\(mysqlEscapeStringLiteral($0))'" }.joined(separator: ", ") - let result = try await execute(query: """ + let result = try await execute(ownStatement: """ SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '\(effectiveSchemaLiteral(schema))' AND TABLE_TYPE = 'BASE TABLE' diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift index 19ade08db1..3cab19b092 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift @@ -8,18 +8,18 @@ import TableProPluginKit extension MySQLPluginDriver { func generateCreatePrincipalSQL(definition: PluginPrincipalDefinition) -> [String]? { - accountStatements.create(definition) + accountStatements.create(definition).map(literalSpelling.respelled) } func generateAlterPrincipalSQL( old: PluginPrincipalDefinition, new: PluginPrincipalDefinition ) -> [String]? { - accountStatements.alter(old: old, new: new) + accountStatements.alter(old: old, new: new).map(literalSpelling.respelled) } func generateSetPasswordSQL(principal: PluginPrincipalRef, password: String) -> [String]? { - accountStatements.setPassword(password, for: principal) + accountStatements.setPassword(password, for: principal).map(literalSpelling.respelled) } func generateDropPrincipalSQL( diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift index 4e12ca4045..af0fd846c5 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Principals.swift @@ -150,7 +150,7 @@ extension MySQLPluginDriver: PluginPrincipalManagement { ORDER BY TABLE_SCHEMA, TABLE_NAME LIMIT \(max(1, limit)) """ - let result = try await execute(query: sql) + let result = try await execute(ownStatement: sql) return result.rows.compactMap { row in guard let database = row[safe: 0]?.asText, diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift index a2d9758512..7e5d04718d 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift @@ -10,7 +10,7 @@ extension MySQLPluginDriver { func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { guard !flavor.isDatabend else { return [] } let resolvedSchema = routineSchema(schema) - let result = try await execute(query: MySQLObjectQueries.routineList(schema: resolvedSchema)) + let result = try await execute(ownStatement: MySQLObjectQueries.routineList(schema: resolvedSchema)) return result.rows.compactMap { row -> PluginRoutineInfo? in guard let name = row[safe: 0]?.asText else { return nil } let isProcedure = row[safe: 1]?.asText?.uppercased() == "PROCEDURE" @@ -49,7 +49,7 @@ extension MySQLPluginDriver { schema: resolvedSchema, name: routine.name ) - let result = try await execute(query: query) + let result = try await execute(ownStatement: query) guard let row = result.rows.first else { throw PluginObjectSourceError.notFound(routine.name) } @@ -80,7 +80,7 @@ extension MySQLPluginDriver { } func triggerList(schema: String, table: String?) async throws -> [PluginTriggerInfo] { - let result = try await execute(query: MySQLObjectQueries.triggerList(schema: schema, table: table)) + let result = try await execute(ownStatement: MySQLObjectQueries.triggerList(schema: schema, table: table)) return result.rows.compactMap { row -> PluginTriggerInfo? in guard let name = row[safe: 0]?.asText, let owningTable = row[safe: 1]?.asText, diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index 6e18c02981..2eb2d1c754 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -148,7 +148,7 @@ internal extension MySQLPluginDriver { AND TABLE_NAME = \'\(mysqlEscapeStringLiteral(table))\'\(generatedOnly) """ do { - let result = try await execute(query: query) + let result = try await execute(ownStatement: query) var details: [String: MySQLCatalogColumnDetail] = [:] for row in result.rows { guard let name = row[safe: 0]?.asText else { continue } @@ -298,7 +298,7 @@ internal extension MySQLPluginDriver { ORDER BY cc.CONSTRAINT_NAME """ } - let result = try await execute(query: query) + let result = try await execute(ownStatement: query) return result.rows.compactMap { row in guard let name = row[safe: 0]?.asText, let clause = row[safe: 1]?.asText else { return nil } @@ -343,7 +343,7 @@ internal extension MySQLPluginDriver { ORDER BY TABLE_NAME, ORDINAL_POSITION """ - let result = try await execute(query: query) + let result = try await execute(ownStatement: query) let createTableClausesByTable = try await oceanbaseDefaultClausesByTable( forRows: result.rows, tableColumn: 0, typeColumn: 2, defaultColumn: 6, schema: schema ) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 5a75170e26..5107e9478e 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -106,6 +106,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { sessionLock.withLock { mariadbConnection }?.noBackslashEscapes } + /// How this session reads a quoted literal. Databend reports no `NO_BACKSLASH_ESCAPES` flag and + /// always reads backslash escapes. + var literalSpelling: MySQLLiteralSpelling { + guard !flavor.isDatabend else { return .backslashEscapes } + return MySQLLiteralSpelling(noBackslashEscapes: noBackslashEscapes) + } + var capabilities: PluginCapabilities { guard !flavor.isDatabend else { return Self.databendCapabilities } return [ @@ -260,6 +267,12 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { try await executeWithReconnect(query: query, isRetry: false) } + /// A statement the plugin wrote itself, spelled with backslash escapes, run with its literals + /// spelled for this session. + func execute(ownStatement statement: String) async throws -> PluginQueryResult { + try await execute(query: literalSpelling.respelled(statement)) + } + func executeUserQuery(query: String, rowCap: Int?, parameters: [PluginCellValue]?) async throws -> PluginQueryResult { let cap = rowCap.flatMap { $0 > 0 ? $0 : nil } guard let parameters else { @@ -569,7 +582,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func catalogTableRows(database: String) async throws -> [[PluginCellValue]] { do { let query = MySQLObjectQueries.tableList(schema: database, includePartitions: true) - return try await execute(query: query).rows + return try await execute(ownStatement: query).rows } catch let error as MariaDBPluginError where MySQLCatalogVisibilityRule.settlesBlindness(code: error.code) { Self.logger.warning( @@ -593,7 +606,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { schema: effectiveSchema(schema), table: table ) - let result = try await execute(query: query) + let result = try await execute(ownStatement: query) var emittedPartitions: Set = [] var ordered: [PluginPartitionInfo] = [] @@ -688,7 +701,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { AND TABLE_NAME = '\(escapedTable)' """ - let result = try await execute(query: query) + let result = try await execute(ownStatement: query) guard let firstRow = result.rows.first, let value = firstRow[safe: 0]?.asText, let count = Int(value) @@ -713,7 +726,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// `SHOW CREATE EVENT` is the only thing that produces a runnable definition. func fetchEvents(schema: String?) async throws -> [PluginEventInfo] { guard !flavor.isDatabend else { return [] } - let result = try await execute(query: """ + let result = try await execute(ownStatement: """ SELECT EVENT_NAME, EVENT_TYPE, STATUS, EVENT_SCHEMA FROM information_schema.EVENTS WHERE EVENT_SCHEMA = '\(effectiveSchemaLiteral(schema))' @@ -754,7 +767,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { guard !flavor.isDatabend else { return try await databendTableMetadata(table: table, schema: schema) } let escapedTable = mysqlEscapeStringLiteral(table) - let result = try await execute(query: showTableStatus(matching: escapedTable, schema: schema)) + let result = try await execute(ownStatement: showTableStatus(matching: escapedTable, schema: schema)) guard let row = result.rows.first else { return PluginTableMetadata(tableName: table) @@ -802,7 +815,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { FROM information_schema.TABLES WHERE TABLE_SCHEMA = '\(escapedDb)' """ - let result = try await execute(query: query) + let result = try await execute(ownStatement: query) let row = result.rows.first let tableCount = Int(row?[safe: 0]?.asText ?? "0") ?? 0 let sizeBytes = Int64(row?[safe: 1]?.asText ?? "0") ?? 0 @@ -979,7 +992,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { guard !flavor.isDatabend else { return DatabendCatalog.createTableSQL(definition: definition) } - return mysqlCreateTableSQL(definition: definition, isMariaDB: flavor.isMariaDB) + return mysqlCreateTableSQL(definition: definition, isMariaDB: flavor.isMariaDB).map(literalSpelling.respelled) } // MARK: - Definition SQL (clipboard copy) @@ -1005,7 +1018,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let definition = flavor.isDatabend ? DatabendCatalog.columnDefinitionSQL(column) : mysqlColumnDefinitionSQL(column, isMariaDB: flavor.isMariaDB) - return "ALTER TABLE \(quoteIdentifier(table)) ADD COLUMN \(definition)" + return literalSpelling.respelled("ALTER TABLE \(quoteIdentifier(table)) ADD COLUMN \(definition)") } func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? { @@ -1013,10 +1026,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return DatabendCatalog.modifyColumnSQL(table: table, oldColumn: oldColumn, newColumn: newColumn) } let tableName = quoteIdentifier(table) + let definition = mysqlColumnDefinitionSQL(newColumn, isMariaDB: flavor.isMariaDB) if oldColumn.name != newColumn.name { - return "ALTER TABLE \(tableName) CHANGE COLUMN \(quoteIdentifier(oldColumn.name)) \(mysqlColumnDefinitionSQL(newColumn, isMariaDB: flavor.isMariaDB))" + return literalSpelling.respelled( + "ALTER TABLE \(tableName) CHANGE COLUMN \(quoteIdentifier(oldColumn.name)) \(definition)" + ) } - return "ALTER TABLE \(tableName) MODIFY COLUMN \(mysqlColumnDefinitionSQL(newColumn, isMariaDB: flavor.isMariaDB))" + return literalSpelling.respelled("ALTER TABLE \(tableName) MODIFY COLUMN \(definition)") } func generateDropColumnSQL(table: String, columnName: String) -> String? { @@ -1050,8 +1066,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } let expression = constraint.expression.trimmingCharacters(in: .whitespacesAndNewlines) guard !expression.isEmpty, !constraint.name.isEmpty else { return nil } - return "ALTER TABLE \(quoteIdentifier(table)) ADD CONSTRAINT " - + "\(quoteIdentifier(constraint.name)) CHECK (\(expression))" + return literalSpelling.respelled( + "ALTER TABLE \(quoteIdentifier(table)) ADD CONSTRAINT \(quoteIdentifier(constraint.name)) CHECK (\(expression))" + ) } func generateDropCheckConstraintSQL(table: String, constraintName: String) -> String? { @@ -1091,7 +1108,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// replaces the whole definition, and the attribute list does not carry /// `GENERATED ALWAYS AS`, so moving a generated column with it dropped the expression and /// left a plain column of stored defaults behind. - return "ALTER TABLE \(tableName) MODIFY COLUMN \(mysqlColumnDefinitionSQL(column, isMariaDB: flavor.isMariaDB)) \(position)" + let definition = mysqlColumnDefinitionSQL(column, isMariaDB: flavor.isMariaDB) + return literalSpelling.respelled("ALTER TABLE \(tableName) MODIFY COLUMN \(definition) \(position)") } /// `MODIFY COLUMN` replaces the whole definition, so every move restates the column in full. @@ -1143,7 +1161,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func allTablesMetadataSQL(schema: String?) -> String? { guard !flavor.isDatabend else { return DatabendCatalog.allTablesMetadataSQL } - return """ + return literalSpelling.respelled(""" SELECT TABLE_SCHEMA as `schema`, TABLE_NAME as name, @@ -1160,6 +1178,6 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ON TABLE_COLLATION = CCSA.COLLATION_NAME WHERE TABLE_SCHEMA = '\(effectiveSchemaLiteral(schema))' ORDER BY TABLE_NAME - """ + """) } } diff --git a/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift b/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift index bfc9941a98..c80dc97f60 100644 --- a/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift +++ b/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift @@ -59,8 +59,7 @@ func escapeStringLiteralFromDialect(_ dialect: SQLDialectDescriptor) -> @Sendabl result = result.replacingOccurrences(of: "\t", with: "\\t") result = result.replacingOccurrences(of: "\0", with: "\\0") result = result.replacingOccurrences(of: "\u{08}", with: "\\b") - result = result.replacingOccurrences(of: "\u{0C}", with: "\\f") - result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z") + result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z") return result } } diff --git a/TableProTests/Plugins/MySQLLiteralSpellingTests.swift b/TableProTests/Plugins/MySQLLiteralSpellingTests.swift new file mode 100644 index 0000000000..dc5ff182fe --- /dev/null +++ b/TableProTests/Plugins/MySQLLiteralSpellingTests.swift @@ -0,0 +1,99 @@ +// +// MySQLLiteralSpellingTests.swift +// TableProTests +// +// Every expected spelling was run against MySQL 8.4.11 and MariaDB 13.0.2 with and without +// NO_BACKSLASH_ESCAPES, and each server stored the characters the test names. +// + +import Foundation +import Testing + +@Suite("MySQL literal spelling") +struct MySQLLiteralSpellingTests { + private let session = MySQLLiteralSpelling.quoteDoubling + + @Test("Only a session that reports NO_BACKSLASH_ESCAPES loses backslash escapes") + func spellingFollowsTheStatusFlag() { + #expect(MySQLLiteralSpelling(noBackslashEscapes: true) == .quoteDoubling) + #expect(MySQLLiteralSpelling(noBackslashEscapes: false) == .backslashEscapes) + #expect(MySQLLiteralSpelling(noBackslashEscapes: nil) == .backslashEscapes) + } + + @Test("A session with backslash escapes gets the statement exactly as written") + func backslashSessionIsUntouched() { + let sql = #"ALTER TABLE `t` MODIFY COLUMN `c` varchar(20) NULL DEFAULT 'x\\y' COMMENT 'a\nb'"# + #expect(MySQLLiteralSpelling.backslashEscapes.respelled(sql) == sql) + } + + /// Written with backslash escapes, `'x\\y'` stored `x\\y` under NO_BACKSLASH_ESCAPES and doubled + /// again on every later save of the column. + @Test("A backslash is written once for a session that reads it literally") + func backslashIsNotDoubled() { + let sql = #"ALTER TABLE `t` MODIFY COLUMN `c` varchar(20) NULL DEFAULT 'x\\y'"# + #expect(session.respelled(sql) == #"ALTER TABLE `t` MODIFY COLUMN `c` varchar(20) NULL DEFAULT 'x\y'"#) + } + + /// A raw line break inside a literal stores a line break in both modes, where `\n` stored the two + /// characters `\` and `n` under NO_BACKSLASH_ESCAPES. + @Test("Control characters are written raw for a session that reads backslashes literally") + func controlCharactersAreRaw() { + #expect(session.respelled(#"COMMENT 'a\nb\rc\td\Ze\bf\0g'"#) + == "COMMENT 'a\nb\rc\td\u{1A}e\u{08}f\u{00}g'") + } + + @Test("A quote escaped either way comes out doubled", arguments: [#"'it\'s'"#, "'it''s'"]) + func quotesAreDoubled(literal: String) { + #expect(session.respelled("DEFAULT \(literal)") == "DEFAULT 'it''s'") + } + + /// The server keeps `\%` and `\_` as two characters outside a pattern, and an unknown escape + /// stands for the character after it. + @Test("Pattern escapes keep their backslash and an unknown escape drops it") + func patternAndUnknownEscapes() { + #expect(session.respelled(#"LIKE 'a\_b\%c'"#) == #"LIKE 'a\_b\%c'"#) + #expect(session.respelled(#"'\q'"#) == "'q'") + } + + /// `SHOW CREATE TABLE` and the catalog print an expression default, a column's ENUM members and + /// a CHECK clause with backslash escapes whatever the session's mode. + @Test("Every literal the server printed is re-spelled, introducers and all") + func serverPrintedSQL() { + #expect(session.respelled(#"DEFAULT (concat(_utf8mb4'it\'s',_utf8mb4'x\\y'))"#) + == #"DEFAULT (concat(_utf8mb4'it''s',_utf8mb4'x\y'))"#) + #expect(session.respelled(#"`k` enum('a\\b','it''s','x')"#) == #"`k` enum('a\b','it''s','x')"#) + #expect(session.respelled(#"CHECK ((`k` <> _utf8mb4'z\\z'))"#) == #"CHECK ((`k` <> _utf8mb4'z\z'))"#) + } + + @Test("Identifiers, double-quoted text and comments are copied unchanged") + func nonLiteralsAreCopied() { + #expect(session.respelled(#"`we'ird\\col` = 'a\\b'"#) == #"`we'ird\\col` = 'a\b'"#) + #expect(session.respelled(#""it's\\" = 'a\\b'"#) == #""it's\\" = 'a\b'"#) + #expect(session.respelled("-- it's\n'a\\\\b'") == "-- it's\n'a\\b'") + #expect(session.respelled("# it's\n'a\\\\b'") == "# it's\n'a\\b'") + #expect(session.respelled("/* it's */ 'a\\\\b'") == "/* it's */ 'a\\b'") + #expect(session.respelled("5--1 'a\\\\b'") == "5--1 'a\\b'") + } + + @Test("A literal that never closes is left as it was") + func unterminatedLiteral() { + #expect(session.respelled(#"DEFAULT 'a\\b"#) == #"DEFAULT 'a\\b"#) + } + + @Test( + "Re-spelling a backslash-escaped literal reads back the same text as quoting it for the session", + arguments: ["C:\\temp\\next", "it's", "line\nbreak", "cr\rtab\t", "nul\u{00}sub\u{1A}bs\u{08}", + "form\u{0C}feed", "50%_off", "\\%\\_", "日本語 é", "'\\'", ""] + ) + func respellingMatchesDirectQuoting(value: String) { + let written = "'\(MySQLLiteralSpelling.backslashEscapes.escaped(value))'" + #expect(session.respelled(written) == "'\(session.escaped(value))'") + } + + /// MySQL and MariaDB have no `\f` escape: `'p\fq'` stored `pfq` on both, a raw form feed stored + /// `pq`. + @Test("A form feed is written as itself") + func formFeedIsRaw() { + #expect(mysqlEscapeStringLiteral("p\u{0C}q") == "p\u{0C}q") + } +} diff --git a/TableProTests/Plugins/SQLExportDialectTests.swift b/TableProTests/Plugins/SQLExportDialectTests.swift index 89459fbcb1..a610fae147 100644 --- a/TableProTests/Plugins/SQLExportDialectTests.swift +++ b/TableProTests/Plugins/SQLExportDialectTests.swift @@ -70,8 +70,10 @@ struct SQLExportDialectTests { } /// A dump taken without a driver has to be the same file as one taken with it. The MySQL - /// driver escapes nine characters; escaping only the backslash and the quote left a raw - /// `\u{1A}` in the output, which truncates a dump fed to the Windows `mysql` client. + /// driver escapes eight characters; escaping only the backslash and the quote left a raw + /// `\u{1A}` in the output, which truncates a dump fed to the Windows `mysql` client. A form feed + /// stays raw: MySQL has no `\f` escape and reads one as the letter `f`, measured on MySQL 8.4 and + /// MariaDB 13, so a dump that wrote it lost the character on import. @Test("The dialect escaper writes what the MySQL driver writes") func mysqlEscaperMatchesTheDriver() throws { let escape = escapeStringLiteralFromDialect(try #require(dialect(for: .mysql))) @@ -80,7 +82,7 @@ struct SQLExportDialectTests { #expect(escape("a\rb") == "a\\rb") #expect(escape("a\u{1A}b") == "a\\Zb") #expect(escape("a\u{08}b") == "a\\bb") - #expect(escape("a\u{0C}b") == "a\\fb") + #expect(escape("a\u{0C}b") == "a\u{0C}b") } /// PostgreSQL reads a backslash literally in a standard-conforming string, so doubling it diff --git a/project.yml b/project.yml index 75f28a29ca..fb9b340639 100644 --- a/project.yml +++ b/project.yml @@ -515,6 +515,7 @@ targets: - Plugins/MySQLDriverPlugin/MySQLLatin1.swift - Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift - Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift + - Plugins/MySQLDriverPlugin/MySQLLiteralSpelling.swift - Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift - Plugins/MySQLDriverPlugin/MySQLIdleRelease.swift - Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift