Skip to content

Commit 0515b3b

Browse files
authored
fix(plugin-mysql): read a nullable column's DEFAULT NULL back as NULL on MySQL and MariaDB (#3067)
1 parent f626e9a commit 0515b3b

28 files changed

Lines changed: 1222 additions & 171 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8282
- Unexpanded schemas hidden by the sidebar filter in the Tree layout.
8383
- Empty object sections opened as "No items" under every match while filtering the sidebar tree.
8484
- **Drop View** offered in Recent for a sequence or materialized view opened from Open Quickly.
85+
- Column default of NULL shown as Empty on MySQL and MariaDB, and a NULL default that never stuck. (#3058)
86+
- String column defaults misread on MariaDB 10.2.7 and later, and expression defaults on MariaDB 10.2.1 to 10.2.6.
87+
- `ERROR 1064` editing a MySQL 8 column whose expression default holds a quoted string.
88+
- `ERROR 1067` saving a column made NOT NULL while its default was NULL.
89+
- NULL default on a MySQL `TEXT`, `BLOB`, `JSON` or `GEOMETRY` column saved as the expression `(NULL)`.
8590
- Unresponsive app and a dropped keystroke when typing in the row inspector's JSON field. (#3051)
8691
- Raw Oracle driver error in the schema switch failure dialog. (#3053)
8792
- Oracle health check closing a connection a statement was still running on. (#3053)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//
2+
// MySQLCatalogDefault.swift
3+
// MySQLDriverPlugin
4+
//
5+
// A column default as the catalog reports it, turned into the SQL that recreates it.
6+
//
7+
8+
import Foundation
9+
import TableProPluginKit
10+
11+
/// A catalog default, tagged with the form the read that produced it uses.
12+
///
13+
/// Nothing in the value alone says which form it is in, and the two forms disagree on what SQL NULL
14+
/// and a bare word mean, so the form travels with the value rather than as a flag beside it.
15+
internal enum MySQLCatalogDefault: Equatable, Sendable {
16+
/// `SHOW FULL COLUMNS` on every server, and `INFORMATION_SCHEMA.COLUMNS` on MySQL and on MariaDB
17+
/// before 10.2.7. A literal arrives unquoted, MySQL marks an expression `DEFAULT_GENERATED` in
18+
/// `EXTRA`, and SQL NULL stands for both `DEFAULT NULL` and no default at all.
19+
case bare(String?)
20+
21+
/// `INFORMATION_SCHEMA.COLUMNS` on MariaDB from 10.2.7. A literal arrives quoted, an expression
22+
/// bare, `DEFAULT NULL` as the unquoted text `NULL`, and SQL NULL only for no default. MariaDB's
23+
/// `SHOW FULL COLUMNS` does not follow it: measured on 12.3, it reports `'abc'` as `abc` and
24+
/// `uuid()` as `uuid()` with an empty `EXTRA`.
25+
case quoted(String?)
26+
27+
var value: String? {
28+
switch self {
29+
case .bare(let value), .quoted(let value): value
30+
}
31+
}
32+
}
33+
34+
/// The column defaults `SHOW CREATE TABLE` states, and which columns a column read takes them for.
35+
internal struct MySQLCreateTableDefaults: Equatable, Sendable {
36+
enum Scope: Equatable, Sendable {
37+
/// A MariaDB whose catalog did not answer in its quoted form: the statement is the only exact
38+
/// source, for every column. A column with no `DEFAULT` clause there has none.
39+
case everyColumn
40+
/// MySQL: only the expression defaults its catalog cannot report exactly. Every other default
41+
/// reads better from the catalog, where a number stays `5` rather than the `'5'` this prints.
42+
case expressionDefaults
43+
}
44+
45+
let clauses: [String: String]
46+
let scope: Scope
47+
48+
func catalogDefault(forColumn name: String, extra: String?) -> MySQLCatalogDefault? {
49+
switch scope {
50+
case .everyColumn:
51+
return .quoted(clauses[name])
52+
case .expressionDefaults:
53+
guard mysqlIsExpressionDefault(extra: extra), let clause = clauses[name] else { return nil }
54+
return .quoted(clause)
55+
}
56+
}
57+
}
58+
59+
/// The SQL that follows `DEFAULT` for a column, or nil when the column has no default.
60+
///
61+
/// A nullable column the catalog gives SQL NULL has `DEFAULT NULL`. MySQL writes that clause itself
62+
/// for a nullable column declared without one, and MariaDB keeps it through `DROP DEFAULT`. The one
63+
/// state this cannot see is a MySQL nullable column whose default was removed with `ALTER COLUMN …
64+
/// DROP DEFAULT`: the catalog reports it the same way, and on `TEXT` so does `SHOW CREATE TABLE`, yet
65+
/// an `INSERT` that omits it fails with `ERROR 1364`. It reads as `NULL` here and everywhere this
66+
/// value goes, schema compare and exported DDL included. Nothing this app writes creates that state,
67+
/// because a `MODIFY` with no `DEFAULT` clause on a nullable column is `DEFAULT NULL` again.
68+
///
69+
/// A generated or AUTO_INCREMENT column has no default whatever the catalog says. MariaDB reports a
70+
/// generated column's as `NULL`, and the server refuses a `DEFAULT` on either.
71+
internal func mysqlColumnDefault(
72+
_ catalog: MySQLCatalogDefault,
73+
extra: String?,
74+
dataType: String,
75+
isNullable: Bool
76+
) -> String? {
77+
guard !mysqlColumnIsGenerated(extra: extra), mysqlIdentityKind(extra: extra) == nil else { return nil }
78+
guard let value = catalog.value else { return isNullable ? "NULL" : nil }
79+
guard case .bare = catalog else { return value }
80+
return mysqlBareCatalogDefault(value, extra: extra, dataType: dataType)
81+
}
82+
83+
private func mysqlBareCatalogDefault(_ value: String, extra: String?, dataType: String) -> String {
84+
// MySQL 8.0.13 marks a plain `DEFAULT CURRENT_TIMESTAMP` DEFAULT_GENERATED like any other
85+
// expression, so this has to be answered before the marker is consulted or the one expression
86+
// MySQL insists on bare comes back parenthesised.
87+
if mysqlTemporalType(dataType), mysqlCurrentTimestampExpression(value, dataType: dataType) != nil {
88+
return value
89+
}
90+
if mysqlIsExpressionDefault(extra: extra) {
91+
let expression = mysqlUnescapedCatalogExpression(value)
92+
return expression.hasPrefix("(") ? expression : "(\(expression))"
93+
}
94+
return mysqlCatalogReportsLiteralAsSQL(dataType: dataType)
95+
? value : "'\(mysqlEscapeStringLiteral(value))'"
96+
}
97+
98+
/// MySQL's marker for an expression default, in `EXTRA` of both catalog reads.
99+
internal func mysqlIsExpressionDefault(extra: String?) -> Bool {
100+
extra?.uppercased().contains("DEFAULT_GENERATED") == true
101+
}
102+
103+
/// Whether a MySQL expression default can only be recreated from `SHOW CREATE TABLE`.
104+
///
105+
/// The catalog keeps an expression default escaped, which `mysqlUnescapedCatalogExpression` undoes
106+
/// exactly, and any non-ASCII text in it encoded twice, which nothing can undo: measured on 8.4.11,
107+
/// `concat('日','x')` comes back with `日` as `æ\u{97}¥`. `SHOW CREATE TABLE` prints it exactly, so an
108+
/// expression holding non-ASCII text is read from there and every other one from the catalog.
109+
internal func mysqlExpressionDefaultNeedsCreateTable(_ value: String?, extra: String?, dataType: String) -> Bool {
110+
guard mysqlIsExpressionDefault(extra: extra), let value else { return false }
111+
return !value.unicodeScalars.allSatisfy(\.isASCII)
112+
}
113+
114+
/// Whether a default a MariaDB reports bare may be an expression rather than the string it reads
115+
/// as. Before 10.2.7 its catalog quotes nothing, so `uuid()` and `'uuid()'` both come back `uuid()`.
116+
/// A default with no parenthesis in it cannot be an expression there, and `CURRENT_TIMESTAMP` on a
117+
/// temporal column reads right either way.
118+
internal func mariaDBBareDefaultMayBeExpression(_ value: String?, dataType: String) -> Bool {
119+
guard let value, value.contains("(") else { return false }
120+
return !(mysqlTemporalType(dataType) && mysqlCurrentTimestampExpression(value, dataType: dataType) != nil)
121+
}
122+
123+
/// An expression default as `SHOW CREATE TABLE` prints it, from the catalog's escaped copy, for a
124+
/// read that has no `SHOW CREATE TABLE` to take it from.
125+
///
126+
/// MySQL stores an expression default with every quote and backslash escaped by a backslash.
127+
/// Measured on 8.4.11, `DEFAULT (concat('a','b'))` comes back as `concat(_utf8mb4\'a\',_utf8mb4\'b\')`,
128+
/// which no statement accepts, so every later edit of the column failed. Undoing it is a pairwise
129+
/// scan in which a backslash takes the next character literally. The escaped form never holds a
130+
/// bare quote, so meeting one means the text was never escaped, and it is returned as it came.
131+
///
132+
/// Non-ASCII text is returned as it came too. The catalog encodes it twice, so unescaping it would
133+
/// produce a statement the server accepts with different text in it, and a default that changes
134+
/// without a word is worse than an edit the server refuses.
135+
internal func mysqlUnescapedCatalogExpression(_ value: String) -> String {
136+
guard value.unicodeScalars.allSatisfy(\.isASCII) else { return value }
137+
var result = ""
138+
var index = value.startIndex
139+
while index < value.endIndex {
140+
let character = value[index]
141+
if character == "'" { return value }
142+
let next = value.index(after: index)
143+
guard character == "\\" else {
144+
result.append(character)
145+
index = next
146+
continue
147+
}
148+
guard next < value.endIndex else { return value }
149+
result.append(value[next])
150+
index = value.index(after: next)
151+
}
152+
return result
153+
}
154+
155+
/// Whether this column type's catalog default is already the SQL that recreates it.
156+
///
157+
/// A string default comes back stripped of its quotes and has to be given them again. A number, a
158+
/// `BIT` default (`b'1'`) and a binary default (`0x61`) all come back as the literal they are, and
159+
/// quoting one changes what it means: `0x61` quoted stores the four characters rather than the byte.
160+
internal func mysqlCatalogReportsLiteralAsSQL(dataType: String) -> Bool {
161+
let base = dataType.uppercased().split(separator: "(", maxSplits: 1).first.map(String.init)?
162+
.trimmingCharacters(in: .whitespaces) ?? dataType.uppercased()
163+
switch base {
164+
case "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT",
165+
"DECIMAL", "DEC", "NUMERIC", "FIXED", "FLOAT", "DOUBLE", "REAL", "YEAR",
166+
"BIT", "BINARY", "VARBINARY", "BOOL", "BOOLEAN":
167+
return true
168+
default:
169+
return false
170+
}
171+
}

Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift

Lines changed: 6 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,14 @@ internal func mysqlWholeStringLiteral(_ value: String) -> String? {
100100
/// column's own fractional-second precision, because MySQL rejects the pair when they differ, and
101101
/// is the one expression MySQL accepts bare. Every other expression is parenthesised on MySQL,
102102
/// which is what its grammar requires from 8.0.13; MariaDB takes them either way and writes them
103-
/// bare itself. A type that cannot carry a bare default is parenthesised whatever the value is.
103+
/// bare itself. A type that cannot carry a bare default is parenthesised whatever the value is, with
104+
/// one exception: `NULL`. Every type takes it bare, and parenthesised it is no longer the literal:
105+
/// MySQL 8 stores `DEFAULT (NULL)` as an expression default and MySQL 5.7 refuses the syntax.
104106
///
105107
/// Nothing else is rewritten: the value already holds the SQL, and re-quoting it is what turned
106108
/// `(UUID())` into the six-character string `uuid()`.
107109
internal func mysqlDefaultValueLiteral(_ value: String, dataType: String, isMariaDB: Bool) -> String {
110+
if mysqlIsNullLiteral(value) { return "NULL" }
108111
if mysqlTemporalType(dataType), let expression = mysqlCurrentTimestampExpression(value, dataType: dataType) {
109112
return expression
110113
}
@@ -114,51 +117,8 @@ internal func mysqlDefaultValueLiteral(_ value: String, dataType: String, isMari
114117
return value.hasPrefix("(") ? value : "(\(value))"
115118
}
116119

117-
/// A column default as the catalog reports it, turned into the SQL that recreates it.
118-
///
119-
/// The two servers report it differently and neither says which it is in the value alone. MySQL
120-
/// leaves a literal bare and marks an expression `DEFAULT_GENERATED` in `EXTRA`. MariaDB from 10.2.7
121-
/// quotes literals and leaves expressions bare, with `EXTRA` empty; before that it quotes nothing,
122-
/// so it reads like MySQL without the marker and every default is a literal.
123-
internal func mysqlDefaultValueFromCatalog(
124-
_ value: String?,
125-
extra: String?,
126-
dataType: String,
127-
quotesLiterals: Bool
128-
) -> String? {
129-
guard let value else { return nil }
130-
if quotesLiterals { return value }
131-
132-
// MySQL 8.0.13 marks a plain `DEFAULT CURRENT_TIMESTAMP` DEFAULT_GENERATED like any other
133-
// expression, so this has to be answered before the marker is consulted or the one expression
134-
// MySQL insists on bare comes back parenthesised.
135-
if mysqlTemporalType(dataType), mysqlCurrentTimestampExpression(value, dataType: dataType) != nil {
136-
return value
137-
}
138-
139-
guard extra?.uppercased().contains("DEFAULT_GENERATED") != true else {
140-
return value.hasPrefix("(") ? value : "(\(value))"
141-
}
142-
return mysqlCatalogReportsLiteralAsSQL(dataType: dataType)
143-
? value : "'\(mysqlEscapeStringLiteral(value))'"
144-
}
145-
146-
/// Whether this column type's catalog default is already the SQL that recreates it.
147-
///
148-
/// A string default comes back stripped of its quotes and has to be given them again. A number, a
149-
/// `BIT` default (`b'1'`) and a binary default (`0x61`) all come back as the literal they are, and
150-
/// quoting one changes what it means: `0x61` quoted stores the four characters rather than the byte.
151-
internal func mysqlCatalogReportsLiteralAsSQL(dataType: String) -> Bool {
152-
let base = dataType.uppercased().split(separator: "(", maxSplits: 1).first.map(String.init)?
153-
.trimmingCharacters(in: .whitespaces) ?? dataType.uppercased()
154-
switch base {
155-
case "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT",
156-
"DECIMAL", "DEC", "NUMERIC", "FIXED", "FLOAT", "DOUBLE", "REAL", "YEAR",
157-
"BIT", "BINARY", "VARBINARY", "BOOL", "BOOLEAN":
158-
return true
159-
default:
160-
return false
161-
}
120+
internal func mysqlIsNullLiteral(_ value: String) -> Bool {
121+
value.trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("NULL") == .orderedSame
162122
}
163123

164124
/// The only types on which a bare `CURRENT_TIMESTAMP` is the temporal expression rather than the

Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,35 @@
66
import Foundation
77

88
internal enum MySQLCreateTableScanner {
9+
/// Each column's `DEFAULT` operand as `SHOW CREATE TABLE` spells it, keyed by column name, or nil
10+
/// when the statement does not create a table. A column with no `DEFAULT` clause is absent.
11+
///
12+
/// Read one definition per line, because OceanBase prints a `SET` or `ENUM` member list with its
13+
/// quotes unbalanced (`set('a','b'c')`), and a quote-aware split would run that column into the
14+
/// next. A quoted column name is the one thing every server quotes correctly, and it may hold a
15+
/// line break, so a line that opens a name without closing it runs on into the next: read on its
16+
/// own, the rest of that name would reach another column as its default.
17+
static func columnDefaultClauses(fromCreateTable sql: String) -> [String: String]? {
18+
let lines = sql.split(separator: "\n", omittingEmptySubsequences: false)
19+
guard let header = lines.first, declaresTable(header) else { return nil }
20+
var clauses: [String: String] = [:]
21+
var first = lines.index(after: lines.startIndex)
22+
while first < lines.endIndex {
23+
var last = first
24+
while last + 1 < lines.endIndex,
25+
opensUnclosedName(sql[lines[first].startIndex..<lines[last].endIndex]) {
26+
last += 1
27+
}
28+
var definition = sql[lines[first].startIndex..<lines[last].endIndex].drop(while: \.isWhitespace)
29+
first = last + 1
30+
guard let name = columnName(consumingFrom: &definition),
31+
let operand = defaultOperand(in: withoutTrailingSeparator(definition))
32+
else { continue }
33+
clauses[name] = operand
34+
}
35+
return clauses
36+
}
37+
938
static func firstGroup(in text: Substring) -> Substring? {
1039
var depth = 0
1140
var start: Substring.Index?
@@ -163,4 +192,52 @@ internal enum MySQLCreateTableScanner {
163192
}
164193
return marks
165194
}
195+
196+
private static func declaresTable(_ header: Substring) -> Bool {
197+
let words = header.prefix { $0 != "`" && $0 != "\"" && $0 != "(" }
198+
.split(whereSeparator: \.isWhitespace)
199+
.map { $0.uppercased() }
200+
guard words.first == "CREATE", let tableIndex = words.firstIndex(of: "TABLE") else { return false }
201+
return !words[..<tableIndex].contains("VIEW")
202+
}
203+
204+
private static func columnName(consumingFrom definition: inout Substring) -> String? {
205+
if let quote = definition.first, quote == "`" || quote == "\"" {
206+
return consumeQuotedName(from: &definition, quote: quote)
207+
}
208+
guard let first = definition.first, first.isLetter || first.isNumber || first == "_" || first == "$" else {
209+
return nil
210+
}
211+
let name = definition.prefix { !$0.isWhitespace }
212+
definition = definition.dropFirst(name.count)
213+
return String(name)
214+
}
215+
216+
private static func opensUnclosedName(_ text: Substring) -> Bool {
217+
var definition = text.drop(while: \.isWhitespace)
218+
guard let quote = definition.first, quote == "`" || quote == "\"" else { return false }
219+
return consumeQuotedName(from: &definition, quote: quote) == nil
220+
}
221+
222+
private static func withoutTrailingSeparator(_ definition: Substring) -> Substring {
223+
var trimmed = definition
224+
while let last = trimmed.last, last.isWhitespace {
225+
trimmed = trimmed.dropLast()
226+
}
227+
return trimmed.last == "," ? trimmed.dropLast() : trimmed
228+
}
229+
230+
private static func defaultOperand(in definition: Substring) -> String? {
231+
let tokens = topLevelTokens(of: definition)
232+
for (index, token) in tokens.enumerated() {
233+
let upper = token.uppercased()
234+
if upper == "DEFAULT" {
235+
return tokens.indices.contains(index + 1) ? String(tokens[index + 1]) : nil
236+
}
237+
if upper.hasPrefix("DEFAULT("), token.count > "DEFAULT".count {
238+
return String(token.dropFirst("DEFAULT".count))
239+
}
240+
}
241+
return nil
242+
}
166243
}

Plugins/MySQLDriverPlugin/MySQLPluginDriver+OceanBaseDefaults.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ internal extension MySQLPluginDriver {
3737
catalogDefault: String?,
3838
extra: String?,
3939
dataType: String,
40+
isNullable: Bool,
4041
column: String,
41-
createTableClauses: [String: String]?
42+
createTableClauses: [String: String]?,
43+
createTableDefaults: MySQLCreateTableDefaults?
4244
) -> String? {
4345
if flavor.isOceanBase, let catalogDefault {
4446
if let currentTimestamp = OceanBaseColumnDefaults.currentTimestampDefault(catalogDefault, dataType: dataType) {
@@ -60,8 +62,12 @@ internal extension MySQLPluginDriver {
6062
return binaryLiteral
6163
}
6264
}
63-
return mysqlDefaultValueFromCatalog(
64-
catalogDefault, extra: extra, dataType: dataType, quotesLiterals: catalogQuotesDefaults
65+
return mysqlColumnDefault(
66+
createTableDefaults?.catalogDefault(forColumn: column, extra: extra)
67+
?? (catalogQuotesDefaults ? .quoted(catalogDefault) : .bare(catalogDefault)),
68+
extra: extra,
69+
dataType: dataType,
70+
isNullable: isNullable
6571
)
6672
}
6773

@@ -79,6 +85,6 @@ internal extension MySQLPluginDriver {
7985
private func oceanbaseDefaultClauses(table: String, schema: String?) async throws -> [String: String] {
8086
let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table, schema: schema))")
8187
guard let createTable = result.rows.first?[safe: 1]?.asText else { return [:] }
82-
return OceanBaseColumnDefaults.defaultClauses(fromCreateTable: createTable) ?? [:]
88+
return MySQLCreateTableScanner.columnDefaultClauses(fromCreateTable: createTable) ?? [:]
8389
}
8490
}

0 commit comments

Comments
 (0)