From e6e21d558415c81c52659e354b8dbe87fd42f366 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 23 Sep 2026 21:41:19 +0700 Subject: [PATCH] fix(ios): read MySQL and MariaDB column defaults the way the Mac app does --- CHANGELOG.md | 1 + .../MySQLAccountStatements.swift | 20 ++ .../MySQLCatalogDefault.swift | 44 ++- .../MySQLColumnDefinitionSQL.swift | 42 --- .../MySQLGeneratedColumnClassification.swift | 6 +- .../MySQLDriverPlugin/MySQLLiteralSQL.swift | 51 ++++ .../MySQLPluginDriver+OceanBaseDefaults.swift | 45 ++- .../MySQLPluginDriver+Schema.swift | 9 +- .../MySQLServerVersion.swift | 20 +- .../OceanBaseColumnDefaults.swift | 20 +- .../Drivers/MySQLColumnListing.swift | 113 ++++++++ .../TableProMobile/Drivers/MySQLDriver.swift | 39 +-- .../Drivers/MySQLColumnListingTests.swift | 272 ++++++++++++++++++ TableProMobile/project.yml | 7 + .../MySQLColumnDefinitionSQLTests.swift | 17 ++ .../OceanBaseColumnDefaultsTests.swift | 14 + project.yml | 1 + 17 files changed, 602 insertions(+), 119 deletions(-) create mode 100644 Plugins/MySQLDriverPlugin/MySQLLiteralSQL.swift create mode 100644 TableProMobile/TableProMobile/Drivers/MySQLColumnListing.swift create mode 100644 TableProMobile/TableProMobileTests/Drivers/MySQLColumnListingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 097521060f..5ae86d408a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift b/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift index bf52455509..e669f7e863 100644 --- a/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift +++ b/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift @@ -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. diff --git a/Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift b/Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift index 13ffe116e8..d66ca8cef6 100644 --- a/Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift +++ b/Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift @@ -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. @@ -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. @@ -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, @@ -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. @@ -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 } @@ -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) } @@ -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) } @@ -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 @@ -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 { diff --git a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift index 133232e17b..cec68ff051 100644 --- a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift +++ b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift @@ -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. @@ -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 = "" diff --git a/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift b/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift index dffca9f439..7970af0ed8 100644 --- a/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift +++ b/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift @@ -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") { @@ -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" { diff --git a/Plugins/MySQLDriverPlugin/MySQLLiteralSQL.swift b/Plugins/MySQLDriverPlugin/MySQLLiteralSQL.swift new file mode 100644 index 0000000000..9cc966ea8f --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLLiteralSQL.swift @@ -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" +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift index ed7d2d1ec9..351ea1dbad 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift @@ -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 ) } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index 2eb2d1c754..a1b1067cd3 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -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 diff --git a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift index fbd55bb88b..c4cb997022 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift @@ -11,7 +11,7 @@ import Foundation /// One plugin serves MySQL and MariaDB, and they gained these catalogs at different releases. /// Reading one that does not exist is not a soft failure: the structure load surfaces the error /// and the whole Structure tab refuses to open, so each read is gated before it runs. -enum MySQLServerVersion { +nonisolated internal enum MySQLServerVersion { /// `(major, minor, patch)` from a version banner such as `8.0.36` or `10.6.16-MariaDB`. static func components(from banner: String) -> (major: Int, minor: Int, patch: Int)? { let leading = banner.prefix { $0.isNumber || $0 == "." } @@ -52,24 +52,6 @@ enum 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 - } - } - /// `COLUMNS.GENERATION_EXPRESSION` arrived with generated columns: MySQL 5.7.6, MariaDB 10.2. /// MariaDB 10.1 has the columns but not the catalog column. static func hasGenerationExpression(banner: String?, flavor: MySQLServerFlavor) -> Bool { diff --git a/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift b/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift index 37c84c8eb2..6d14e8d77c 100644 --- a/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift +++ b/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift @@ -5,7 +5,7 @@ import Foundation -internal enum OceanBaseColumnDefaults { +nonisolated internal enum OceanBaseColumnDefaults { enum Resolution: Equatable { case value(String) case unverified @@ -35,6 +35,24 @@ internal enum OceanBaseColumnDefaults { return "'\(mysqlEscapeStringLiteral(catalogDefault))'" } + /// The default OceanBase's catalog text stands for, where `SHOW CREATE TABLE` has not settled it. + /// + /// The catalog drops the precision `CURRENT_TIMESTAMP(3)` was written with and reports a binary + /// default as the text it holds, so both are answered before the MySQL reading of a bare catalog. + static func columnDefault( + _ catalogDefault: String?, + extra: String?, + dataType: String, + isNullable: Bool + ) -> String? { + if let catalogDefault, + let literal = currentTimestampDefault(catalogDefault, dataType: dataType) + ?? binaryLiteralDefault(catalogDefault, dataType: dataType) { + return literal + } + return mysqlColumnDefault(.bare(catalogDefault), extra: extra, dataType: dataType, isNullable: isNullable) + } + static func resolve(clause: String?, catalogDefault: String) -> Resolution { guard let clause, clause.uppercased() != "NULL" else { return .unverified } if clause.hasPrefix("'") { diff --git a/TableProMobile/TableProMobile/Drivers/MySQLColumnListing.swift b/TableProMobile/TableProMobile/Drivers/MySQLColumnListing.swift new file mode 100644 index 0000000000..68a1ec5b93 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/MySQLColumnListing.swift @@ -0,0 +1,113 @@ +import Foundation +import TableProDatabase +import TableProModels + +/// The columns `SHOW FULL COLUMNS` lists, each default read the way the Mac app reads it. +nonisolated internal enum MySQLColumnListing { + enum DefaultSource: Equatable, Sendable { + /// `SHOW FULL COLUMNS` alone, in its bare form: MySQL, TiDB, and a MariaDB before 10.2.7. The + /// Mac app also reads `SHOW CREATE TABLE` where that form is ambiguous, and this does not. + case showFullColumns + /// `INFORMATION_SCHEMA.COLUMNS` in the quoted form a MariaDB uses from 10.2.7, the only read + /// there that tells an expression default from a string. + case quotedCatalog + /// `INFORMATION_SCHEMA.COLUMNS`, which OceanBase answers right for a view where its `SHOW FULL + /// COLUMNS` does not. + case oceanBaseCatalog + /// The server's own text. Databend's defaults follow none of MySQL's catalog rules. + case asReported + } + + static func defaultSource(flavor: MySQLServerFlavor, banner: String?) -> DefaultSource { + if flavor.isDatabend { return .asReported } + if flavor.isOceanBase { return .oceanBaseCatalog } + guard MySQLServerVersion.quotesColumnDefault(banner: banner, flavor: flavor) else { return .showFullColumns } + return .quotedCatalog + } + + /// The catalog read for one table's defaults, or nil when `SHOW FULL COLUMNS` is the only source. + /// + /// It filters on `DATABASE()` because the unqualified `SHOW FULL COLUMNS` reads the session's + /// database, and the two answers are matched by column name alone. + static func catalogDefaultsQuery(table: String, source: DefaultSource) -> String? { + switch source { + case .quotedCatalog, .oceanBaseCatalog: + return """ + SELECT COLUMN_NAME, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '\(SQLEscaping.backslashStringLiteral(table))' + """ + case .showFullColumns, .asReported: + return nil + } + } + + static func catalogDefaults(fromRows rows: [[String?]], source: DefaultSource) -> [String: MySQLCatalogDefault] { + var defaults: [String: MySQLCatalogDefault] = [:] + for row in rows { + guard row.count >= 2, let name = row[0] else { continue } + defaults[name] = source == .quotedCatalog ? .quoted(row[1]) : .bare(row[1]) + } + return defaults + } + + static func columns( + fromShowFullColumns rows: [[String?]], + catalogDefaults: [String: MySQLCatalogDefault], + source: DefaultSource + ) -> [ColumnInfo] { + rows.enumerated().compactMap { index, row in + guard row.count >= 9, let name = row[0], let dataType = row[1] else { return nil } + let isNullable = row[3]?.uppercased() == "YES" + let extra = row[6] + return ColumnInfo( + name: name, + typeName: dataType, + isPrimaryKey: row[4]?.uppercased().contains("PRI") == true, + isNullable: isNullable, + defaultValue: columnDefault( + row[5], + catalog: catalogDefaults[name], + source: source, + column: name, + extra: extra, + dataType: dataType, + isNullable: isNullable + ), + comment: row[8], + characterMaxLength: nil, + ordinalPosition: index, + isAutoIncrement: ColumnMetadataRules.mySQLIsAutoIncrement(extra: extra), + isGenerated: ColumnMetadataRules.mySQLIsGenerated(extra: extra) + ) + } + } + + private static func columnDefault( + _ shown: String?, + catalog: MySQLCatalogDefault?, + source: DefaultSource, + column: String, + extra: String?, + dataType: String, + isNullable: Bool + ) -> String? { + switch source { + case .asReported: + return shown + case .oceanBaseCatalog: + return OceanBaseColumnDefaults.columnDefault( + (catalog ?? .bare(shown)).value, extra: extra, dataType: dataType, isNullable: isNullable + ) + case .showFullColumns, .quotedCatalog: + return mysqlShowColumnsDefault( + shown, + catalog: catalog, + createTable: nil, + column: column, + extra: extra, + dataType: dataType, + isNullable: isNullable + ) + } + } +} diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index d1c015372d..ca7500f881 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -180,24 +180,29 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { let safe = table.replacingOccurrences(of: "`", with: "``") let raw = try await actor.execute("SHOW FULL COLUMNS FROM `\(safe)`") - - return raw.rows.enumerated().compactMap { index, row in - guard row.count >= 9, let name = row[0], let dataType = row[1] else { return nil } - let isPK = row[4]?.uppercased().contains("PRI") == true - let isNullable = row[3]?.uppercased() == "YES" - let extra = row[6] - return ColumnInfo( - name: name, - typeName: dataType, - isPrimaryKey: isPK, - isNullable: isNullable, - defaultValue: row[5], - comment: row[8], - characterMaxLength: nil, - ordinalPosition: index, - isAutoIncrement: ColumnMetadataRules.mySQLIsAutoIncrement(extra: extra), - isGenerated: ColumnMetadataRules.mySQLIsGenerated(extra: extra) + let source = MySQLColumnListing.defaultSource( + flavor: Self.serverFlavor(for: databaseType, banner: serverVersion), + banner: serverVersion + ) + let catalog = await catalogDefaults(table: table, source: source) + return MySQLColumnListing.columns(fromShowFullColumns: raw.rows, catalogDefaults: catalog, source: source) + } + + /// A catalog that refuses leaves every default on what `SHOW FULL COLUMNS` reported, as it does on + /// the Mac, rather than taking the column list down with it. + private func catalogDefaults( + table: String, + source: MySQLColumnListing.DefaultSource + ) async -> [String: MySQLCatalogDefault] { + guard let query = MySQLColumnListing.catalogDefaultsQuery(table: table, source: source) else { return [:] } + do { + let raw = try await actor.execute(query) + return MySQLColumnListing.catalogDefaults(fromRows: raw.rows, source: source) + } catch { + Self.logger.warning( + "Column default catalog read failed: \(error.localizedDescription, privacy: .private)" ) + return [:] } } diff --git a/TableProMobile/TableProMobileTests/Drivers/MySQLColumnListingTests.swift b/TableProMobile/TableProMobileTests/Drivers/MySQLColumnListingTests.swift new file mode 100644 index 0000000000..58f854171a --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/MySQLColumnListingTests.swift @@ -0,0 +1,272 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("MySQL column listing") +struct MySQLColumnListingTests { + private static let privileges = "select,insert,update,references" + + /// `SHOW FULL COLUMNS FROM t` verbatim from MySQL 8.4.11, with `Default` and `Extra` at 5 and 6. + private static let mysqlShowFullColumns: [[String?]] = [ + ["id", "int", nil, "NO", "PRI", nil, "auto_increment", privileges, ""], + ["nullable_default_null", "varchar(20)", "utf8mb4_0900_ai_ci", "YES", "", nil, "", privileges, ""], + ["nullable_no_clause", "varchar(20)", "utf8mb4_0900_ai_ci", "YES", "", nil, "", privileges, ""], + ["not_null_no_default", "varchar(20)", "utf8mb4_0900_ai_ci", "NO", "", nil, "", privileges, ""], + ["s_abc", "varchar(20)", "utf8mb4_0900_ai_ci", "YES", "", "abc", "", privileges, ""], + ["s_empty", "varchar(20)", "utf8mb4_0900_ai_ci", "YES", "", "", "", privileges, ""], + ["s_null_text", "varchar(20)", "utf8mb4_0900_ai_ci", "YES", "", "NULL", "", privileges, ""], + ["s_quote", "varchar(20)", "utf8mb4_0900_ai_ci", "YES", "", "it's", "", privileges, ""], + ["n_int", "int", nil, "YES", "", "5", "", privileges, ""], + ["n_dec", "decimal(5,2)", nil, "YES", "", "1.50", "", privileges, ""], + ["ts", "timestamp(3)", nil, "YES", "", "CURRENT_TIMESTAMP(3)", "DEFAULT_GENERATED", privileges, ""], + [ + "dt", "datetime", nil, "YES", "", "CURRENT_TIMESTAMP", + "DEFAULT_GENERATED on update CURRENT_TIMESTAMP", privileges, "" + ], + ["s_ct_text", "varchar(20)", "utf8mb4_0900_ai_ci", "YES", "", "CURRENT_TIMESTAMP", "", privileges, ""], + ["e_uuid", "varchar(36)", "utf8mb4_0900_ai_ci", "YES", "", "uuid()", "DEFAULT_GENERATED", privileges, ""], + [ + "e_concat", "varchar(20)", "utf8mb4_0900_ai_ci", "YES", "", #"concat(_utf8mb4\'a\',_utf8mb4\'b\')"#, + "DEFAULT_GENERATED", privileges, "" + ], + ["s_uuid_text", "varchar(36)", "utf8mb4_0900_ai_ci", "YES", "", "uuid()", "", privileges, ""], + ["b", "bit(1)", nil, "YES", "", "b'1'", "", privileges, ""], + ["bin", "varbinary(4)", nil, "YES", "", "0x6162", "", privileges, ""], + ["g", "int", nil, "YES", "", nil, "VIRTUAL GENERATED", privileges, ""], + ["txt", "text", "utf8mb4_0900_ai_ci", "YES", "", nil, "", privileges, ""] + ] + + /// `SHOW FULL COLUMNS FROM t` verbatim from MariaDB 13.0.2, for the same table. Every literal comes + /// back unquoted and no expression is marked, so `e_uuid` and `s_uuid_text` read the same here. + private static let mariaDBShowFullColumns: [[String?]] = [ + ["id", "int(11)", nil, "NO", "PRI", nil, "auto_increment", privileges, ""], + ["nullable_default_null", "varchar(20)", "utf8mb4_general_ci", "YES", "", nil, "", privileges, ""], + ["nullable_no_clause", "varchar(20)", "utf8mb4_general_ci", "YES", "", nil, "", privileges, ""], + ["not_null_no_default", "varchar(20)", "utf8mb4_general_ci", "NO", "", nil, "", privileges, ""], + ["s_abc", "varchar(20)", "utf8mb4_general_ci", "YES", "", "abc", "", privileges, ""], + ["s_empty", "varchar(20)", "utf8mb4_general_ci", "YES", "", "", "", privileges, ""], + ["s_null_text", "varchar(20)", "utf8mb4_general_ci", "YES", "", "NULL", "", privileges, ""], + ["s_quote", "varchar(20)", "utf8mb4_general_ci", "YES", "", "it's", "", privileges, ""], + ["n_int", "int(11)", nil, "YES", "", "5", "", privileges, ""], + ["n_dec", "decimal(5,2)", nil, "YES", "", "1.50", "", privileges, ""], + ["ts", "timestamp(3)", nil, "YES", "", "current_timestamp(3)", "", privileges, ""], + [ + "dt", "datetime", nil, "YES", "", "current_timestamp()", "on update current_timestamp()", + privileges, "" + ], + ["s_ct_text", "varchar(20)", "utf8mb4_general_ci", "YES", "", "CURRENT_TIMESTAMP", "", privileges, ""], + ["e_uuid", "varchar(36)", "utf8mb4_general_ci", "YES", "", "uuid()", "", privileges, ""], + ["e_concat", "varchar(20)", "utf8mb4_general_ci", "YES", "", "concat('a','b')", "", privileges, ""], + ["s_uuid_text", "varchar(36)", "utf8mb4_general_ci", "YES", "", "uuid()", "", privileges, ""], + ["b", "bit(1)", nil, "YES", "", "b'1'", "", privileges, ""], + ["bin", "varbinary(4)", nil, "YES", "", "x'6162'", "", privileges, ""], + ["g", "int(11)", nil, "YES", "", nil, "VIRTUAL GENERATED", privileges, ""], + ["txt", "text", "utf8mb4_general_ci", "YES", "", nil, "", privileges, ""] + ] + + /// `SELECT COLUMN_NAME, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS` verbatim from MariaDB 13.0.2, + /// where literals are quoted, expressions are bare and `DEFAULT NULL` is the unquoted text `NULL`. + private static let mariaDBCatalogColumns: [[String?]] = [ + ["id", nil], + ["nullable_default_null", "NULL"], + ["nullable_no_clause", "NULL"], + ["not_null_no_default", nil], + ["s_abc", "'abc'"], + ["s_empty", "''"], + ["s_null_text", "'NULL'"], + ["s_quote", "'it''s'"], + ["n_int", "5"], + ["n_dec", "1.50"], + ["ts", "current_timestamp(3)"], + ["dt", "current_timestamp()"], + ["s_ct_text", "'CURRENT_TIMESTAMP'"], + ["e_uuid", "uuid()"], + ["e_concat", "concat('a','b')"], + ["s_uuid_text", "'uuid()'"], + ["b", "b'1'"], + ["bin", "x'6162'"], + ["g", "NULL"], + ["txt", "NULL"] + ] + + private func defaults(of columns: [ColumnInfo]) -> [String: String?] { + Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0.defaultValue) }) + } + + private func expectDefaults(_ columns: [ColumnInfo], _ expected: [(name: String, sql: String?)]) { + let byName = defaults(of: columns) + for column in expected { + #expect(byName[column.name] == .some(column.sql), "\(column.name)") + } + } + + @Test("MySQL defaults read as the SQL that recreates them, DEFAULT NULL included") + func mysqlDefaultsReadAsSQL() { + let source = MySQLColumnListing.defaultSource(flavor: .mysql, banner: "8.4.11") + #expect(source == .showFullColumns) + #expect(MySQLColumnListing.catalogDefaultsQuery(table: "t", source: source) == nil) + + let columns = MySQLColumnListing.columns( + fromShowFullColumns: Self.mysqlShowFullColumns, catalogDefaults: [:], source: source + ) + #expect(columns.map(\.name) == Self.mysqlShowFullColumns.compactMap { $0[0] }) + expectDefaults(columns, [ + ("id", nil), + ("nullable_default_null", "NULL"), + ("nullable_no_clause", "NULL"), + ("not_null_no_default", nil), + ("s_abc", "'abc'"), + ("s_empty", "''"), + ("s_null_text", "'NULL'"), + ("s_quote", "'it''s'"), + ("n_int", "5"), + ("n_dec", "1.50"), + ("ts", "CURRENT_TIMESTAMP(3)"), + ("dt", "CURRENT_TIMESTAMP"), + ("s_ct_text", "'CURRENT_TIMESTAMP'"), + ("e_uuid", "(uuid())"), + ("e_concat", "(concat(_utf8mb4'a',_utf8mb4'b'))"), + ("s_uuid_text", "'uuid()'"), + ("b", "b'1'"), + ("bin", "0x6162"), + ("g", nil), + ("txt", "NULL") + ]) + } + + @Test("MariaDB defaults come from its quoted catalog, which tells an expression from a string") + func mariaDBDefaultsComeFromTheQuotedCatalog() { + let source = MySQLColumnListing.defaultSource(flavor: .mariadb, banner: "13.0.2-MariaDB") + #expect(source == .quotedCatalog) + + let catalog = MySQLColumnListing.catalogDefaults(fromRows: Self.mariaDBCatalogColumns, source: source) + let columns = MySQLColumnListing.columns( + fromShowFullColumns: Self.mariaDBShowFullColumns, catalogDefaults: catalog, source: source + ) + expectDefaults(columns, [ + ("id", nil), + ("nullable_default_null", "NULL"), + ("nullable_no_clause", "NULL"), + ("not_null_no_default", nil), + ("s_abc", "'abc'"), + ("s_empty", "''"), + ("s_null_text", "'NULL'"), + ("s_quote", "'it''s'"), + ("n_int", "5"), + ("n_dec", "1.50"), + ("ts", "current_timestamp(3)"), + ("dt", "current_timestamp()"), + ("s_ct_text", "'CURRENT_TIMESTAMP'"), + ("e_uuid", "uuid()"), + ("e_concat", "concat('a','b')"), + ("s_uuid_text", "'uuid()'"), + ("b", "b'1'"), + ("bin", "x'6162'"), + ("g", nil), + ("txt", "NULL") + ]) + } + + /// A catalog that refuses leaves `SHOW FULL COLUMNS` as the only source, which still states every + /// literal and `DEFAULT NULL` right. Its expressions read as strings; the Mac app settles those from + /// `SHOW CREATE TABLE`, which this does not read. + @Test("A MariaDB whose catalog refuses still reads its literals and DEFAULT NULL") + func mariaDBWithoutTheCatalogReadsLiterals() { + let columns = MySQLColumnListing.columns( + fromShowFullColumns: Self.mariaDBShowFullColumns, catalogDefaults: [:], source: .quotedCatalog + ) + expectDefaults(columns, [ + ("id", nil), + ("nullable_default_null", "NULL"), + ("not_null_no_default", nil), + ("s_abc", "'abc'"), + ("s_empty", "''"), + ("s_null_text", "'NULL'"), + ("s_quote", "'it''s'"), + ("n_int", "5"), + ("ts", "current_timestamp(3)"), + ("bin", "x'6162'"), + ("g", nil), + ("txt", "NULL") + ]) + } + + @Test("Each server reads its defaults where the Mac app reads them") + func defaultSourceFollowsTheServer() { + let cases: [(flavor: MySQLServerFlavor, banner: String, source: MySQLColumnListing.DefaultSource)] = [ + (.mysql, "8.4.11", .showFullColumns), + (.mariadb, "13.0.2-MariaDB", .quotedCatalog), + (.mariadb, "10.2.7-MariaDB", .quotedCatalog), + (.mariadb, "10.2.6-MariaDB", .showFullColumns), + (.tidb(version: nil), "8.0.11-TiDB-v7.5.0", .showFullColumns), + (.oceanbase(version: nil), "5.7.25", .oceanBaseCatalog), + (.databend, "8.0.90-v1.2.3-nightly", .asReported) + ] + for testCase in cases { + #expect( + MySQLColumnListing.defaultSource(flavor: testCase.flavor, banner: testCase.banner) == testCase.source, + "\(testCase.banner)" + ) + } + } + + @Test("A MySQL connection that reaches a MariaDB reads the quoted catalog too") + func mysqlConnectionToMariaDB() { + let flavor = MySQLDriver.serverFlavor(for: .mysql, banner: "13.0.2-MariaDB") + #expect(MySQLColumnListing.defaultSource(flavor: flavor, banner: "13.0.2-MariaDB") == .quotedCatalog) + } + + @Test("The catalog read names the session's database and escapes the table name") + func catalogQueryReadsTheSessionDatabase() throws { + let query = try #require(MySQLColumnListing.catalogDefaultsQuery(table: #"o'b\x"#, source: .quotedCatalog)) + #expect(query.contains("TABLE_SCHEMA = DATABASE()")) + #expect(query.contains(#"TABLE_NAME = 'o''b\\x'"#)) + #expect(MySQLColumnListing.catalogDefaultsQuery(table: "t", source: .oceanBaseCatalog) != nil) + #expect(MySQLColumnListing.catalogDefaultsQuery(table: "t", source: .asReported) == nil) + } + + /// OceanBase's catalog drops the precision `CURRENT_TIMESTAMP(3)` was declared with, reports a + /// binary default as the text it holds, and answers a view right where `SHOW FULL COLUMNS` gives + /// the text `NULL` for every default. The shapes are OceanBase CE 4.4.2.1's. + @Test("OceanBase defaults read from its catalog with the Mac app's OceanBase rules") + func oceanBaseDefaults() { + let show: [[String?]] = [ + ["ts", "timestamp(3)", nil, "YES", "", "CURRENT_TIMESTAMP", "", Self.privileges, ""], + ["bin", "varbinary(4)", nil, "YES", "", "ab", "", Self.privileges, ""], + ["s", "varchar(10)", "utf8mb4_general_ci", "YES", "", "abc", "", Self.privileges, ""], + ["v", "varchar(10)", "utf8mb4_general_ci", "YES", "", "NULL", "", Self.privileges, ""] + ] + let catalogRows: [[String?]] = [["ts", "CURRENT_TIMESTAMP"], ["bin", "ab"], ["s", "abc"], ["v", nil]] + let source = MySQLColumnListing.defaultSource(flavor: .oceanbase(version: nil), banner: "5.7.25") + let catalog = MySQLColumnListing.catalogDefaults(fromRows: catalogRows, source: source) + let columns = MySQLColumnListing.columns(fromShowFullColumns: show, catalogDefaults: catalog, source: source) + expectDefaults(columns, [ + ("ts", "CURRENT_TIMESTAMP(3)"), + ("bin", "'ab'"), + ("s", "'abc'"), + ("v", "NULL") + ]) + } + + @Test("A Databend default is shown as the server states it") + func databendDefaultsAreUnchanged() { + let show: [[String?]] = [["a", "VARCHAR", nil, "YES", "", "'x'", "", "", ""]] + let columns = MySQLColumnListing.columns(fromShowFullColumns: show, catalogDefaults: [:], source: .asReported) + #expect(columns.first?.defaultValue == "'x'") + } + + @Test("The generated and auto-increment flags still come from Extra") + func extraFlagsSurvive() { + let columns = MySQLColumnListing.columns( + fromShowFullColumns: Self.mysqlShowFullColumns, catalogDefaults: [:], source: .showFullColumns + ) + let byName = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0) }) + #expect(byName["id"]?.isAutoIncrement == true) + #expect(byName["id"]?.isPrimaryKey == true) + #expect(byName["g"]?.isGenerated == true) + #expect(byName["e_uuid"]?.isGenerated == false) + #expect(byName["not_null_no_default"]?.isNullable == false) + #expect(columns.map(\.ordinalPosition) == Array(0.. String? { + mysqlShowColumnsDefault( + "uuid()", catalog: catalog, createTable: createTable, + column: "e", extra: "", dataType: "VARCHAR(36)", isNullable: true + ) + } + #expect(read(.quoted("'uuid()'"), createTable) == "'uuid()'") + #expect(read(nil, createTable) == "uuid()") + #expect(read(nil, nil) == "'uuid()'") + #expect(read(.quoted("NULL"), nil) == "NULL") + } + /// The catalog's escaping is undone exactly, its double encoding of non-ASCII text is not. @Test( "Only a MySQL expression default holding non-ASCII text needs SHOW CREATE TABLE", diff --git a/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift b/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift index 3415dccf92..8dff055738 100644 --- a/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift +++ b/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift @@ -192,6 +192,20 @@ struct OceanBaseColumnDefaultsTests { #expect(OceanBaseColumnDefaults.binaryLiteralDefault("A", dataType: "VARCHAR(4)") == nil) } + @Test("A catalog default with nothing to resolve against reads by OceanBase's rules, then MySQL's") + func catalogDefaultWithoutCreateTable() { + func read(_ value: String?, _ dataType: String, isNullable: Bool = true, extra: String = "") -> String? { + OceanBaseColumnDefaults.columnDefault(value, extra: extra, dataType: dataType, isNullable: isNullable) + } + #expect(read("CURRENT_TIMESTAMP", "TIMESTAMP(3)") == "CURRENT_TIMESTAMP(3)") + #expect(read("ab", "VARBINARY(4)") == "'ab'") + #expect(read("abc", "VARCHAR(10)") == "'abc'") + #expect(read("5", "INT(11)") == "5") + #expect(read(nil, "VARCHAR(10)") == "NULL") + #expect(read(nil, "VARCHAR(10)", isNullable: false) == nil) + #expect(read(nil, "INT(11)", extra: "auto_increment") == nil) + } + @Test("A literal that does not close where the clause ends is not a literal", arguments: [ #"'abc\'"#, "'(ab", "'a'b'", "'" ]) diff --git a/project.yml b/project.yml index fb9b340639..afee24ce8d 100644 --- a/project.yml +++ b/project.yml @@ -514,6 +514,7 @@ targets: - Plugins/MySQLDriverPlugin/MySQLConnectionEncoding.swift - Plugins/MySQLDriverPlugin/MySQLLatin1.swift - Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift + - Plugins/MySQLDriverPlugin/MySQLLiteralSQL.swift - Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift - Plugins/MySQLDriverPlugin/MySQLLiteralSpelling.swift - Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift