Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "\\\\")
Expand All @@ -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
}
Expand Down
171 changes: 171 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLLiteralSpelling.swift
Original file line number Diff line number Diff line change
@@ -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..<end])
index = end
continue
case "#":
let end = Self.endOfLine(in: scalars, from: index)
output.append(contentsOf: scalars[index..<end])
index = end
continue
case "-" where Self.opensDashComment(scalars, at: index):
let end = Self.endOfLine(in: scalars, from: index)
output.append(contentsOf: scalars[index..<end])
index = end
continue
case "/" where index + 1 < scalars.count && scalars[index + 1] == "*":
let end = Self.endOfBlockComment(in: scalars, from: index)
output.append(contentsOf: scalars[index..<end])
index = end
continue
default:
output.append(scalar)
index += 1
}
}
return String(output)
}

/// What a single-quoted literal spelled with backslash escapes reads as, and where it ends, or
/// nil when it never closes. `\%` and `\_` keep their backslash, as the server keeps them
/// outside a pattern, and any other escaped character stands for itself.
private static func backslashLiteral(
in scalars: [Unicode.Scalar],
openingAt start: Int
) -> (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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ internal extension MySQLPluginDriver {

private func baseTableNames(among tables: Set<String>, schema: String?) async throws -> Set<String> {
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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
)
Expand Down
Loading
Loading