|
| 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 | +} |
0 commit comments