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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Query picked from Open Quickly's Recent list dropping out of it once the query ran again.
- Table opened in one database shown in Open Quickly's Recent in every other database, and opened there.
- Open Quickly's Recent split between the Connections scope and the other scopes, each showing about half.
- MySQL and MariaDB column defaults on iPhone and iPad missing for DEFAULT NULL, and string defaults shown unquoted.

### Security

Expand Down
20 changes: 20 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,26 @@ internal enum MySQLAccountSyntax: Equatable, Sendable {
case grantUsage
}

internal extension MySQLServerVersion {
/// Which account grammar this server takes. `CREATE USER ... WITH MAX_USER_CONNECTIONS`,
/// `ALTER USER ... WITH MAX_USER_CONNECTIONS` and `ALTER USER ... IDENTIFIED BY` all arrived in
/// MySQL 5.7.6 and MariaDB 10.2.0; measured, MySQL 5.5.62 and 5.6.51 and MariaDB 5.5.64,
/// 10.0.38 and 10.1.48 answer `ERROR 1064` to all three.
///
/// TiDB and OceanBase ignore the banner, which lies about them: OceanBase handshakes as 5.7.25,
/// or 5.6.25 through OBProxy.
static func accountSyntax(banner: String?, flavor: MySQLServerFlavor) -> MySQLAccountSyntax {
switch flavor {
case .mysql:
return isKnownBelow((5, 7, 6), banner: banner) ? .grantUsage : .alterUser
case .mariadb:
return isKnownBelow((10, 2, 0), banner: banner) ? .grantUsage : .alterUser
case .tidb, .oceanbase, .databend:
return .alterUser
}
}
}

/// `CREATE USER ... WITH MAX_USER_CONNECTIONS`, `ALTER USER ... WITH MAX_USER_CONNECTIONS` and
/// `ALTER USER ... IDENTIFIED BY` all arrived in MySQL 5.7.6 and MariaDB 10.2.0, and MySQL 8
/// removed every form that works below them, so no single spelling reaches both.
Expand Down
44 changes: 35 additions & 9 deletions Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import TableProPluginKit
///
/// Nothing in the value alone says which form it is in, and the two forms disagree on what SQL NULL
/// and a bare word mean, so the form travels with the value rather than as a flag beside it.
internal enum MySQLCatalogDefault: Equatable, Sendable {
nonisolated internal enum MySQLCatalogDefault: Equatable, Sendable {
/// `SHOW FULL COLUMNS` on every server, and `INFORMATION_SCHEMA.COLUMNS` on MySQL and on MariaDB
/// before 10.2.7. A literal arrives unquoted, MySQL marks an expression `DEFAULT_GENERATED` in
/// `EXTRA`, and SQL NULL stands for both `DEFAULT NULL` and no default at all.
Expand All @@ -32,7 +32,7 @@ internal enum MySQLCatalogDefault: Equatable, Sendable {
}

/// The column defaults `SHOW CREATE TABLE` states, and which columns a column read takes them for.
internal struct MySQLCreateTableDefaults: Equatable, Sendable {
nonisolated internal struct MySQLCreateTableDefaults: Equatable, Sendable {
enum Scope: Equatable, Sendable {
/// A MariaDB whose catalog did not answer in its quoted form: the statement is the only exact
/// source, for every column. A column with no `DEFAULT` clause there has none.
Expand Down Expand Up @@ -68,7 +68,7 @@ internal struct MySQLCreateTableDefaults: Equatable, Sendable {
///
/// A generated or AUTO_INCREMENT column has no default whatever the catalog says. MariaDB reports a
/// generated column's as `NULL`, and the server refuses a `DEFAULT` on either.
internal func mysqlColumnDefault(
nonisolated internal func mysqlColumnDefault(
_ catalog: MySQLCatalogDefault,
extra: String?,
dataType: String,
Expand All @@ -80,7 +80,29 @@ internal func mysqlColumnDefault(
return mysqlBareCatalogDefault(value, extra: extra, dataType: dataType)
}

private func mysqlBareCatalogDefault(_ value: String, extra: String?, dataType: String) -> String {
/// The default a `SHOW FULL COLUMNS` row stands for, from the most exact source read for its column.
///
/// The catalog's quoted form comes first where the server has one, then `SHOW CREATE TABLE`, and the
/// row's own bare value last. The bare value alone takes a MariaDB expression default for a string,
/// and leaves a MySQL expression holding non-ASCII text as the catalog encoded it.
nonisolated internal func mysqlShowColumnsDefault(
_ shown: String?,
catalog: MySQLCatalogDefault?,
createTable: MySQLCreateTableDefaults?,
column: String,
extra: String?,
dataType: String,
isNullable: Bool
) -> String? {
mysqlColumnDefault(
catalog ?? createTable?.catalogDefault(forColumn: column, extra: extra) ?? .bare(shown),
extra: extra,
dataType: dataType,
isNullable: isNullable
)
}

nonisolated private func mysqlBareCatalogDefault(_ value: String, extra: String?, dataType: String) -> String {
// MySQL 8.0.13 marks a plain `DEFAULT CURRENT_TIMESTAMP` DEFAULT_GENERATED like any other
// expression, so this has to be answered before the marker is consulted or the one expression
// MySQL insists on bare comes back parenthesised.
Expand All @@ -96,7 +118,7 @@ private func mysqlBareCatalogDefault(_ value: String, extra: String?, dataType:
}

/// MySQL's marker for an expression default, in `EXTRA` of both catalog reads.
internal func mysqlIsExpressionDefault(extra: String?) -> Bool {
nonisolated internal func mysqlIsExpressionDefault(extra: String?) -> Bool {
extra?.uppercased().contains("DEFAULT_GENERATED") == true
}

Expand All @@ -106,7 +128,11 @@ internal func mysqlIsExpressionDefault(extra: String?) -> Bool {
/// exactly, and any non-ASCII text in it encoded twice, which nothing can undo: measured on 8.4.11,
/// `concat('日','x')` comes back with `日` as `æ\u{97}¥`. `SHOW CREATE TABLE` prints it exactly, so an
/// expression holding non-ASCII text is read from there and every other one from the catalog.
internal func mysqlExpressionDefaultNeedsCreateTable(_ value: String?, extra: String?, dataType: String) -> Bool {
nonisolated internal func mysqlExpressionDefaultNeedsCreateTable(
_ value: String?,
extra: String?,
dataType: String
) -> Bool {
guard mysqlIsExpressionDefault(extra: extra), let value else { return false }
return !value.unicodeScalars.allSatisfy(\.isASCII)
}
Expand All @@ -115,7 +141,7 @@ internal func mysqlExpressionDefaultNeedsCreateTable(_ value: String?, extra: St
/// as. Before 10.2.7 its catalog quotes nothing, so `uuid()` and `'uuid()'` both come back `uuid()`.
/// A default with no parenthesis in it cannot be an expression there, and `CURRENT_TIMESTAMP` on a
/// temporal column reads right either way.
internal func mariaDBBareDefaultMayBeExpression(_ value: String?, dataType: String) -> Bool {
nonisolated internal func mariaDBBareDefaultMayBeExpression(_ value: String?, dataType: String) -> Bool {
guard let value, value.contains("(") else { return false }
return !(mysqlTemporalType(dataType) && mysqlCurrentTimestampExpression(value, dataType: dataType) != nil)
}
Expand All @@ -132,7 +158,7 @@ internal func mariaDBBareDefaultMayBeExpression(_ value: String?, dataType: Stri
/// Non-ASCII text is returned as it came too. The catalog encodes it twice, so unescaping it would
/// produce a statement the server accepts with different text in it, and a default that changes
/// without a word is worse than an edit the server refuses.
internal func mysqlUnescapedCatalogExpression(_ value: String) -> String {
nonisolated internal func mysqlUnescapedCatalogExpression(_ value: String) -> String {
guard value.unicodeScalars.allSatisfy(\.isASCII) else { return value }
var result = ""
var index = value.startIndex
Expand All @@ -157,7 +183,7 @@ internal func mysqlUnescapedCatalogExpression(_ value: String) -> String {
/// A string default comes back stripped of its quotes and has to be given them again. A number, a
/// `BIT` default (`b'1'`) and a binary default (`0x61`) all come back as the literal they are, and
/// quoting one changes what it means: `0x61` quoted stores the four characters rather than the byte.
internal func mysqlCatalogReportsLiteralAsSQL(dataType: String) -> Bool {
nonisolated internal func mysqlCatalogReportsLiteralAsSQL(dataType: String) -> Bool {
let base = dataType.uppercased().split(separator: "(", maxSplits: 1).first.map(String.init)?
.trimmingCharacters(in: .whitespaces) ?? dataType.uppercased()
switch base {
Expand Down
42 changes: 0 additions & 42 deletions Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,6 @@ 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: "\\\\")
result = result.replacingOccurrences(of: "'", with: "''")
result = result.replacingOccurrences(of: "\n", with: "\\n")
result = result.replacingOccurrences(of: "\r", with: "\\r")
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{1A}", with: "\\Z")
return result
}

/// MySQL rejects a CURRENT_TIMESTAMP expression whose fractional-second precision differs
/// from the column's own, so the precision is always taken from the declared type.
internal func mysqlFractionalSecondsSuffix(forDataType dataType: String) -> String {
let upper = dataType.uppercased()
guard upper.hasPrefix("TIMESTAMP(") || upper.hasPrefix("DATETIME(") else { return "" }
guard let open = dataType.firstIndex(of: "("),
let close = dataType[open...].firstIndex(of: ")") else { return "" }
return String(dataType[open...close])
}

internal func mysqlCurrentTimestampExpression(_ value: String, dataType: String) -> String? {
let upper = value.uppercased()
guard upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()"
|| upper.hasPrefix("CURRENT_TIMESTAMP(") else { return nil }
return "CURRENT_TIMESTAMP" + mysqlFractionalSecondsSuffix(forDataType: dataType)
}

/// MySQL and MariaDB take a `BLOB`, `TEXT`, `JSON` or `GEOMETRY` default "only if the value is
/// written as an expression, even if the expression value is a literal", so the parentheses are
/// required by the grammar rather than chosen by the caller.
Expand Down Expand Up @@ -122,16 +90,6 @@ internal func mysqlIsNullLiteral(_ value: String) -> Bool {
value.trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("NULL") == .orderedSame
}

/// The only types on which a bare `CURRENT_TIMESTAMP` is the temporal expression rather than the
/// seventeen-character string. On a `VARCHAR`, MySQL reports a literal of that text the same way and
/// with no `DEFAULT_GENERATED` marker, so reading it as the expression turns a stored string into a
/// clock reading on the next edit to the column.
internal func mysqlTemporalType(_ dataType: String) -> Bool {
let base = dataType.uppercased().split(separator: "(", maxSplits: 1).first.map(String.init)?
.trimmingCharacters(in: .whitespaces) ?? dataType.uppercased()
return base == "TIMESTAMP" || base == "DATETIME"
}

internal func mysqlColumnAttributesSQL(_ column: PluginColumnDefinition, isMariaDB: Bool) -> String {
var def = ""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import TableProPluginKit
///
/// "DEFAULT_GENERATED" is a MySQL 8 expression default, not a generated column,
/// and stays insertable.
internal func mysqlColumnIsGenerated(extra: String?) -> Bool {
nonisolated internal func mysqlColumnIsGenerated(extra: String?) -> Bool {
guard let extra else { return false }
let upper = extra.uppercased()
if upper.contains("STORED GENERATED") || upper.contains("VIRTUAL GENERATED") {
Expand All @@ -33,13 +33,13 @@ internal func mysqlColumnIsGenerated(extra: String?) -> Bool {
///
/// It is `byDefault` rather than `always`: MySQL accepts an explicit value and only allocates the
/// next one when the column is omitted or given NULL.
internal func mysqlIdentityKind(extra: String?) -> IdentityKind? {
nonisolated internal func mysqlIdentityKind(extra: String?) -> IdentityKind? {
guard let extra, extra.uppercased().contains("AUTO_INCREMENT") else { return nil }
return .byDefault
}

/// The kind, from the same `Extra` value. MariaDB 10.1 and older spell stored as "PERSISTENT".
internal func mysqlGenerationKind(extra: String?) -> GenerationKind? {
nonisolated internal func mysqlGenerationKind(extra: String?) -> GenerationKind? {
guard let extra, mysqlColumnIsGenerated(extra: extra) else { return nil }
let upper = extra.uppercased()
if upper.contains("STORED GENERATED") || upper.trimmingCharacters(in: .whitespaces) == "PERSISTENT" {
Expand Down
51 changes: 51 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLLiteralSQL.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// MySQLLiteralSQL.swift
// MySQLDriverPlugin
//
// The literal spellings the column default reader and the DDL writers share. Foundation only, so
// the iOS app compiles it alongside the reader.
//

import Foundation

/// 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.
nonisolated internal func mysqlEscapeStringLiteral(_ value: String) -> String {
var result = value
result = result.replacingOccurrences(of: "\\", with: "\\\\")
result = result.replacingOccurrences(of: "'", with: "''")
result = result.replacingOccurrences(of: "\n", with: "\\n")
result = result.replacingOccurrences(of: "\r", with: "\\r")
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{1A}", with: "\\Z")
return result
}

/// MySQL rejects a CURRENT_TIMESTAMP expression whose fractional-second precision differs
/// from the column's own, so the precision is always taken from the declared type.
nonisolated internal func mysqlFractionalSecondsSuffix(forDataType dataType: String) -> String {
let upper = dataType.uppercased()
guard upper.hasPrefix("TIMESTAMP(") || upper.hasPrefix("DATETIME(") else { return "" }
guard let open = dataType.firstIndex(of: "("),
let close = dataType[open...].firstIndex(of: ")") else { return "" }
return String(dataType[open...close])
}

nonisolated internal func mysqlCurrentTimestampExpression(_ value: String, dataType: String) -> String? {
let upper = value.uppercased()
guard upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()"
|| upper.hasPrefix("CURRENT_TIMESTAMP(") else { return nil }
return "CURRENT_TIMESTAMP" + mysqlFractionalSecondsSuffix(forDataType: dataType)
}

/// The only types on which a bare `CURRENT_TIMESTAMP` is the temporal expression rather than the
/// seventeen-character string. On a `VARCHAR`, MySQL reports a literal of that text the same way and
/// with no `DEFAULT_GENERATED` marker, so reading it as the expression turns a stored string into a
/// clock reading on the next edit to the column.
nonisolated internal func mysqlTemporalType(_ dataType: String) -> Bool {
let base = dataType.uppercased().split(separator: "(", maxSplits: 1).first.map(String.init)?
.trimmingCharacters(in: .whitespaces) ?? dataType.uppercased()
return base == "TIMESTAMP" || base == "DATETIME"
}
45 changes: 21 additions & 24 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,32 +42,29 @@ internal extension MySQLPluginDriver {
createTableClauses: [String: String]?,
createTableDefaults: MySQLCreateTableDefaults?
) -> String? {
if flavor.isOceanBase, let catalogDefault {
if let currentTimestamp = OceanBaseColumnDefaults.currentTimestampDefault(catalogDefault, dataType: dataType) {
return currentTimestamp
}
if let createTableClauses,
OceanBaseColumnDefaults.catalogDefaultNeedsCreateTable(catalogDefault, dataType: dataType) {
let resolution = OceanBaseColumnDefaults.resolve(
clause: createTableClauses[column], catalogDefault: catalogDefault
)
if case .value(let value) = resolution {
return value
}
Self.logger.warning(
"OceanBase default of \(column, privacy: .public) is not in SHOW CREATE TABLE as reported"
)
}
if let binaryLiteral = OceanBaseColumnDefaults.binaryLiteralDefault(catalogDefault, dataType: dataType) {
return binaryLiteral
guard flavor.isOceanBase else {
return mysqlColumnDefault(
createTableDefaults?.catalogDefault(forColumn: column, extra: extra)
?? (catalogQuotesDefaults ? .quoted(catalogDefault) : .bare(catalogDefault)),
extra: extra,
dataType: dataType,
isNullable: isNullable
)
}
if let catalogDefault, let createTableClauses,
OceanBaseColumnDefaults.catalogDefaultNeedsCreateTable(catalogDefault, dataType: dataType) {
let resolution = OceanBaseColumnDefaults.resolve(
clause: createTableClauses[column], catalogDefault: catalogDefault
)
if case .value(let value) = resolution {
return value
}
Self.logger.warning(
"OceanBase default of \(column, privacy: .public) is not in SHOW CREATE TABLE as reported"
)
}
return mysqlColumnDefault(
createTableDefaults?.catalogDefault(forColumn: column, extra: extra)
?? (catalogQuotesDefaults ? .quoted(catalogDefault) : .bare(catalogDefault)),
extra: extra,
dataType: dataType,
isNullable: isNullable
return OceanBaseColumnDefaults.columnDefault(
catalogDefault, extra: extra, dataType: dataType, isNullable: isNullable
)
}

Expand Down
9 changes: 5 additions & 4 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,11 @@ internal extension MySQLPluginDriver {
? dataType : upperType
let allowedValues = EnumValueParser.parseMySQLEnumOrSet(from: normalizedType)
let detail = catalogDetails[name]
let defaultValue = mysqlColumnDefault(
detail?.catalogDefault
?? createTableDefaults?.catalogDefault(forColumn: name, extra: extra)
?? .bare(rawDefault),
let defaultValue = mysqlShowColumnsDefault(
rawDefault,
catalog: detail?.catalogDefault,
createTable: createTableDefaults,
column: name,
extra: extra,
dataType: normalizedType,
isNullable: isNullable
Expand Down
Loading