From 9f1c9ad4ca78e82261791fdb4140f8b460c40ae3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 23 Sep 2026 12:30:40 +0700 Subject: [PATCH] fix(plugin-mysql): read a nullable column's DEFAULT NULL back as NULL on MySQL and MariaDB --- CHANGELOG.md | 5 + .../MySQLCatalogDefault.swift | 171 ++++++++++ .../MySQLColumnDefinitionSQL.swift | 52 +-- .../MySQLCreateTableScanner.swift | 77 +++++ .../MySQLPluginDriver+OceanBaseDefaults.swift | 14 +- .../MySQLPluginDriver+Schema.swift | 176 ++++++++-- .../MySQLServerVersion.swift | 13 +- .../OceanBaseColumnDefaults.swift | 56 ---- .../Compare/StructureDiffEngine+Members.swift | 10 +- .../CrossEngine/CrossEngineDefaultValue.swift | 2 +- .../StructureChangeManager.swift | 13 + TablePro/Models/Query/TableRows.swift | 7 +- .../Models/Schema/ColumnDefaultLiteral.swift | 16 + TablePro/Models/Schema/ColumnDefinition.swift | 12 + .../Schema/CreateTableDraftBuilder.swift | 8 +- TablePro/Resources/Localizable.xcstrings | 6 + .../Structure/StructureEditingSupport.swift | 4 +- .../Compare/StructureDiffEngineTests.swift | 18 ++ .../CrossEngineStructureTranslatorTests.swift | 10 + .../Services/RowOperationsManagerTests.swift | 16 +- .../Schema/ColumnDefaultRoundTripTests.swift | 10 +- .../Schema/CreateTableDraftBuilderTests.swift | 21 +- .../MySQLColumnDefinitionSQLTests.swift | 304 ++++++++++++++++-- .../OceanBaseColumnDefaultsTests.swift | 24 +- .../Structure/StructureNullDefaultTests.swift | 157 +++++++++ docs/features/table-structure.mdx | 4 + project.yml | 1 + scripts/check-mysql-column-defaults.sh | 186 +++++++++++ 28 files changed, 1222 insertions(+), 171 deletions(-) create mode 100644 Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift create mode 100644 TablePro/Models/Schema/ColumnDefaultLiteral.swift create mode 100644 TableProTests/Views/Structure/StructureNullDefaultTests.swift create mode 100755 scripts/check-mysql-column-defaults.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index a503b7b117..48c72b143a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Unexpanded schemas hidden by the sidebar filter in the Tree layout. - Empty object sections opened as "No items" under every match while filtering the sidebar tree. - **Drop View** offered in Recent for a sequence or materialized view opened from Open Quickly. +- Column default of NULL shown as Empty on MySQL and MariaDB, and a NULL default that never stuck. (#3058) +- String column defaults misread on MariaDB 10.2.7 and later, and expression defaults on MariaDB 10.2.1 to 10.2.6. +- `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)`. - 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/MySQLCatalogDefault.swift b/Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift new file mode 100644 index 0000000000..13ffe116e8 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift @@ -0,0 +1,171 @@ +// +// MySQLCatalogDefault.swift +// MySQLDriverPlugin +// +// A column default as the catalog reports it, turned into the SQL that recreates it. +// + +import Foundation +import TableProPluginKit + +/// A catalog default, tagged with the form the read that produced it uses. +/// +/// 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 { + /// `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. + case bare(String?) + + /// `INFORMATION_SCHEMA.COLUMNS` on MariaDB from 10.2.7. A literal arrives quoted, an expression + /// bare, `DEFAULT NULL` as the unquoted text `NULL`, and SQL NULL only for no default. MariaDB's + /// `SHOW FULL COLUMNS` does not follow it: measured on 12.3, it reports `'abc'` as `abc` and + /// `uuid()` as `uuid()` with an empty `EXTRA`. + case quoted(String?) + + var value: String? { + switch self { + case .bare(let value), .quoted(let value): value + } + } +} + +/// The column defaults `SHOW CREATE TABLE` states, and which columns a column read takes them for. +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. + case everyColumn + /// MySQL: only the expression defaults its catalog cannot report exactly. Every other default + /// reads better from the catalog, where a number stays `5` rather than the `'5'` this prints. + case expressionDefaults + } + + let clauses: [String: String] + let scope: Scope + + func catalogDefault(forColumn name: String, extra: String?) -> MySQLCatalogDefault? { + switch scope { + case .everyColumn: + return .quoted(clauses[name]) + case .expressionDefaults: + guard mysqlIsExpressionDefault(extra: extra), let clause = clauses[name] else { return nil } + return .quoted(clause) + } + } +} + +/// The SQL that follows `DEFAULT` for a column, or nil when the column has no default. +/// +/// A nullable column the catalog gives SQL NULL has `DEFAULT NULL`. MySQL writes that clause itself +/// for a nullable column declared without one, and MariaDB keeps it through `DROP DEFAULT`. The one +/// state this cannot see is a MySQL nullable column whose default was removed with `ALTER COLUMN … +/// DROP DEFAULT`: the catalog reports it the same way, and on `TEXT` so does `SHOW CREATE TABLE`, yet +/// an `INSERT` that omits it fails with `ERROR 1364`. It reads as `NULL` here and everywhere this +/// value goes, schema compare and exported DDL included. Nothing this app writes creates that state, +/// because a `MODIFY` with no `DEFAULT` clause on a nullable column is `DEFAULT NULL` again. +/// +/// 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( + _ catalog: MySQLCatalogDefault, + extra: String?, + dataType: String, + isNullable: Bool +) -> String? { + guard !mysqlColumnIsGenerated(extra: extra), mysqlIdentityKind(extra: extra) == nil else { return nil } + guard let value = catalog.value else { return isNullable ? "NULL" : nil } + guard case .bare = catalog else { return value } + return mysqlBareCatalogDefault(value, extra: extra, dataType: dataType) +} + +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. + if mysqlTemporalType(dataType), mysqlCurrentTimestampExpression(value, dataType: dataType) != nil { + return value + } + if mysqlIsExpressionDefault(extra: extra) { + let expression = mysqlUnescapedCatalogExpression(value) + return expression.hasPrefix("(") ? expression : "(\(expression))" + } + return mysqlCatalogReportsLiteralAsSQL(dataType: dataType) + ? value : "'\(mysqlEscapeStringLiteral(value))'" +} + +/// MySQL's marker for an expression default, in `EXTRA` of both catalog reads. +internal func mysqlIsExpressionDefault(extra: String?) -> Bool { + extra?.uppercased().contains("DEFAULT_GENERATED") == true +} + +/// Whether a MySQL expression default can only be recreated from `SHOW CREATE TABLE`. +/// +/// The catalog keeps an expression default escaped, which `mysqlUnescapedCatalogExpression` undoes +/// 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 { + guard mysqlIsExpressionDefault(extra: extra), let value else { return false } + return !value.unicodeScalars.allSatisfy(\.isASCII) +} + +/// Whether a default a MariaDB reports bare may be an expression rather than the string it reads +/// 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 { + guard let value, value.contains("(") else { return false } + return !(mysqlTemporalType(dataType) && mysqlCurrentTimestampExpression(value, dataType: dataType) != nil) +} + +/// An expression default as `SHOW CREATE TABLE` prints it, from the catalog's escaped copy, for a +/// read that has no `SHOW CREATE TABLE` to take it from. +/// +/// MySQL stores an expression default with every quote and backslash escaped by a backslash. +/// Measured on 8.4.11, `DEFAULT (concat('a','b'))` comes back as `concat(_utf8mb4\'a\',_utf8mb4\'b\')`, +/// which no statement accepts, so every later edit of the column failed. Undoing it is a pairwise +/// scan in which a backslash takes the next character literally. The escaped form never holds a +/// bare quote, so meeting one means the text was never escaped, and it is returned as it came. +/// +/// 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 { + guard value.unicodeScalars.allSatisfy(\.isASCII) else { return value } + var result = "" + var index = value.startIndex + while index < value.endIndex { + let character = value[index] + if character == "'" { return value } + let next = value.index(after: index) + guard character == "\\" else { + result.append(character) + index = next + continue + } + guard next < value.endIndex else { return value } + result.append(value[next]) + index = value.index(after: next) + } + return result +} + +/// Whether this column type's catalog default is already the SQL that recreates it. +/// +/// 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 { + let base = dataType.uppercased().split(separator: "(", maxSplits: 1).first.map(String.init)? + .trimmingCharacters(in: .whitespaces) ?? dataType.uppercased() + switch base { + case "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT", + "DECIMAL", "DEC", "NUMERIC", "FIXED", "FLOAT", "DOUBLE", "REAL", "YEAR", + "BIT", "BINARY", "VARBINARY", "BOOL", "BOOLEAN": + return true + default: + return false + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift index ed0adf664a..e1eb593f9d 100644 --- a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift +++ b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift @@ -100,11 +100,14 @@ internal func mysqlWholeStringLiteral(_ value: String) -> String? { /// column's own fractional-second precision, because MySQL rejects the pair when they differ, and /// is the one expression MySQL accepts bare. Every other expression is parenthesised on MySQL, /// which is what its grammar requires from 8.0.13; MariaDB takes them either way and writes them -/// bare itself. A type that cannot carry a bare default is parenthesised whatever the value is. +/// bare itself. A type that cannot carry a bare default is parenthesised whatever the value is, with +/// one exception: `NULL`. Every type takes it bare, and parenthesised it is no longer the literal: +/// MySQL 8 stores `DEFAULT (NULL)` as an expression default and MySQL 5.7 refuses the syntax. /// /// Nothing else is rewritten: the value already holds the SQL, and re-quoting it is what turned /// `(UUID())` into the six-character string `uuid()`. internal func mysqlDefaultValueLiteral(_ value: String, dataType: String, isMariaDB: Bool) -> String { + if mysqlIsNullLiteral(value) { return "NULL" } if mysqlTemporalType(dataType), let expression = mysqlCurrentTimestampExpression(value, dataType: dataType) { return expression } @@ -114,51 +117,8 @@ internal func mysqlDefaultValueLiteral(_ value: String, dataType: String, isMari return value.hasPrefix("(") ? value : "(\(value))" } -/// A column default as the catalog reports it, turned into the SQL that recreates it. -/// -/// The two servers report it differently and neither says which it is in the value alone. MySQL -/// leaves a literal bare and marks an expression `DEFAULT_GENERATED` in `EXTRA`. MariaDB from 10.2.7 -/// quotes literals and leaves expressions bare, with `EXTRA` empty; before that it quotes nothing, -/// so it reads like MySQL without the marker and every default is a literal. -internal func mysqlDefaultValueFromCatalog( - _ value: String?, - extra: String?, - dataType: String, - quotesLiterals: Bool -) -> String? { - guard let value else { return nil } - if quotesLiterals { return value } - - // 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. - if mysqlTemporalType(dataType), mysqlCurrentTimestampExpression(value, dataType: dataType) != nil { - return value - } - - guard extra?.uppercased().contains("DEFAULT_GENERATED") != true else { - return value.hasPrefix("(") ? value : "(\(value))" - } - return mysqlCatalogReportsLiteralAsSQL(dataType: dataType) - ? value : "'\(mysqlEscapeStringLiteral(value))'" -} - -/// Whether this column type's catalog default is already the SQL that recreates it. -/// -/// 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 { - let base = dataType.uppercased().split(separator: "(", maxSplits: 1).first.map(String.init)? - .trimmingCharacters(in: .whitespaces) ?? dataType.uppercased() - switch base { - case "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT", - "DECIMAL", "DEC", "NUMERIC", "FIXED", "FLOAT", "DOUBLE", "REAL", "YEAR", - "BIT", "BINARY", "VARBINARY", "BOOL", "BOOLEAN": - return true - default: - return false - } +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 diff --git a/Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift b/Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift index 985965a354..9b4863a5d4 100644 --- a/Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift +++ b/Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift @@ -6,6 +6,35 @@ import Foundation internal enum MySQLCreateTableScanner { + /// Each column's `DEFAULT` operand as `SHOW CREATE TABLE` spells it, keyed by column name, or nil + /// when the statement does not create a table. A column with no `DEFAULT` clause is absent. + /// + /// Read one definition per line, because OceanBase prints a `SET` or `ENUM` member list with its + /// quotes unbalanced (`set('a','b'c')`), and a quote-aware split would run that column into the + /// next. A quoted column name is the one thing every server quotes correctly, and it may hold a + /// line break, so a line that opens a name without closing it runs on into the next: read on its + /// own, the rest of that name would reach another column as its default. + static func columnDefaultClauses(fromCreateTable sql: String) -> [String: String]? { + let lines = sql.split(separator: "\n", omittingEmptySubsequences: false) + guard let header = lines.first, declaresTable(header) else { return nil } + var clauses: [String: String] = [:] + var first = lines.index(after: lines.startIndex) + while first < lines.endIndex { + var last = first + while last + 1 < lines.endIndex, + opensUnclosedName(sql[lines[first].startIndex.. Substring? { var depth = 0 var start: Substring.Index? @@ -163,4 +192,52 @@ internal enum MySQLCreateTableScanner { } return marks } + + private static func declaresTable(_ header: Substring) -> Bool { + let words = header.prefix { $0 != "`" && $0 != "\"" && $0 != "(" } + .split(whereSeparator: \.isWhitespace) + .map { $0.uppercased() } + guard words.first == "CREATE", let tableIndex = words.firstIndex(of: "TABLE") else { return false } + return !words[.. String? { + if let quote = definition.first, quote == "`" || quote == "\"" { + return consumeQuotedName(from: &definition, quote: quote) + } + guard let first = definition.first, first.isLetter || first.isNumber || first == "_" || first == "$" else { + return nil + } + let name = definition.prefix { !$0.isWhitespace } + definition = definition.dropFirst(name.count) + return String(name) + } + + private static func opensUnclosedName(_ text: Substring) -> Bool { + var definition = text.drop(while: \.isWhitespace) + guard let quote = definition.first, quote == "`" || quote == "\"" else { return false } + return consumeQuotedName(from: &definition, quote: quote) == nil + } + + private static func withoutTrailingSeparator(_ definition: Substring) -> Substring { + var trimmed = definition + while let last = trimmed.last, last.isWhitespace { + trimmed = trimmed.dropLast() + } + return trimmed.last == "," ? trimmed.dropLast() : trimmed + } + + private static func defaultOperand(in definition: Substring) -> String? { + let tokens = topLevelTokens(of: definition) + for (index, token) in tokens.enumerated() { + let upper = token.uppercased() + if upper == "DEFAULT" { + return tokens.indices.contains(index + 1) ? String(tokens[index + 1]) : nil + } + if upper.hasPrefix("DEFAULT("), token.count > "DEFAULT".count { + return String(token.dropFirst("DEFAULT".count)) + } + } + return nil + } } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift index 3a3b519215..7c825983df 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift @@ -37,8 +37,10 @@ internal extension MySQLPluginDriver { catalogDefault: String?, extra: String?, dataType: String, + isNullable: Bool, column: String, - createTableClauses: [String: String]? + createTableClauses: [String: String]?, + createTableDefaults: MySQLCreateTableDefaults? ) -> String? { if flavor.isOceanBase, let catalogDefault { if let currentTimestamp = OceanBaseColumnDefaults.currentTimestampDefault(catalogDefault, dataType: dataType) { @@ -60,8 +62,12 @@ internal extension MySQLPluginDriver { return binaryLiteral } } - return mysqlDefaultValueFromCatalog( - catalogDefault, extra: extra, dataType: dataType, quotesLiterals: catalogQuotesDefaults + return mysqlColumnDefault( + createTableDefaults?.catalogDefault(forColumn: column, extra: extra) + ?? (catalogQuotesDefaults ? .quoted(catalogDefault) : .bare(catalogDefault)), + extra: extra, + dataType: dataType, + isNullable: isNullable ) } @@ -79,6 +85,6 @@ internal extension MySQLPluginDriver { private func oceanbaseDefaultClauses(table: String, schema: String?) async throws -> [String: String] { let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table, schema: schema))") guard let createTable = result.rows.first?[safe: 1]?.asText else { return [:] } - return OceanBaseColumnDefaults.defaultClauses(fromCreateTable: createTable) ?? [:] + return MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: createTable) ?? [:] } } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index 0a20b7f9e0..6e18c02981 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -50,7 +50,10 @@ internal extension MySQLPluginDriver { return columnsByTable[table] ?? (columnsByTable.count == 1 ? columnsByTable.values.first ?? [] : []) } let result = try await execute(query: "SHOW FULL COLUMNS FROM \(qualifiedName(table, schema: schema))") - let generationExpressions = try await fetchGenerationExpressions(table: table, schema: schema) + let catalogDetails = try await fetchCatalogColumnDetails(table: table, schema: schema) + let createTableDefaults = try await createTableDefaults( + table: table, schema: schema, showRows: result.rows, catalogDetails: catalogDetails + ) return result.rows.compactMap { row in guard let name = row[safe: 0]?.asText, @@ -73,8 +76,14 @@ internal extension MySQLPluginDriver { let normalizedType = (upperType.hasPrefix("ENUM(") || upperType.hasPrefix("SET(")) ? dataType : upperType let allowedValues = EnumValueParser.parseMySQLEnumOrSet(from: normalizedType) - let defaultValue = mysqlDefaultValueFromCatalog( - rawDefault, extra: extra, dataType: normalizedType, quotesLiterals: catalogQuotesDefaults + let detail = catalogDetails[name] + let defaultValue = mysqlColumnDefault( + detail?.catalogDefault + ?? createTableDefaults?.catalogDefault(forColumn: name, extra: extra) + ?? .bare(rawDefault), + extra: extra, + dataType: normalizedType, + isNullable: isNullable ) return PluginColumnInfo( @@ -90,15 +99,25 @@ internal extension MySQLPluginDriver { identityKind: mysqlIdentityKind(extra: extra), isGenerated: mysqlColumnIsGenerated(extra: extra), allowedValues: allowedValues, - generationExpression: generationExpressions[name], + generationExpression: detail?.generationExpression, generationKind: mysqlGenerationKind(extra: extra) ) } } + /// What `INFORMATION_SCHEMA.COLUMNS` says about a table's columns that `SHOW FULL COLUMNS` cannot: + /// a generated column's expression, and on MariaDB from 10.2.7 the default in its quoted form. + /// MariaDB's `SHOW FULL COLUMNS` reports `'abc'` as `abc` and an expression with no marker, so + /// only this read tells a string from an expression there. + /// + /// `SHOW FULL COLUMNS` stays the primary read rather than giving way to this one: a MySQL-protocol + /// proxy answers `SHOW` truthfully while answering the catalog with nothing or an error (see + /// `MySQLPluginDriver+CatalogFallback.swift`), and a column list read from the catalog alone would + /// come back empty there. + /// /// Merged into the `SHOW FULL COLUMNS` rows by column name alone, so this has to read the same - /// database that statement did. Reading the session's instead grafts one table's generation - /// expressions onto another's columns wherever the two databases share a column name. + /// database that statement did. Reading the session's instead grafts one table's details onto + /// another's columns wherever the two databases share a column name. /// /// Skipped entirely where the catalog is known not to describe the database: it would answer /// nothing there, and the degraded whole-schema read runs this once per table. @@ -106,38 +125,141 @@ internal extension MySQLPluginDriver { /// A catalog that refuses is not this read's to report either. One refused read does not mark a /// database blind, so a proxy that answers `ERROR 1064` rather than answering nothing still /// reaches this statement, and a throw here would take the whole `SHOW FULL COLUMNS` answer with - /// it. It degrades to no expressions instead, which is what the blind path returns anyway. - private func fetchGenerationExpressions(table: String, schema: String?) async throws -> [String: String] { + /// it. It degrades to no details instead, which is what the blind path returns anyway, and every + /// default then reads from `SHOW FULL COLUMNS` in its bare form. + private func fetchCatalogColumnDetails( + table: String, + schema: String? + ) async throws -> [String: MySQLCatalogColumnDetail] { guard catalogVisibility.visibility(of: effectiveSchema(schema)) != .blind else { return [:] } let identity = serverIdentity - guard MySQLServerVersion.hasGenerationExpression(banner: identity.banner, flavor: identity.flavor) else { - return [:] - } + let readsGeneration = MySQLServerVersion.hasGenerationExpression( + banner: identity.banner, flavor: identity.flavor + ) + let readsDefault = MySQLServerVersion.quotesColumnDefault(banner: identity.banner, flavor: identity.flavor) + guard readsGeneration || readsDefault else { return [:] } + let generationProjection = readsGeneration ? "GENERATION_EXPRESSION" : "NULL" + let defaultProjection = readsDefault ? "COLUMN_DEFAULT" : "NULL" + let generatedOnly = readsDefault ? "" : " AND GENERATION_EXPRESSION <> \'\'" let query = """ - SELECT COLUMN_NAME, GENERATION_EXPRESSION + SELECT COLUMN_NAME, \(generationProjection), \(defaultProjection) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = \'\(effectiveSchemaLiteral(schema))\' - AND TABLE_NAME = \'\(mysqlEscapeStringLiteral(table))\' - AND GENERATION_EXPRESSION <> \'\' + AND TABLE_NAME = \'\(mysqlEscapeStringLiteral(table))\'\(generatedOnly) """ do { let result = try await execute(query: query) - var expressions: [String: String] = [:] + var details: [String: MySQLCatalogColumnDetail] = [:] for row in result.rows { - guard let name = row[safe: 0]?.asText, - let expression = row[safe: 1]?.asText?.nilIfEmpty else { continue } - expressions[name] = expression + guard let name = row[safe: 0]?.asText else { continue } + details[name] = MySQLCatalogColumnDetail( + generationExpression: row[safe: 1]?.asText?.nilIfEmpty, + catalogDefault: readsDefault ? .quoted(row[safe: 2]?.asText) : nil + ) } - return expressions + return details } catch let error as MariaDBPluginError where MySQLCatalogVisibilityRule.settlesBlindness(code: error.code) { Self.logger.warning( - "generation expression read refused code=\(error.code, privacy: .public) message=\(error.message)" + "column catalog read refused code=\(error.code, privacy: .public) message=\(error.message)" ) return [:] } } + /// The defaults `SHOW CREATE TABLE` states, for the columns whose catalog answer cannot recreate + /// them, or nil when no column needs it. + /// + /// Two cases need it. A MariaDB whose catalog did not answer in its quoted form, a server before + /// 10.2.7 or one whose catalog is blind or refused: its `SHOW FULL COLUMNS` reports the expression + /// `uuid()` and the string `'uuid()'` alike, so the bare form turns an expression into a constant + /// the next time the column is written. And a MySQL expression default holding non-ASCII text, + /// which the catalog keeps encoded twice. + /// `SHOW CREATE TABLE` spells both exactly, and a proxy answers it as truthfully as it answers + /// `SHOW FULL COLUMNS`. + private func createTableDefaults( + table: String, + schema: String?, + showRows: [[PluginCellValue]], + catalogDetails: [String: MySQLCatalogColumnDetail] + ) async throws -> MySQLCreateTableDefaults? { + guard let scope = createTableDefaultsScope(showRows: showRows, catalogDetails: catalogDetails), + let clauses = try await createTableDefaultClauses(table: table, schema: schema) + else { return nil } + return MySQLCreateTableDefaults(clauses: clauses, scope: scope) + } + + /// The whole-schema read's counterpart of `createTableDefaults`: one `SHOW CREATE TABLE` for each + /// table holding a default its catalog row cannot recreate, and none for the rest. The rows are + /// `INFORMATION_SCHEMA.COLUMNS` rows, which on a MariaDB from 10.2.7 are already exact. + private func createTableDefaultsByTable( + forCatalogRows rows: [[PluginCellValue]], + schema: String? + ) async throws -> [String: MySQLCreateTableDefaults] { + let identity = serverIdentity + guard !identity.flavor.isOceanBase else { return [:] } + let scope: MySQLCreateTableDefaults.Scope + let needsCreateTable: ([PluginCellValue]) -> Bool + if identity.flavor.isMariaDB { + guard MySQLServerVersion.mariaDBDefaultsCanBeExpressions(banner: identity.banner, flavor: identity.flavor), + !MySQLServerVersion.quotesColumnDefault(banner: identity.banner, flavor: identity.flavor) + else { return [:] } + scope = .everyColumn + needsCreateTable = { row in + mariaDBBareDefaultMayBeExpression(row[safe: 6]?.asText, dataType: row[safe: 2]?.asText ?? "") + } + } else { + scope = .expressionDefaults + needsCreateTable = { row in + mysqlExpressionDefaultNeedsCreateTable( + row[safe: 6]?.asText, extra: row[safe: 7]?.asText, dataType: row[safe: 2]?.asText ?? "" + ) + } + } + let tables = Set(rows.filter(needsCreateTable).compactMap { $0[safe: 0]?.asText }) + var defaults: [String: MySQLCreateTableDefaults] = [:] + for table in tables.sorted() { + guard let clauses = try await createTableDefaultClauses(table: table, schema: schema) else { continue } + defaults[table] = MySQLCreateTableDefaults(clauses: clauses, scope: scope) + } + return defaults + } + + /// Nil when the statement is refused, or does not describe a table: a view's leaves its columns on + /// the catalog's answer. + private func createTableDefaultClauses(table: String, schema: String?) async throws -> [String: String]? { + do { + let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table, schema: schema))") + guard let createTable = result.rows.first?[safe: 1]?.asText else { return nil } + return MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: createTable) + } catch let error as MariaDBPluginError + where MySQLCatalogVisibilityRule.settlesBlindness(code: error.code) { + Self.logger.warning( + "create table default read refused code=\(error.code, privacy: .public) message=\(error.message)" + ) + return nil + } + } + + private func createTableDefaultsScope( + showRows: [[PluginCellValue]], + catalogDetails: [String: MySQLCatalogColumnDetail] + ) -> MySQLCreateTableDefaults.Scope? { + let identity = serverIdentity + guard identity.flavor.isMariaDB else { + let needsExpressions = showRows.contains { row in + mysqlExpressionDefaultNeedsCreateTable( + row[safe: 5]?.asText, extra: row[safe: 6]?.asText, dataType: row[safe: 1]?.asText ?? "" + ) + } + return needsExpressions ? .expressionDefaults : nil + } + guard MySQLServerVersion.mariaDBDefaultsCanBeExpressions(banner: identity.banner, flavor: identity.flavor), + !catalogDetails.values.contains(where: { $0.catalogDefault != nil }) + else { return nil } + return .everyColumn + } + /// MySQL and MariaDB disagree on this catalog: MySQL 8 has no TABLE_NAME on CHECK_CONSTRAINTS /// and must join TABLE_CONSTRAINTS to find the owning table, while MariaDB carries TABLE_NAME /// directly. Neither exposes the columns a check touches, so `columns` stays empty rather than @@ -225,6 +347,9 @@ internal extension MySQLPluginDriver { let createTableClausesByTable = try await oceanbaseDefaultClausesByTable( forRows: result.rows, tableColumn: 0, typeColumn: 2, defaultColumn: 6, schema: schema ) + let createTableDefaultsByTable = try await createTableDefaultsByTable( + forCatalogRows: result.rows, schema: schema + ) var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { @@ -253,8 +378,10 @@ internal extension MySQLPluginDriver { catalogDefault: rawDefault, extra: extra, dataType: normalizedType, + isNullable: isNullable, column: name, - createTableClauses: createTableClausesByTable[tableName] + createTableClauses: createTableClausesByTable[tableName], + createTableDefaults: createTableDefaultsByTable[tableName] ) let column = PluginColumnInfo( @@ -280,3 +407,10 @@ internal extension MySQLPluginDriver { return allColumns } } + +/// What the catalog adds to one `SHOW FULL COLUMNS` row. `catalogDefault` is nil when the read did +/// not project a default, which is every server whose catalog does not quote its literals. +internal struct MySQLCatalogColumnDetail: Equatable, Sendable { + let generationExpression: String? + let catalogDefault: MySQLCatalogDefault? +} diff --git a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift index 9951722f59..fbd55bb88b 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift @@ -108,12 +108,23 @@ enum MySQLServerVersion { return isAtLeast((8, 0, 0), banner: banner) ? "NO ACTION" : "RESTRICT" } - /// Whether a literal default comes back from the catalog already quoted. + /// Whether a literal default comes back from `INFORMATION_SCHEMA.COLUMNS` already quoted. /// /// MariaDB began quoting `COLUMN_DEFAULT` in 10.2.7, alongside expression defaults. Before that, /// and on every MySQL, a literal arrives bare and is indistinguishable from an expression by its /// text alone. MySQL never quotes, and marks an expression `DEFAULT_GENERATED` in `EXTRA` instead. + /// + /// It describes that catalog table and nothing else. MariaDB's `SHOW FULL COLUMNS` kept the old + /// bare form, so a `SHOW` answer is never read this way whatever the server version. static func quotesColumnDefault(banner: String?, flavor: MySQLServerFlavor) -> Bool { flavor.isMariaDB && isAtLeast((10, 2, 7), banner: banner) } + + /// Whether a MariaDB default can be an expression other than `CURRENT_TIMESTAMP`, which MariaDB + /// allows from 10.2.1. From then on its `SHOW FULL COLUMNS` reports `uuid()` and the string + /// `'uuid()'` alike, so the bare form alone cannot recreate a default. An unreadable banner is + /// not an old server. + static func mariaDBDefaultsCanBeExpressions(banner: String?, flavor: MySQLServerFlavor) -> Bool { + flavor.isMariaDB && !isKnownBelow((10, 2, 1), banner: banner) + } } diff --git a/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift b/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift index d97768f703..37c84c8eb2 100644 --- a/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift +++ b/Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift @@ -35,20 +35,6 @@ internal enum OceanBaseColumnDefaults { return "'\(mysqlEscapeStringLiteral(catalogDefault))'" } - static func defaultClauses(fromCreateTable sql: String) -> [String: String]? { - let lines = sql.split(separator: "\n", omittingEmptySubsequences: false) - guard let header = lines.first, declaresTable(header) else { return nil } - var clauses: [String: String] = [:] - for line in lines.dropFirst() { - var definition = line.drop(while: \.isWhitespace) - guard let name = columnName(consumingFrom: &definition), - let operand = defaultOperand(in: withoutTrailingSeparator(definition)) - else { continue } - clauses[name] = operand - } - return clauses - } - static func resolve(clause: String?, catalogDefault: String) -> Resolution { guard let clause, clause.uppercased() != "NULL" else { return .unverified } if clause.hasPrefix("'") { @@ -63,48 +49,6 @@ internal enum OceanBaseColumnDefaults { return .value(clause) } - private static func declaresTable(_ header: Substring) -> Bool { - let words = header.prefix { $0 != "`" && $0 != "\"" && $0 != "(" } - .split(whereSeparator: \.isWhitespace) - .map { $0.uppercased() } - guard words.first == "CREATE", let tableIndex = words.firstIndex(of: "TABLE") else { return false } - return !words[.. String? { - if let quote = definition.first, quote == "`" || quote == "\"" { - return MySQLCreateTableScanner.consumeQuotedName(from: &definition, quote: quote) - } - guard let first = definition.first, first.isLetter || first.isNumber || first == "_" || first == "$" else { - return nil - } - let name = definition.prefix { !$0.isWhitespace } - definition = definition.dropFirst(name.count) - return String(name) - } - - private static func withoutTrailingSeparator(_ definition: Substring) -> Substring { - var trimmed = definition - while let last = trimmed.last, last.isWhitespace { - trimmed = trimmed.dropLast() - } - return trimmed.last == "," ? trimmed.dropLast() : trimmed - } - - private static func defaultOperand(in definition: Substring) -> String? { - let tokens = MySQLCreateTableScanner.topLevelTokens(of: definition) - for (index, token) in tokens.enumerated() { - let upper = token.uppercased() - if upper == "DEFAULT" { - return tokens.indices.contains(index + 1) ? String(tokens[index + 1]) : nil - } - if upper.hasPrefix("DEFAULT("), token.count > "DEFAULT".count { - return String(token.dropFirst("DEFAULT".count)) - } - } - return nil - } - private static func decodedStringLiteral(_ operand: String) -> String? { var characters = Array(operand) guard characters.count >= 2, characters.removeFirst() == "'" else { return nil } diff --git a/TablePro/Core/Compare/StructureDiffEngine+Members.swift b/TablePro/Core/Compare/StructureDiffEngine+Members.swift index 3dc90939d3..3b34d0b3bb 100644 --- a/TablePro/Core/Compare/StructureDiffEngine+Members.swift +++ b/TablePro/Core/Compare/StructureDiffEngine+Members.swift @@ -136,6 +136,14 @@ private extension StructureDiffEngine { .joined(separator: "\u{1F}") } + /// A nullable column behaves the same with a NULL default and with none, and engines report the + /// pair differently: MySQL and MariaDB spell it `DEFAULT NULL`, PostgreSQL stores no default at + /// all. Compared as written, every nullable column of a MySQL table reads as changed against the + /// same table on another engine. + func comparableDefault(_ column: EditableColumnDefinition) -> String? { + column.isNullable && column.hasNullDefault ? nil : column.defaultValue + } + func columnSignature(_ column: EditableColumnDefinition) -> String { var parts: [String] = [ options.matchKey(column.name), @@ -143,7 +151,7 @@ private extension StructureDiffEngine { String(column.isNullable), String(column.autoIncrement), String(column.unsigned), - options.normalizedText(column.defaultValue) ?? "", + options.normalizedText(comparableDefault(column)) ?? "", options.normalizedText(column.onUpdate) ?? "", options.normalizedText(strippedExtra(column.extra)) ?? "", options.normalizedText(column.generationExpression) ?? "", diff --git a/TablePro/Core/CrossEngine/CrossEngineDefaultValue.swift b/TablePro/Core/CrossEngine/CrossEngineDefaultValue.swift index 485feb0506..06d08e694a 100644 --- a/TablePro/Core/CrossEngine/CrossEngineDefaultValue.swift +++ b/TablePro/Core/CrossEngine/CrossEngineDefaultValue.swift @@ -40,7 +40,7 @@ internal enum CrossEngineDefaultValue { let upper = stripped.uppercased() if upper.hasPrefix("NEXTVAL(") { return .autoIncrement } - if upper == "NULL" { return .keep("NULL") } + if upper == "NULL" { return .none } if let now = currentTimestamp(upper, target: target) { return .keep(now) } if let today = currentDate(upper, target: target) { return .keep(today) } if let boolean = booleanLiteral(upper, kind: kind, target: target) { return .keep(boolean) } diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 15d2ff4500..be4755b206 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -404,6 +404,10 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { for column in workingColumns { if !column.isValid { validationErrors[.column(column.id)] = String(localized: "Column must have a name and a data type") + } else if isStaged(.column(column.id)), introducesNullDefaultOnNotNull(column) { + validationErrors[.column(column.id)] = String( + format: String(localized: "%@ does not allow NULL, so its default cannot be NULL"), column.name + ) } } @@ -524,6 +528,15 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { columnNames.contains { $0.compare(name, options: .caseInsensitive) == .orderedSame } } + /// MySQL and MariaDB refuse `NOT NULL DEFAULT NULL` with ERROR 1067. SQLite and DuckDB accept it, + /// so a loaded column can already hold the pair, and an edit that leaves it as it was is not the + /// user's to fix before the save. + private func introducesNullDefaultOnNotNull(_ column: EditableColumnDefinition) -> Bool { + guard !column.isNullable, column.hasNullDefault else { return false } + guard let loaded = currentColumns.first(where: { $0.id == column.id }) else { return true } + return loaded.isNullable || !loaded.hasNullDefault + } + private func isColumnPendingDeletion(_ id: UUID) -> Bool { if case .deleteColumn = pendingChanges[.column(id)] { return true diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index 5d15041eda..67472804cb 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -68,9 +68,14 @@ struct TableRows: Sendable { /// default at all: PostgreSQL reports the generation in `pg_attribute.attidentity` and leaves /// `column_default` null, so asking about the default alone answers "no" for every identity /// column and the new row goes out carrying NULL. + /// + /// A default of NULL is not one of them. The value is already known, so sending it gives the + /// same row, and it keeps working on a MySQL column whose default was dropped, which the catalog + /// reports exactly like `DEFAULT NULL` but which fails an INSERT that leaves it out. func serverAssignsValue(forColumn name: String) -> Bool { if columnIdentity[name] != nil { return true } - return (columnDefaults[name] ?? nil) != nil + guard let defaultValue = columnDefaults[name] ?? nil else { return false } + return !ColumnDefaultLiteral.isNull(defaultValue) } func value(at row: Int, column: Int) -> PluginCellValue { diff --git a/TablePro/Models/Schema/ColumnDefaultLiteral.swift b/TablePro/Models/Schema/ColumnDefaultLiteral.swift new file mode 100644 index 0000000000..b0de9f492c --- /dev/null +++ b/TablePro/Models/Schema/ColumnDefaultLiteral.swift @@ -0,0 +1,16 @@ +// +// ColumnDefaultLiteral.swift +// TablePro +// + +import Foundation + +/// A column default that states NULL outright, as opposed to one the server computes. +/// +/// Its value is already known, so a new row sends NULL rather than leaving the column to the +/// server, and a column that stops accepting NULL cannot keep it as its default. +internal enum ColumnDefaultLiteral { + static func isNull(_ value: String?) -> Bool { + value?.trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("NULL") == .orderedSame + } +} diff --git a/TablePro/Models/Schema/ColumnDefinition.swift b/TablePro/Models/Schema/ColumnDefinition.swift index 7a5b9485f4..0131734bea 100644 --- a/TablePro/Models/Schema/ColumnDefinition.swift +++ b/TablePro/Models/Schema/ColumnDefinition.swift @@ -186,6 +186,18 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable { var isGenerated: Bool { generationExpression?.isEmpty == false } + var hasNullDefault: Bool { ColumnDefaultLiteral.isNull(defaultValue) } + + /// A column that stops accepting NULL cannot keep NULL as its default: MySQL and MariaDB refuse + /// `NOT NULL DEFAULT NULL` with `ERROR 1067`, and elsewhere the pair only moves the failure to + /// the first insert that relies on it. The default goes with the nullability, in one edit, so + /// undo brings both back. + mutating func setNullable(_ isNullable: Bool) { + self.isNullable = isNullable + guard !isNullable, hasNullDefault else { return } + defaultValue = nil + } + /// Check if this definition is valid (not a placeholder) var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && diff --git a/TablePro/Models/Schema/CreateTableDraftBuilder.swift b/TablePro/Models/Schema/CreateTableDraftBuilder.swift index 9e56413f56..c663305445 100644 --- a/TablePro/Models/Schema/CreateTableDraftBuilder.swift +++ b/TablePro/Models/Schema/CreateTableDraftBuilder.swift @@ -134,6 +134,12 @@ enum CreateTableDraftBuilder { )) continue } + if !column.isNullable, column.hasNullDefault { + issues.append(SchemaDraftIssue( + tab: .columns, row: row, + message: String(localized: "This column does not allow NULL, so its default cannot be NULL.") + )) + } var normalized = column normalized.name = name normalized.dataType = type @@ -145,7 +151,7 @@ enum CreateTableDraftBuilder { resolved.contains(where: { $0.autoIncrement }) { for index in resolved.indices where resolved[index].autoIncrement { resolved[index].isPrimaryKey = true - resolved[index].isNullable = false + resolved[index].setNullable(false) } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 07c23c5c64..4a040c2ddf 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -6285,6 +6285,9 @@ } } } + }, + "%@ does not allow NULL, so its default cannot be NULL" : { + }, "%d added" : { "localizations" : { @@ -163025,6 +163028,9 @@ }, "This column carries no SRID and its coordinates are outside the range of longitude and latitude, so the map cannot place them." : { + }, + "This column does not allow NULL, so its default cannot be NULL." : { + }, "This column holds %@, which the map cannot draw." : { diff --git a/TablePro/Views/Structure/StructureEditingSupport.swift b/TablePro/Views/Structure/StructureEditingSupport.swift index bdd3341a91..0b001e020c 100644 --- a/TablePro/Views/Structure/StructureEditingSupport.swift +++ b/TablePro/Views/Structure/StructureEditingSupport.swift @@ -29,13 +29,13 @@ enum StructureEditingSupport { switch orderedFields[index] { case .name: column.name = value case .type: column.dataType = value - case .nullable: column.isNullable = parseBool(value) && !column.isPrimaryKey + case .nullable: column.setNullable(parseBool(value) && !column.isPrimaryKey) case .defaultValue: column.defaultValue = value.isEmpty ? nil : value case .onUpdate: column.onUpdate = parseBool(value) ? EditableColumnDefinition.currentTimestampExpression : nil case .primaryKey: column.isPrimaryKey = parseBool(value) - if column.isPrimaryKey { column.isNullable = false } + if column.isPrimaryKey { column.setNullable(false) } case .autoIncrement: column.autoIncrement = parseBool(value) case .comment: column.comment = value.isEmpty ? nil : value case .charset: column.charset = value.isEmpty ? nil : value diff --git a/TableProTests/Core/Compare/StructureDiffEngineTests.swift b/TableProTests/Core/Compare/StructureDiffEngineTests.swift index a2b7f3d65d..edaffc1126 100644 --- a/TableProTests/Core/Compare/StructureDiffEngineTests.swift +++ b/TableProTests/Core/Compare/StructureDiffEngineTests.swift @@ -225,6 +225,24 @@ final class StructureDiffEngineTests: XCTestCase { XCTAssertEqual(result.status, .identical) } + func testNullDefaultOnANullableColumnMatchesNoDefault() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("nickname", defaultValue: "NULL")]), + target: table("users", columns: [column("nickname")]) + ) + + XCTAssertEqual(result.status, .identical) + } + + func testNullDefaultOnANotNullColumnIsStillADifference() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("nickname", nullable: false, defaultValue: "NULL")]), + target: table("users", columns: [column("nickname", nullable: false)]) + ) + + XCTAssertNotEqual(result.status, .identical) + } + func testCollationOnlyDifferenceIsIgnoredByDefaultAndReportedWhenOptionOff() { let source = table("users", columns: [column("name", "varchar(20)", collation: "utf8mb4_general_ci")]) let target = table("users", columns: [column("name", "varchar(20)", collation: "utf8mb4_unicode_ci")]) diff --git a/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift b/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift index 93eef27ccf..5bc926f9c8 100644 --- a/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift +++ b/TableProTests/Core/CrossEngine/CrossEngineStructureTranslatorTests.swift @@ -220,6 +220,16 @@ final class CrossEngineStructureTranslatorTests: XCTestCase { } } + /// A MySQL or MariaDB nullable column reads back with a NULL default. Carried across, SQL Server + /// turns it into a named default constraint on every such column, and a later DROP COLUMN on the + /// target then fails on each one. No default means the same thing on every engine. + func testANullDefaultIsNotCarriedAcross() { + let source = snapshot(columns: [column("nickname", "VARCHAR(50)", defaultValue: "NULL")]) + let result = CrossEngineStructureTranslator.translate(source, from: .mysql, to: .mssql) + XCTAssertNil(result.snapshot.columns[0].defaultValue) + XCTAssertFalse(result.notes.contains { $0.subject == "nickname" }) + } + func testAPostgresCastIsStrippedFromALiteralDefault() { let source = snapshot(columns: [column("state", "character varying(10)", defaultValue: "'new'::character varying")]) let result = CrossEngineStructureTranslator.translate(source, from: .postgresql, to: .mysql) diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index be1327effe..4a053ddece 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -1,6 +1,6 @@ import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @MainActor @@ -141,6 +141,20 @@ struct RowOperationsManagerTests { #expect(result?.values[1] == .null) } + /// A nullable MySQL column reads back with a NULL default (#3058), and one whose default was + /// dropped reads back the same way while failing an INSERT that omits it with ERROR 1364. + @Test("addNewRow sends NULL rather than DEFAULT for a column whose default is NULL", arguments: ["NULL", "null"]) + func addNewRowSendsNullForNullDefault(spelling: String) { + let (manager, _) = makeManager() + var tableRows = emptyTableRows(columnDefaults: ["name": spelling, "email": "'x'"]) + + let result = manager.addNewRow(tableRows: &tableRows) + + #expect(result?.values[1] == .null) + #expect(result?.values[2] == "__DEFAULT__") + #expect(tableRows.serverAssignsValue(forColumn: "name") == false) + } + @Test("addNewRow uses nil for columns without defaults") func addNewRowUsesNilForNoDefaults() { let (manager, _) = makeManager() diff --git a/TableProTests/Models/Schema/ColumnDefaultRoundTripTests.swift b/TableProTests/Models/Schema/ColumnDefaultRoundTripTests.swift index c3c41d8dad..40b78da9c2 100644 --- a/TableProTests/Models/Schema/ColumnDefaultRoundTripTests.swift +++ b/TableProTests/Models/Schema/ColumnDefaultRoundTripTests.swift @@ -52,9 +52,11 @@ struct ColumnDefaultRoundTripTests { } /// A `TEXT` column is the one place MySQL's grammar adds something, and it adds it to every - /// value rather than to the ones the writer happens to recognise. + /// value rather than to the ones the writer happens to recognise. NULL is the exception: every + /// type takes it bare, and `DEFAULT (NULL)` is an expression default on MySQL 8 and a syntax + /// error on MySQL 5.7. @Test( - "A MySQL TEXT column parenthesises every menu value exactly once", + "A MySQL TEXT column parenthesises every menu value but NULL exactly once", arguments: [(type: DatabaseType.mysql, isMariaDB: false), (type: .mariadb, isMariaDB: true)] ) func mysqlTextColumnParenthesisesOnce(type: DatabaseType, isMariaDB: Bool) { @@ -62,9 +64,9 @@ struct ColumnDefaultRoundTripTests { let column = PluginColumnDefinition( name: "c", dataType: "TEXT", isNullable: true, defaultValue: sql ) - let expected = sql.hasPrefix("(") ? sql : "(\(sql))" + let expected = sql == "NULL" || sql.hasPrefix("(") ? sql : "(\(sql))" #expect( - mysqlColumnDefinitionSQL(column, isMariaDB: isMariaDB).contains("DEFAULT \(expected)"), + mysqlColumnDefinitionSQL(column, isMariaDB: isMariaDB).hasSuffix("DEFAULT \(expected)"), "\(type.rawValue): \(sql)" ) } diff --git a/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift b/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift index de5ee5942c..e271ffe69e 100644 --- a/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift +++ b/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift @@ -4,8 +4,8 @@ // import Foundation -import Testing import TableProPluginKit +import Testing @testable import TablePro @@ -237,6 +237,25 @@ struct CreateTableDraftBuilderTests { #expect(definition.columns.first?.isPrimaryKey == true) } + @Test("an auto-increment column made the primary key drops a NULL default with its nullability") + func promotedPrimaryKeyDropsNullDefault() throws { + var id = column("id", autoIncrement: true) + id.defaultValue = "NULL" + let result = plan(columns: [id, column("parent_id")]) + let definition = try #require(result.definition) + #expect(definition.columns.first?.isNullable == false) + #expect(definition.columns.first?.defaultValue == nil) + } + + @Test("a NOT NULL column with a NULL default is reported rather than sent") + func notNullColumnWithNullDefaultIsReported() { + var name = column("name", "VARCHAR(255)") + name.isNullable = false + name.defaultValue = "NULL" + let result = plan(columns: [column("id", primaryKey: true), name]) + #expect(result.issues.contains { $0.row == 1 }) + } + @Test("an explicit primary key is left alone") func explicitPrimaryKeyWins() throws { let result = plan(columns: [column("id", primaryKey: true), column("parent_id", autoIncrement: true)]) diff --git a/TableProTests/Plugins/MySQLColumnDefinitionSQLTests.swift b/TableProTests/Plugins/MySQLColumnDefinitionSQLTests.swift index 3c1a1b6012..26c8e2fc33 100644 --- a/TableProTests/Plugins/MySQLColumnDefinitionSQLTests.swift +++ b/TableProTests/Plugins/MySQLColumnDefinitionSQLTests.swift @@ -194,18 +194,268 @@ struct MySQLColumnDefinitionSQLTests { ] ) func catalogDefaultRoundTrip(value: String, extra: String, type: String, expected: String) { - let resolved = mysqlDefaultValueFromCatalog(value, extra: extra, dataType: type, quotesLiterals: false) + for isNullable in [true, false] { + #expect(mysqlColumnDefault(.bare(value), extra: extra, dataType: type, isNullable: isNullable) == expected) + } + } + + /// #3058. MySQL writes `DEFAULT NULL` itself for a nullable column declared without a default, and + /// both servers report it as SQL NULL, the same answer they give for a NOT NULL column with no + /// default at all. Only the column's nullability tells the two apart. + @Test( + "SQL NULL is DEFAULT NULL on a nullable column and no default on a NOT NULL one", + arguments: [ + MySQLCatalogDefault.bare(nil), + MySQLCatalogDefault.quoted(nil) + ] + ) + func catalogNullFollowsNullability(catalog: MySQLCatalogDefault) { + for type in ["VARCHAR(255)", "INT", "TEXT", "JSON", "TIMESTAMP", "enum('a','b')"] { + #expect(mysqlColumnDefault(catalog, extra: "", dataType: type, isNullable: true) == "NULL", "\(type)") + #expect(mysqlColumnDefault(catalog, extra: "", dataType: type, isNullable: false) == nil, "\(type)") + } + } + + /// MariaDB reports a generated column's default as the text `NULL`, and both servers refuse a + /// `DEFAULT` on a generated or AUTO_INCREMENT column, so neither may read as having one. + @Test( + "A generated or AUTO_INCREMENT column has no default whatever the catalog says", + arguments: [ + (catalog: MySQLCatalogDefault.quoted("NULL"), extra: "VIRTUAL GENERATED"), + (catalog: MySQLCatalogDefault.quoted("NULL"), extra: "STORED GENERATED"), + (catalog: MySQLCatalogDefault.bare(nil), extra: "VIRTUAL GENERATED"), + (catalog: MySQLCatalogDefault.bare(nil), extra: "PERSISTENT"), + (catalog: MySQLCatalogDefault.bare(nil), extra: "auto_increment"), + (catalog: MySQLCatalogDefault.quoted(nil), extra: "auto_increment") + ] + ) + func generatedAndAutoIncrementHaveNoDefault(catalog: MySQLCatalogDefault, extra: String) { + #expect(mysqlColumnDefault(catalog, extra: extra, dataType: "INT", isNullable: true) == nil) + #expect(mysqlColumnDefault(catalog, extra: extra, dataType: "INT", isNullable: false) == nil) + } + + /// `INFORMATION_SCHEMA.COLUMNS.COLUMN_DEFAULT` on MariaDB 12.3.3, byte for byte: literals quoted, + /// expressions bare, and `DEFAULT NULL` as the unquoted text `NULL`. + @Test( + "A MariaDB catalog default is already the SQL", + arguments: [ + (value: "NULL", type: "VARCHAR(255)"), + (value: "'NULL'", type: "VARCHAR(10)"), + (value: "'abc'", type: "VARCHAR(10)"), + (value: "''", type: "VARCHAR(10)"), + (value: "'it''s'", type: "VARCHAR(40)"), + (value: #"'x\\y'"#, type: "VARCHAR(40)"), + (value: #"'l1\nl2'"#, type: "VARCHAR(40)"), + (value: "'q'", type: "TEXT"), + (value: "'a'", type: "enum('a','b')"), + (value: "uuid()", type: "VARCHAR(36)"), + (value: "concat('a','b')", type: "VARCHAR(40)"), + (value: "current_timestamp()", type: "DATETIME"), + (value: "5", type: "INT(11)"), + (value: "1.50", type: "DECIMAL(5,2)"), + (value: "b'1'", type: "BIT(1)"), + (value: "x'61'", type: "VARBINARY(4)") + ] + ) + func mariaDBCatalogDefaultPassesThrough(value: String, type: String) { + #expect(mysqlColumnDefault(.quoted(value), extra: "", dataType: type, isNullable: true) == value) + } + + /// MariaDB's `SHOW FULL COLUMNS` never quotes, whatever the version: measured on 12.3.3 it answers + /// `abc` for `'abc'`, an empty string for `''` and the text `NULL` for the string `'NULL'`. That + /// read is what a MariaDB connection falls back to when its catalog is blind or refuses, so it + /// has to be decoded as bare, where the text `NULL` and SQL NULL stay apart. + @Test( + "A MariaDB SHOW FULL COLUMNS default is decoded as bare", + arguments: [ + (value: "abc", expected: "'abc'"), + (value: "", expected: "''"), + (value: "NULL", expected: "'NULL'"), + (value: "it's", expected: "'it''s'") + ] + ) + func mariaDBShowColumnsDefaultIsBare(value: String, expected: String) { + #expect(mysqlColumnDefault(.bare(value), extra: "", dataType: "VARCHAR(10)", isNullable: true) == expected) + } + + /// What a MariaDB whose catalog does not answer in the quoted form falls back to: `SHOW CREATE + /// TABLE`, verbatim from MariaDB 13.0.2. Its `SHOW FULL COLUMNS` reports `k`'s expression and a + /// string of the same text alike, so reading that instead turns `concat('x',uuid())` into the + /// constant string the next time the column is written. + @Test("A MariaDB SHOW CREATE TABLE default reads like its catalog's") + func mariaDBCreateTableDefaultsReadLikeTheCatalog() throws { + let createTable = """ + CREATE TABLE `t` ( + `a` int(11) DEFAULT (1 + 2), + `b` int(11) DEFAULT -5, + `c` varchar(20) DEFAULT 'a b', + `d` varchar(40) DEFAULT concat('a','b'), + `e` bigint(20) DEFAULT nextval(`p2`.`s1`), + `f` datetime(6) DEFAULT current_timestamp(6) ON UPDATE current_timestamp(6), + `g` varchar(10) DEFAULT 'x' COMMENT 'c,d', + `h` varchar(10) NOT NULL, + `i` text DEFAULT 'q', + `n` varchar(10) DEFAULT NULL, + `k` varchar(40) DEFAULT concat('x',uuid()), + `we ird` varchar(5) DEFAULT 'y', + `l` varchar(10) DEFAULT 'it''s, ok' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """ + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: createTable)) + let expected: [(name: String, type: String, isNullable: Bool, sql: String?)] = [ + ("a", "INT(11)", true, "(1 + 2)"), + ("b", "INT(11)", true, "-5"), + ("c", "VARCHAR(20)", true, "'a b'"), + ("d", "VARCHAR(40)", true, "concat('a','b')"), + ("e", "BIGINT(20)", true, "nextval(`p2`.`s1`)"), + ("f", "DATETIME(6)", true, "current_timestamp(6)"), + ("g", "VARCHAR(10)", true, "'x'"), + ("h", "VARCHAR(10)", false, nil), + ("i", "TEXT", true, "'q'"), + ("n", "VARCHAR(10)", true, "NULL"), + ("k", "VARCHAR(40)", true, "concat('x',uuid())"), + ("we ird", "VARCHAR(5)", true, "'y'"), + ("l", "VARCHAR(10)", true, "'it''s, ok'") + ] + for column in expected { + let resolved = mysqlColumnDefault( + .quoted(clauses[column.name]), extra: "", dataType: column.type, isNullable: column.isNullable + ) + #expect(resolved == column.sql, "\(column.name)") + } + } + + /// MySQL keeps an expression default escaped in its catalog, and non-ASCII text in it encoded twice + /// (`日` comes back as `æ\u{97}¥`), so an expression default is taken from `SHOW CREATE TABLE`. + /// Verbatim from MySQL 8.4.11. + @Test("A MySQL expression default reads from SHOW CREATE TABLE exactly, non-ASCII text included") + func mysqlExpressionDefaultsReadFromCreateTable() throws { + let createTable = """ + CREATE TABLE `mx` ( + `a` varchar(20) DEFAULT (concat(_utf8mb4'日',_utf8mb4'x')), + `b` varchar(20) CHARACTER SET latin1 COLLATE latin1_swedish_ci DEFAULT (concat(_utf8mb4'é',_utf8mb4'x')), + `c` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `f` text DEFAULT (NULL), + `g` int DEFAULT ((1 + 2)), + `i` int DEFAULT '5', + `日本` varchar(5) DEFAULT (upper(_utf8mb4'ü')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: createTable)) + let defaults = MySQLCreateTableDefaults(clauses: clauses, scope: .expressionDefaults) + let generated = "DEFAULT_GENERATED" + + #expect(defaults.catalogDefault(forColumn: "a", extra: generated) == .quoted("(concat(_utf8mb4'日',_utf8mb4'x'))")) + #expect(defaults.catalogDefault(forColumn: "b", extra: generated) == .quoted("(concat(_utf8mb4'é',_utf8mb4'x'))")) + #expect(defaults.catalogDefault(forColumn: "f", extra: generated) == .quoted("(NULL)")) + #expect(defaults.catalogDefault(forColumn: "g", extra: generated) == .quoted("((1 + 2))")) + #expect(defaults.catalogDefault(forColumn: "日本", extra: generated) == .quoted("(upper(_utf8mb4'ü'))")) + #expect(defaults.catalogDefault(forColumn: "i", extra: "") == nil) + #expect(defaults.catalogDefault(forColumn: "missing", extra: generated) == nil) + } + + /// A quoted identifier may hold a line break and a string default may hold a comma. Read line by + /// line, the first handed the rest of its name's line to another column as that column's default. + @Test("A line break in a quoted name or a comma in a default moves no default to another column") + func lineBreakInQuotedNameKeepsDefaultsInPlace() throws { + let createTable = """ + CREATE TABLE `t` ( + `a` varchar(10) DEFAULT 'x', + `we + ird` varchar(5) DEFAULT 'y, z', + `b` int DEFAULT NULL, + PRIMARY KEY (`a`), + KEY `k` (`b`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """ + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: createTable)) + #expect(clauses == ["a": "'x'", "we\nird": "'y, z'", "b": "NULL"]) + } + + @Test("SHOW CREATE TABLE answers every column for a MariaDB, and a missing clause is no default") + func everyColumnScopeAnswersForAll() { + let defaults = MySQLCreateTableDefaults(clauses: ["a": "'x'"], scope: .everyColumn) + #expect(defaults.catalogDefault(forColumn: "a", extra: "") == .quoted("'x'")) + #expect(defaults.catalogDefault(forColumn: "b", extra: "") == .quoted(nil)) + } + + /// 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", + arguments: [ + (value: "concat(_utf8mb4\\'\u{E6}\u{97}\u{A5}\\',_utf8mb4\\'x\\')", extra: "DEFAULT_GENERATED", + type: "VARCHAR(20)", needs: true), + (value: "upper(_utf8mb4\\'\u{C3}\u{BC}\\')", extra: "DEFAULT_GENERATED", type: "VARCHAR(5)", needs: true), + (value: "uuid()", extra: "DEFAULT_GENERATED", type: "VARCHAR(36)", needs: false), + (value: "concat(_utf8mb4\\'it\\\\\\'s\\')", extra: "DEFAULT_GENERATED", type: "VARCHAR(40)", needs: false), + (value: "NULL", extra: "DEFAULT_GENERATED", type: "TEXT", needs: false), + (value: "CURRENT_TIMESTAMP", extra: "DEFAULT_GENERATED", type: "TIMESTAMP", needs: false), + (value: "caf\u{E9}", extra: "", type: "VARCHAR(10)", needs: false) + ] + ) + func expressionDefaultsNeedCreateTable(value: String, extra: String, type: String, needs: Bool) { + #expect(mysqlExpressionDefaultNeedsCreateTable(value, extra: extra, dataType: type) == needs) + } + + /// Before 10.2.7 a MariaDB catalog reports `uuid()` and the string `'uuid()'` alike. + @Test( + "A bare MariaDB default may be an expression only when it holds a parenthesis", + arguments: [ + (value: "uuid()", type: "VARCHAR(36)", may: true), + (value: "concat('a','b')", type: "VARCHAR(10)", may: true), + (value: "(1 + 2)", type: "INT(11)", may: true), + (value: "current_timestamp()", type: "DATETIME", may: false), + (value: "abc", type: "VARCHAR(10)", may: false), + (value: "5", type: "INT(11)", may: false) + ] + ) + func mariaDBBareDefaultsThatMayBeExpressions(value: String, type: String, may: Bool) { + #expect(mariaDBBareDefaultMayBeExpression(value, dataType: type) == may) + } + + @Test("A catalog expression holding non-ASCII text is left escaped rather than rewritten") + func nonASCIIExpressionIsNotUnescaped() { + let doubleEncoded = "concat(_utf8mb4\\'\u{E6}\u{97}\u{A5}\\',_utf8mb4\\'x\\')" + #expect(mysqlUnescapedCatalogExpression(doubleEncoded) == doubleEncoded) + } + + /// MySQL 8.4.11 backslash-escapes every quote and backslash in an expression default, relative to + /// what `SHOW CREATE TABLE` prints. Measured from `HEX(COLUMN_DEFAULT)`. + @Test( + "A MySQL expression default is unescaped back to the SQL it was written as", + arguments: [ + (value: #"concat(_utf8mb4\'a\',_utf8mb4\'b\')"#, type: "VARCHAR(40)", + expected: #"(concat(_utf8mb4'a',_utf8mb4'b'))"#), + (value: #"_utf8mb4\'it\\\'s\'"#, type: "VARCHAR(40)", expected: #"(_utf8mb4'it\'s')"#), + (value: #"concat(_utf8mb4\'x\\\\y\',_utf8mb4\'z\')"#, type: "VARCHAR(40)", + expected: #"(concat(_utf8mb4'x\\y',_utf8mb4'z'))"#), + (value: #"concat(_utf8mb4\'l1\\nl2\',_utf8mb4\'\')"#, type: "VARCHAR(40)", + expected: #"(concat(_utf8mb4'l1\nl2',_utf8mb4''))"#), + (value: #"json_object(_utf8mb4\'k\',_utf8mb4\'v\')"#, type: "JSON", + expected: #"(json_object(_utf8mb4'k',_utf8mb4'v'))"#), + (value: "NULL", type: "TEXT", expected: "(NULL)") + ] + ) + func mysqlExpressionDefaultIsUnescaped(value: String, type: String, expected: String) { + let resolved = mysqlColumnDefault(.bare(value), extra: "DEFAULT_GENERATED", dataType: type, isNullable: true) #expect(resolved == expected) } - @Test("A server that quotes its own literals has already produced the SQL") - func quotingServerCatalogDefaultPassesThrough() { - #expect( - mysqlDefaultValueFromCatalog("'abc'", extra: "", dataType: "VARCHAR(16)", quotesLiterals: true) == "'abc'" - ) - #expect( - mysqlDefaultValueFromCatalog("uuid()", extra: "", dataType: "VARCHAR(36)", quotesLiterals: true) == "uuid()" - ) + /// A server that stops escaping would hand back the SQL itself, which always holds a bare quote + /// wherever it holds a string, so the scan leaves it as it came rather than eating its backslashes. + @Test( + "Text that was never escaped is returned unchanged", + arguments: [ + #"concat(_utf8mb4'a',_utf8mb4'b')"#, + #"(_utf8mb4'it\'s')"#, + #"concat('x\\y','z')"#, + "uuid()", + "(curdate() + interval 1 year)", + #"trailing\"# + ] + ) + func unescapedExpressionPassesThrough(value: String) { + #expect(mysqlUnescapedCatalogExpression(value) == value) } /// MariaDB began quoting `COLUMN_DEFAULT` in 10.2.7. Before that it reads like MySQL without the @@ -228,18 +478,34 @@ struct MySQLColumnDefinitionSQLTests { ) } - @Test("An older MariaDB literal is quoted rather than passed through") - func olderMariaDBLiteralIsQuoted() { - #expect( - mysqlDefaultValueFromCatalog("active", extra: "", dataType: "VARCHAR(16)", quotesLiterals: false) - == "'active'" - ) + /// The whole of #3058 in one line: the default a nullable column reads back is written as + /// `DEFAULT NULL`, which the next read turns into the same `NULL` again. + @Test("A nullable column's NULL default survives a read and a rewrite") + func nullDefaultRoundTrips() { + let read = mysqlColumnDefault(.bare(nil), extra: "", dataType: "VARCHAR(255)", isNullable: true) + let column = PluginColumnDefinition(name: "Name", dataType: "VARCHAR(255)", isNullable: true, defaultValue: read) + #expect(mysqlColumnDefinitionSQL(column) == "`Name` VARCHAR(255) NULL DEFAULT NULL") + } + + /// `DEFAULT (NULL)` is an expression default on MySQL 8 and a syntax error on MySQL 5.7, while a + /// bare `DEFAULT NULL` is taken by every type, including the four whose other defaults need + /// parentheses. + @Test( + "NULL is written bare on every type and for both servers", + arguments: ["TEXT", "LONGBLOB", "JSON", "GEOMETRY", "VARCHAR(16)", "INT", "TIMESTAMP"] + ) + func nullDefaultIsNeverParenthesised(dataType: String) { + for isMariaDB in [false, true] { + #expect(mysqlDefaultValueLiteral("NULL", dataType: dataType, isMariaDB: isMariaDB) == "NULL") + #expect(mysqlDefaultValueLiteral("null", dataType: dataType, isMariaDB: isMariaDB) == "NULL") + } + let column = PluginColumnDefinition(name: "c", dataType: dataType, isNullable: true, defaultValue: "NULL") + #expect(mysqlColumnDefinitionSQL(column).hasSuffix(" NULL DEFAULT NULL")) } - @Test("No default at all stays absent") - func absentCatalogDefault() { - #expect(mysqlDefaultValueFromCatalog(nil, extra: "", dataType: "INT", quotesLiterals: false) == nil) - #expect(mysqlDefaultValueFromCatalog(nil, extra: "", dataType: "INT", quotesLiterals: true) == nil) + @Test("An expression default of NULL keeps the parentheses it was read with") + func parenthesisedNullExpressionIsKept() { + #expect(mysqlDefaultValueLiteral("(NULL)", dataType: "TEXT", isMariaDB: false) == "(NULL)") } @Test("A numeric default is unquoted") diff --git a/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift b/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift index f4da9fa258..3415dccf92 100644 --- a/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift +++ b/TableProTests/Plugins/OceanBaseColumnDefaultsTests.swift @@ -81,7 +81,7 @@ struct OceanBaseColumnDefaultsTests { @Test("Each column's DEFAULT clause is read as written, and a column without one has none") func clausesAsWritten() throws { - let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.awkwardLiterals)) + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: Self.awkwardLiterals)) #expect(clauses["id"] == nil) #expect(clauses["gen"] == nil) #expect(clauses["q"] == #"'it\'s'"#) @@ -103,7 +103,7 @@ struct OceanBaseColumnDefaultsTests { ("d", "curdate()", "(curdate())") ]) func expressionOrLiteral(column: String, catalogDefault: String, expected: String) throws { - let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.expressionAndLiteral)) + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: Self.expressionAndLiteral)) #expect(OceanBaseColumnDefaults.resolve(clause: clauses[column], catalogDefault: catalogDefault) == .value(expected)) } @@ -118,7 +118,7 @@ struct OceanBaseColumnDefaultsTests { ("nn", "", "''") ]) func literalsAsSQL(column: String, catalogDefault: String, expected: String) throws { - let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.awkwardLiterals)) + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: Self.awkwardLiterals)) #expect(OceanBaseColumnDefaults.resolve(clause: clauses[column], catalogDefault: catalogDefault) == .value(expected)) } @@ -130,22 +130,22 @@ struct OceanBaseColumnDefaultsTests { ("ch", "abc", "'abc'") ]) func escapesDecode(column: String, catalogDefault: String, expected: String) throws { - let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.escapes)) + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: Self.escapes)) #expect(OceanBaseColumnDefaults.resolve(clause: clauses[column], catalogDefault: catalogDefault) == .value(expected)) } @Test("A set member printed with a bare quote does not disturb the columns after it") func malformedMemberListStaysOnItsLine() throws { - let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.escapes)) + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: Self.escapes)) #expect(OceanBaseColumnDefaults.resolve(clause: clauses["ch"], catalogDefault: "abc") == .value("'abc'")) - let partitioned = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.partitionedWithEnum)) + let partitioned = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: Self.partitionedWithEnum)) #expect(OceanBaseColumnDefaults.resolve(clause: partitioned["note"], catalogDefault: "x") == .value("'x'")) #expect(partitioned["p0"] == nil) } @Test("Backtick-escaped table and column names are read") func quotedNames() throws { - let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.quotedNames)) + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: Self.quotedNames)) #expect(clauses["a`b"] == "'1'") #expect(OceanBaseColumnDefaults.resolve(clause: clauses["c d"], catalogDefault: "x") == .value("'x'")) } @@ -166,9 +166,9 @@ struct OceanBaseColumnDefaultsTests { @Test("A view's CREATE statement yields nothing to resolve against") func viewIsNotATable() { let view = "CREATE VIEW `v` AS select `tp_def2`.`p`.`id` AS `id`,`tp_def2`.`p`.`kind` AS `kind` from `tp_def2`.`p`" - #expect(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: view) == nil) + #expect(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: view) == nil) let definer = "CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `v` AS select 1 AS `a`" - #expect(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: definer) == nil) + #expect(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: definer) == nil) } private static let unquotedIdentifiers = #""" @@ -204,7 +204,7 @@ struct OceanBaseColumnDefaultsTests { func bareNameStartingWithDigit() throws { let statement = "CREATE TABLE t (\n id int(11) NOT NULL,\n 1st_id varchar(36) DEFAULT (uuid()),\n" + " `$col` varchar(9) DEFAULT (upper('x')),\n PRIMARY KEY (id)\n) ORGANIZATION INDEX" - let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: statement)) + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: statement)) #expect(clauses["1st_id"] == "(uuid())") #expect(clauses["$col"] == "(upper('x'))") } @@ -235,7 +235,7 @@ struct OceanBaseColumnDefaultsTests { @Test("Identifiers in double quotes, as ANSI_QUOTES prints them, are read") func ansiQuotedIdentifiersAreRead() throws { - let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.ansiQuotedIdentifiers)) + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: Self.ansiQuotedIdentifiers)) #expect(OceanBaseColumnDefaults.resolve(clause: clauses["e"], catalogDefault: "uuid()") == .value("(uuid())")) #expect(OceanBaseColumnDefaults.resolve(clause: clauses["l"], catalogDefault: "it's (x)") == .value("'it''s (x)'")) #expect( @@ -249,7 +249,7 @@ struct OceanBaseColumnDefaultsTests { @Test("Identifiers printed without backticks, as sql_quote_show_create = 0 leaves them, are read") func unquotedIdentifiersAreRead() throws { - let clauses = try #require(OceanBaseColumnDefaults.defaultClauses(fromCreateTable: Self.unquotedIdentifiers)) + let clauses = try #require(MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: Self.unquotedIdentifiers)) #expect(OceanBaseColumnDefaults.resolve(clause: clauses["a"], catalogDefault: "(1 + 2)") == .value("((1 + 2))")) #expect(OceanBaseColumnDefaults.resolve(clause: clauses["c"], catalogDefault: "now()") == .value("(now())")) #expect(OceanBaseColumnDefaults.resolve(clause: clauses["g"], catalogDefault: "A") == .value("'A'")) diff --git a/TableProTests/Views/Structure/StructureNullDefaultTests.swift b/TableProTests/Views/Structure/StructureNullDefaultTests.swift new file mode 100644 index 0000000000..0740ef4ce6 --- /dev/null +++ b/TableProTests/Views/Structure/StructureNullDefaultTests.swift @@ -0,0 +1,157 @@ +// +// StructureNullDefaultTests.swift +// TableProTests +// +// A nullable MySQL or MariaDB column reads back with a NULL default (#3058), so turning Nullable +// off is the ordinary way to reach `NOT NULL DEFAULT NULL`, which both servers refuse with +// ERROR 1067. The default has to go with the nullability. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor @Suite("Structure NULL default") +struct StructureNullDefaultTests { + private func column(default defaultValue: String?, isNullable: Bool = true) -> EditableColumnDefinition { + var column = EditableColumnDefinition.placeholder() + column.name = "Name" + column.dataType = "VARCHAR(255)" + column.isNullable = isNullable + column.defaultValue = defaultValue + return column + } + + @Test("Turning Nullable off drops a NULL default", arguments: ["NULL", "null", " NULL "]) + func notNullDropsNullDefault(spelling: String) { + var target = column(default: spelling) + StructureEditingSupport.updateColumn(&target, at: 0, with: "NO", orderedFields: [.nullable]) + #expect(target.isNullable == false) + #expect(target.defaultValue == nil) + } + + @Test( + "Turning Nullable off keeps every other default", + arguments: ["'NULL'", "''", "'abc'", "0", "(NULL)", "CURRENT_TIMESTAMP"] + ) + func notNullKeepsOtherDefaults(defaultValue: String) { + var target = column(default: defaultValue) + StructureEditingSupport.updateColumn(&target, at: 0, with: "NO", orderedFields: [.nullable]) + #expect(target.isNullable == false) + #expect(target.defaultValue == defaultValue) + } + + @Test("Turning Nullable on invents no default") + func nullableInventsNoDefault() { + var target = column(default: nil, isNullable: false) + StructureEditingSupport.updateColumn(&target, at: 0, with: "YES", orderedFields: [.nullable]) + #expect(target.isNullable == true) + #expect(target.defaultValue == nil) + } + + @Test("Setting Primary Key drops a NULL default along with the nullability") + func primaryKeyDropsNullDefault() { + var target = column(default: "NULL") + StructureEditingSupport.updateColumn(&target, at: 0, with: "YES", orderedFields: [.primaryKey]) + #expect(target.isPrimaryKey == true) + #expect(target.isNullable == false) + #expect(target.defaultValue == nil) + } + + @Test("The MODIFY written after turning Nullable off carries no DEFAULT NULL") + func modifyAfterNotNullIsAccepted() { + var target = column(default: "NULL") + StructureEditingSupport.updateColumn(&target, at: 0, with: "NO", orderedFields: [.nullable]) + #expect(mysqlColumnDefinitionSQL(target.toPlugin()) == "`Name` VARCHAR(255) NOT NULL") + } + + private func loadedManager(nameIsNullable: Bool = true, nameDefault: String? = "NULL") -> StructureChangeManager { + let manager = StructureChangeManager() + manager.loadSchema( + tableName: "Event", + columns: [ + ColumnInfo(name: "new_id", dataType: "INT", isNullable: false, isPrimaryKey: true), + ColumnInfo(name: "Name", dataType: "VARCHAR(255)", isNullable: nameIsNullable, isPrimaryKey: false, + defaultValue: nameDefault) + ], + indexes: [], + foreignKeys: [], + primaryKey: ["new_id"] + ) + return manager + } + + @Test("Picking NULL as the default of a NOT NULL column is refused before save") + func nullDefaultOnNotNullColumnIsRefused() { + let manager = loadedManager(nameIsNullable: false, nameDefault: nil) + var edited = manager.workingColumns[1] + StructureEditingSupport.updateColumn(&edited, at: 0, with: "NULL", orderedFields: [.defaultValue]) + manager.updateColumn(id: edited.id, with: edited) + + #expect(manager.validationErrors[.column(edited.id)] != nil) + #expect(manager.canCommit == false) + } + + @Test("Turning Nullable off and then picking NULL is refused, whichever comes first") + func notNullThenNullDefaultIsRefused() { + let manager = loadedManager(nameDefault: nil) + var edited = manager.workingColumns[1] + StructureEditingSupport.updateColumn(&edited, at: 0, with: "NO", orderedFields: [.nullable]) + StructureEditingSupport.updateColumn(&edited, at: 0, with: "NULL", orderedFields: [.defaultValue]) + manager.updateColumn(id: edited.id, with: edited) + + #expect(manager.validationErrors[.column(edited.id)] != nil) + #expect(manager.canCommit == false) + } + + /// SQLite and DuckDB accept `NOT NULL DEFAULT NULL`, so a table can already hold one. Only a + /// column the user is changing is theirs to fix before an unrelated edit saves. + @Test("An untouched NOT NULL column that already defaults to NULL does not block another edit") + func untouchedNotNullNullDefaultDoesNotBlock() { + let manager = loadedManager(nameIsNullable: false, nameDefault: "NULL") + var other = manager.workingColumns[0] + other.comment = "surrogate key" + manager.updateColumn(id: other.id, with: other) + + #expect(manager.validationErrors.isEmpty) + #expect(manager.canCommit) + } + + @Test("Editing another field of a column that already had NOT NULL and a NULL default does not block") + func editingExistingNotNullNullDefaultDoesNotBlock() { + let manager = loadedManager(nameIsNullable: false, nameDefault: "NULL") + var edited = manager.workingColumns[1] + edited.comment = "shown on the invoice" + manager.updateColumn(id: edited.id, with: edited) + + #expect(manager.validationErrors.isEmpty) + #expect(manager.canCommit) + } + + @Test("A nullable column with a NULL default saves") + func nullableNullDefaultIsAccepted() { + let manager = loadedManager(nameDefault: nil) + var edited = manager.workingColumns[1] + StructureEditingSupport.updateColumn(&edited, at: 0, with: "NULL", orderedFields: [.defaultValue]) + manager.updateColumn(id: edited.id, with: edited) + + #expect(manager.validationErrors.isEmpty) + #expect(manager.canCommit) + } + + @Test("Undo brings back the nullability and the NULL default together") + func undoRestoresBoth() { + let manager = loadedManager() + var edited = manager.workingColumns[1] + StructureEditingSupport.updateColumn(&edited, at: 0, with: "NO", orderedFields: [.nullable]) + manager.updateColumn(id: edited.id, with: edited) + #expect(manager.workingColumns[1].isNullable == false) + #expect(manager.workingColumns[1].defaultValue == nil) + + manager.undo() + #expect(manager.workingColumns[1].isNullable == true) + #expect(manager.workingColumns[1].defaultValue == "NULL") + #expect(manager.hasChanges == false) + } +} diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 35d6ed02db..1fb99c5964 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -54,6 +54,10 @@ The cell holds the SQL that follows `DEFAULT`, so a string literal carries its o Typing straight into the cell is the same as **SQL expression**, so `pending` is a column reference and `'pending'` is the string. **Text** does the quoting and escaping for you. +On MySQL and MariaDB a nullable column declared without a default has `DEFAULT NULL`, so its cell reads **NULL**, and **No default** on a nullable column saves as **NULL**. + +Setting **Nullable** to NO, or **Primary Key** to YES, clears a NULL default. A column you change so that it refuses NULL and defaults to NULL is reported before anything is saved. + Auto-increment has its own **Auto Inc** field. diff --git a/project.yml b/project.yml index 4ac82cd592..763242e325 100644 --- a/project.yml +++ b/project.yml @@ -513,6 +513,7 @@ targets: - Plugins/MySQLDriverPlugin/MySQLConnectionEncoding.swift - Plugins/MySQLDriverPlugin/MySQLLatin1.swift - Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift + - Plugins/MySQLDriverPlugin/MySQLCatalogDefault.swift - Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift - Plugins/MySQLDriverPlugin/MySQLIdleRelease.swift - Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift diff --git a/scripts/check-mysql-column-defaults.sh b/scripts/check-mysql-column-defaults.sh new file mode 100755 index 0000000000..d95d5940b2 --- /dev/null +++ b/scripts/check-mysql-column-defaults.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# Check how a live MySQL or MariaDB server reports column defaults against what the driver assumes. +# +# MySQLCatalogDefault.swift decodes a default from the catalog by hand, and each rule in it is a +# transcription of server behaviour nothing at runtime re-checks: that SQL NULL stands for DEFAULT +# NULL on a nullable column, that MariaDB's SHOW FULL COLUMNS never quotes while its +# INFORMATION_SCHEMA does from 10.2.7, and that MySQL backslash-escapes the quotes in an expression +# default. This builds one scratch table in a throwaway database, reads it back both ways, and fails +# on any answer that differs from the one the decoder was written against. +# +# Usage: +# scripts/check-mysql-column-defaults.sh [host] [port] [user] +# +# Needs the mysql client and an account that may create and drop a database. The password, if any, +# comes from MYSQL_PWD. Set MYSQL_UNIX_PORT to connect through that socket instead of host and port. Exits +# non-zero on a disagreement and 3 when no server answers. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-3306}" +USER_NAME="${3:-root}" +DATABASE="tablepro_default_check_$$" + +command -v mysql > /dev/null || { + echo "mysql client not found" >&2 + exit 3 +} + +MYSQL=(mysql --no-defaults -u "$USER_NAME" -N -B --default-character-set=utf8mb4) +# Both clients pick TCP for a numeric host or a given port, and a socket path does not override +# either, so the socket form names neither. +if [ -n "${MYSQL_UNIX_PORT:-}" ]; then + MYSQL+=(--protocol=socket -S "$MYSQL_UNIX_PORT") +else + MYSQL+=(-h "$HOST" -P "$PORT") +fi +VERSION="$("${MYSQL[@]}" -e "SELECT VERSION()" 2> /dev/null)" || { + echo "no MySQL at $HOST:$PORT for $USER_NAME" >&2 + exit 3 +} + +IS_MARIADB=0 +case "$VERSION" in + *MariaDB*) IS_MARIADB=1 ;; +esac + +IFS=. read -r MAJOR MINOR PATCH <<< "${VERSION%%-*}" +PATCH="${PATCH%%[!0-9]*}" + +version_at_least() { + [ "$MAJOR" -gt "$1" ] && return 0 + [ "$MAJOR" -lt "$1" ] && return 1 + [ "$MINOR" -gt "$2" ] && return 0 + [ "$MINOR" -lt "$2" ] && return 1 + [ "$PATCH" -ge "$3" ] +} + +# MySQL takes a general expression default from 8.0.13 and MariaDB from 10.2.1; MariaDB quotes its +# catalog literals from 10.2.7. Below those floors the fixture is left out or the bare form expected. +HAS_EXPRESSION_DEFAULT=0 +CATALOG_QUOTES=0 +if [ "$IS_MARIADB" = 1 ]; then + version_at_least 10 2 1 && HAS_EXPRESSION_DEFAULT=1 + version_at_least 10 2 7 && CATALOG_QUOTES=1 +else + version_at_least 8 0 13 && HAS_EXPRESSION_DEFAULT=1 +fi + +"${MYSQL[@]}" -e "CREATE DATABASE \`$DATABASE\`" || exit 3 +trap '"${MYSQL[@]}" -e "DROP DATABASE IF EXISTS \`$DATABASE\`"' EXIT + +if [ "$HAS_EXPRESSION_DEFAULT" = 0 ]; then + EXPRESSION_DEFAULT="'plain'" +elif [ "$IS_MARIADB" = 1 ]; then + EXPRESSION_DEFAULT="uuid()" +else + EXPRESSION_DEFAULT="(concat('a','b'))" +fi + +"${MYSQL[@]}" "$DATABASE" -e " + CREATE TABLE probe ( + implicit_null VARCHAR(10) NULL, + explicit_null VARCHAR(10) NULL DEFAULT NULL, + text_null TEXT NULL, + string_null VARCHAR(10) NULL DEFAULT 'NULL', + string_abc VARCHAR(10) NULL DEFAULT 'abc', + string_empty VARCHAR(10) NOT NULL DEFAULT '', + no_default VARCHAR(10) NOT NULL, + expression VARCHAR(40) NULL DEFAULT $EXPRESSION_DEFAULT + )" || exit 1 + +failures=0 + +report() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo "ok $label: $actual" + else + echo "FAIL $label: expected $expected, got $actual" + failures=$((failures + 1)) + fi +} + +hex() { + printf '%s' "$1" | od -An -tx1 | tr -d ' \n' | tr 'a-f' 'A-F' +} + +# SQL NULL and the text NULL print the same in batch mode, so the catalog answer is compared as hex +# with SQL NULL spelled out. +catalog_default() { + "${MYSQL[@]}" -e " + SELECT IF(COLUMN_DEFAULT IS NULL, 'SQL-NULL', HEX(COLUMN_DEFAULT)) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = '$DATABASE' AND TABLE_NAME = 'probe' AND COLUMN_NAME = '$1'" +} + +# SHOW FULL COLUMNS cannot be wrapped in a SELECT, so SQL NULL is read from the XML form's xsi:nil. +show_default() { + local line + line="$("${MYSQL[@]}" --xml "$DATABASE" -e "SHOW FULL COLUMNS FROM probe WHERE Field = '$1'" \ + | grep '//' -e 's/<\/field>.*//' ;; + esac +} + +# The expression's DEFAULT operand as SHOW CREATE TABLE prints it, which is what the driver reads for a +# MySQL expression default and for a MariaDB whose catalog does not answer in its quoted form. +create_default() { + "${MYSQL[@]}" -r "$DATABASE" -e "SHOW CREATE TABLE probe" | cut -f2 \ + | grep "^ \`$1\` " | sed -e 's/.* DEFAULT //' -e 's/,$//' +} + +extra_of() { + "${MYSQL[@]}" -e " + SELECT EXTRA FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = '$DATABASE' AND TABLE_NAME = 'probe' AND COLUMN_NAME = '$1'" +} + +echo "server: $VERSION" + +for column in implicit_null explicit_null text_null no_default; do + report "SHOW $column" "SQL-NULL" "$(show_default "$column")" +done +report "SHOW string_null" "NULL" "$(show_default string_null)" +report "SHOW string_abc" "abc" "$(show_default string_abc)" +report "SHOW string_empty" "" "$(show_default string_empty)" +report "catalog no_default" "SQL-NULL" "$(catalog_default no_default)" + +if [ "$CATALOG_QUOTES" = 1 ]; then + for column in implicit_null explicit_null text_null; do + report "catalog $column" "$(hex "NULL")" "$(catalog_default "$column")" + done + report "catalog string_null" "$(hex "'NULL'")" "$(catalog_default string_null)" + report "catalog string_abc" "$(hex "'abc'")" "$(catalog_default string_abc)" + report "catalog string_empty" "$(hex "''")" "$(catalog_default string_empty)" +else + for column in implicit_null explicit_null text_null; do + report "catalog $column" "SQL-NULL" "$(catalog_default "$column")" + done + report "catalog string_null" "$(hex "NULL")" "$(catalog_default string_null)" + report "catalog string_abc" "$(hex "abc")" "$(catalog_default string_abc)" +fi + +if [ "$HAS_EXPRESSION_DEFAULT" = 0 ]; then + echo "skip expression defaults: $VERSION predates them" +elif [ "$IS_MARIADB" = 1 ]; then + [ "$CATALOG_QUOTES" = 1 ] && report "catalog expression" "$(hex "uuid()")" "$(catalog_default expression)" + report "SHOW expression" "uuid()" "$(show_default expression)" + report "extra expression" "" "$(extra_of expression)" + report "SHOW CREATE expression" "uuid()" "$(create_default expression)" +else + report "catalog expression" "$(hex "concat(_utf8mb4\\'a\\',_utf8mb4\\'b\\')")" \ + "$(catalog_default expression)" + report "extra expression" "DEFAULT_GENERATED" "$(extra_of expression)" + report "SHOW CREATE expression" "(concat(_utf8mb4'a',_utf8mb4'b'))" "$(create_default expression)" +fi + +if [ "$failures" -gt 0 ]; then + echo "$failures disagreement(s) with MySQLCatalogDefault.swift" + exit 1 +fi +echo "every answer matches MySQLCatalogDefault.swift"