Skip to content

Commit d7de6c6

Browse files
authored
fix(plugin-mysql): name the database a catalog read means instead of using the session's (#2798)
Claude-Session: https://claude.ai/code/session_01SP8Cj5R28unz7YhwL2BtiM
1 parent 02e8c7e commit d7de6c6

11 files changed

Lines changed: 512 additions & 239 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5151

5252
### Fixed
5353

54+
- MySQL and MariaDB reads of an object in another database answering about the current database's same-named one, including the foreign key picker's column list. (#2769)
5455
- Select All painting the whole column header row as selected, and leaving a cell cursor on the first cell.
5556
- Column header shown as selected after a cell drag reached the first and last row of the page.
5657
- No outline around a swept cell block whose rows reached both ends of the page.

Plugins/MySQLDriverPlugin/MySQLObjectQueries.swift

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,47 @@
22
// MySQLObjectQueries.swift
33
// MySQLDriverPlugin
44
//
5-
// Catalog SQL for routines and triggers. Pure, so it is testable without a server.
5+
// Catalog SQL for routines and triggers, and the rule every catalog read shares for naming the
6+
// database it means. Pure, so it is testable without a server.
67
//
78

89
import Foundation
910

1011
public enum MySQLObjectQueries {
1112
public static func escapeLiteral(_ value: String) -> String {
12-
value
13-
.replacingOccurrences(of: "\\", with: "\\\\")
14-
.replacingOccurrences(of: "'", with: "''")
13+
mysqlEscapeStringLiteral(value)
1514
}
1615

1716
public static func quoteIdentifier(_ value: String) -> String {
1817
"`\(value.replacingOccurrences(of: "`", with: "``"))`"
1918
}
2019

20+
/// The database a catalog read means.
21+
///
22+
/// These engines have no schema layer, so every `schema:` the driver protocol hands them is a
23+
/// database name, and a caller that names none means the one the connection is already on. That
24+
/// fallback is the whole rule: an unqualified name resolves against the session's current
25+
/// database, so a read that drops the caller's schema silently answers about a same-named table
26+
/// somewhere else.
27+
public static func effectiveSchema(_ schema: String?, activeDatabase: String) -> String {
28+
guard let schema, !schema.isEmpty else { return activeDatabase }
29+
return schema
30+
}
31+
32+
/// Quoting is the caller's, not this file's: Databend answers the same protocol through the same
33+
/// driver and escapes a backtick-bearing name by switching to double quotes, so rendering one
34+
/// here with the MySQL quoter would corrupt it.
35+
public static func qualifiedIdentifier(
36+
schema: String?,
37+
name: String,
38+
quote: (String) -> String
39+
) -> String {
40+
guard let schema, !schema.isEmpty else { return quote(name) }
41+
return "\(quote(schema)).\(quote(name))"
42+
}
43+
2144
public static func qualifiedIdentifier(schema: String?, name: String) -> String {
22-
guard let schema, !schema.isEmpty else { return quoteIdentifier(name) }
23-
return "\(quoteIdentifier(schema)).\(quoteIdentifier(name))"
45+
qualifiedIdentifier(schema: schema, name: name, quote: quoteIdentifier)
2446
}
2547

2648
/// The parameter list comes from information_schema.PARAMETERS, where ordinal 0 is a function's

Plugins/MySQLDriverPlugin/MySQLPluginDriver+Databend.swift

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ extension MySQLPluginDriver {
1414
.cancelQuery,
1515
]
1616

17-
func databendColumns(table: String) async throws -> [PluginColumnInfo] {
18-
let result = try await execute(query: DatabendCatalog.columnsQuery(database: activeDatabaseName, table: table))
17+
func databendColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
18+
let query = DatabendCatalog.columnsQuery(database: effectiveSchema(schema), table: table)
19+
let result = try await execute(query: query)
1920
return result.rows.compactMap { DatabendCatalog.column(from: $0) }
2021
}
2122

22-
func databendAllColumns() async throws -> [String: [PluginColumnInfo]] {
23-
let result = try await execute(query: DatabendCatalog.allColumnsQuery(database: activeDatabaseName))
23+
func databendAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] {
24+
let result = try await execute(query: DatabendCatalog.allColumnsQuery(database: effectiveSchema(schema)))
2425
var columns: [String: [PluginColumnInfo]] = [:]
2526
for row in result.rows {
2627
guard let table = row[safe: 0]?.asText,
@@ -30,8 +31,8 @@ extension MySQLPluginDriver {
3031
return columns
3132
}
3233

33-
func databendCheckConstraints(table: String) async throws -> [PluginCheckConstraintInfo] {
34-
let query = DatabendCatalog.checkConstraintsQuery(database: activeDatabaseName, table: table)
34+
func databendCheckConstraints(table: String, schema: String?) async throws -> [PluginCheckConstraintInfo] {
35+
let query = DatabendCatalog.checkConstraintsQuery(database: effectiveSchema(schema), table: table)
3536
let result = try await execute(query: query)
3637
return result.rows.compactMap { row in
3738
guard let name = row[safe: 0]?.asText,
@@ -40,16 +41,16 @@ extension MySQLPluginDriver {
4041
}
4142
}
4243

43-
func databendViewDefinition(view: String) async throws -> String {
44-
let result = try await execute(query: "SHOW CREATE TABLE \(quoteIdentifier(view))")
44+
func databendViewDefinition(view: String, schema: String?) async throws -> String {
45+
let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(view, schema: schema))")
4546
guard let definition = result.rows.first?[safe: 1]?.asText else {
4647
throw MariaDBPluginError(code: 0, message: "Failed to fetch definition for view '\(view)'", sqlState: nil)
4748
}
4849
return definition
4950
}
5051

51-
func databendTableMetadata(table: String) async throws -> PluginTableMetadata {
52-
let query = DatabendCatalog.tableMetadataQuery(database: activeDatabaseName, table: table)
52+
func databendTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata {
53+
let query = DatabendCatalog.tableMetadataQuery(database: effectiveSchema(schema), table: table)
5354
let result = try await execute(query: query)
5455
guard let row = result.rows.first, let metadata = DatabendCatalog.tableMetadata(from: row) else {
5556
return PluginTableMetadata(tableName: table)

Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ extension MySQLPluginDriver {
6464
return flavor.killTarget(connectionIdentifier: identifier)
6565
}
6666

67-
func tidbCheckConstraints(table: String) async throws -> [PluginCheckConstraintInfo] {
68-
let result = try await execute(query: "SHOW CREATE TABLE \(quoteIdentifier(table))")
67+
func tidbCheckConstraints(table: String, schema: String?) async throws -> [PluginCheckConstraintInfo] {
68+
let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table, schema: schema))")
6969
guard let createTable = result.rows.first?[safe: 1]?.asText else { return [] }
7070
return TiDBCheckConstraints.parse(createTable: createTable)
7171
}

Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,6 @@ extension MySQLPluginDriver {
125125
}
126126

127127
func routineSchema(_ schema: String?) -> String {
128-
guard let schema, !schema.isEmpty else { return activeDatabaseName }
129-
return schema
128+
effectiveSchema(schema)
130129
}
131130
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
//
2+
// MySQLPluginDriver+Schema.swift
3+
// MySQLDriverPlugin
4+
//
5+
// The column reads, and the rule they share for naming the database they mean.
6+
//
7+
8+
import Foundation
9+
import TableProPluginKit
10+
11+
internal extension MySQLPluginDriver {
12+
func effectiveSchema(_ schema: String?) -> String {
13+
MySQLObjectQueries.effectiveSchema(schema, activeDatabase: activeDatabaseName)
14+
}
15+
16+
/// The same answer as a literal, for a catalog query that filters on a `TABLE_SCHEMA` column
17+
/// rather than naming the object.
18+
func effectiveSchemaLiteral(_ schema: String?) -> String {
19+
mysqlEscapeStringLiteral(effectiveSchema(schema))
20+
}
21+
22+
/// An object name qualified by the database the caller meant.
23+
///
24+
/// A connection with no database selected has nothing to qualify against, and an unqualified
25+
/// name is then the only form the server will take, so an empty answer falls back to the bare
26+
/// name rather than rendering an empty qualifier.
27+
func qualifiedName(_ name: String, schema: String?) -> String {
28+
MySQLObjectQueries.qualifiedIdentifier(
29+
schema: effectiveSchema(schema).nilIfEmpty,
30+
name: name,
31+
quote: quoteIdentifier
32+
)
33+
}
34+
35+
/// `SHOW TABLE STATUS` is the one statement here that cannot take a dotted name: its grammar
36+
/// puts the database in a `FROM` clause of its own, and the `WHERE` then matches the bare name.
37+
func showTableStatus(matching escapedTable: String, schema: String?) -> String {
38+
let database = effectiveSchema(schema).nilIfEmpty
39+
let from = database.map { " FROM \(quoteIdentifier($0))" } ?? ""
40+
return "SHOW TABLE STATUS\(from) WHERE Name = '\(escapedTable)'"
41+
}
42+
43+
func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
44+
guard !flavor.isDatabend else {
45+
return try await databendColumns(table: table, schema: schema)
46+
}
47+
let result = try await execute(query: "SHOW FULL COLUMNS FROM \(qualifiedName(table, schema: schema))")
48+
let generationExpressions = try await fetchGenerationExpressions(table: table, schema: schema)
49+
50+
return result.rows.compactMap { row in
51+
guard let name = row[safe: 0]?.asText,
52+
let dataType = row[safe: 1]?.asText
53+
else { return nil }
54+
55+
let collation = row[safe: 2]?.asText
56+
let isNullable = (row[safe: 3]?.asText) == "YES"
57+
let isPrimaryKey = (row[safe: 4]?.asText) == "PRI"
58+
let rawDefault = row[safe: 5]?.asText
59+
let extra = row[safe: 6]?.asText
60+
let comment = row[safe: 8]?.asText
61+
62+
let charset: String? = {
63+
guard let coll = collation, coll != "NULL" else { return nil }
64+
return coll.components(separatedBy: "_").first
65+
}()
66+
67+
let upperType = dataType.uppercased()
68+
let normalizedType = (upperType.hasPrefix("ENUM(") || upperType.hasPrefix("SET("))
69+
? dataType : upperType
70+
let allowedValues = EnumValueParser.parseMySQLEnumOrSet(from: normalizedType)
71+
let defaultValue = mysqlDefaultValueFromCatalog(
72+
rawDefault, extra: extra, dataType: normalizedType, quotesLiterals: catalogQuotesDefaults
73+
)
74+
75+
return PluginColumnInfo(
76+
name: name,
77+
dataType: normalizedType,
78+
isNullable: isNullable,
79+
isPrimaryKey: isPrimaryKey,
80+
defaultValue: defaultValue,
81+
extra: extra,
82+
charset: charset,
83+
collation: collation == "NULL" ? nil : collation,
84+
comment: comment?.isEmpty == false ? comment : nil,
85+
identityKind: mysqlIdentityKind(extra: extra),
86+
isGenerated: mysqlColumnIsGenerated(extra: extra),
87+
allowedValues: allowedValues,
88+
generationExpression: generationExpressions[name],
89+
generationKind: mysqlGenerationKind(extra: extra)
90+
)
91+
}
92+
}
93+
94+
/// Merged into the `SHOW FULL COLUMNS` rows by column name alone, so this has to read the same
95+
/// database that statement did. Reading the session's instead grafts one table's generation
96+
/// expressions onto another's columns wherever the two databases share a column name.
97+
private func fetchGenerationExpressions(table: String, schema: String?) async throws -> [String: String] {
98+
guard MySQLServerVersion.hasGenerationExpression(banner: _serverVersion, flavor: flavor) else {
99+
return [:]
100+
}
101+
let query = """
102+
SELECT COLUMN_NAME, GENERATION_EXPRESSION
103+
FROM INFORMATION_SCHEMA.COLUMNS
104+
WHERE TABLE_SCHEMA = \'\(effectiveSchemaLiteral(schema))\'
105+
AND TABLE_NAME = \'\(mysqlEscapeStringLiteral(table))\'
106+
AND GENERATION_EXPRESSION <> \'\'
107+
"""
108+
let result = try await execute(query: query)
109+
var expressions: [String: String] = [:]
110+
for row in result.rows {
111+
guard let name = row[safe: 0]?.asText,
112+
let expression = row[safe: 1]?.asText?.nilIfEmpty else { continue }
113+
expressions[name] = expression
114+
}
115+
return expressions
116+
}
117+
118+
/// MySQL and MariaDB disagree on this catalog: MySQL 8 has no TABLE_NAME on CHECK_CONSTRAINTS
119+
/// and must join TABLE_CONSTRAINTS to find the owning table, while MariaDB carries TABLE_NAME
120+
/// directly. Neither exposes the columns a check touches, so `columns` stays empty rather than
121+
/// being guessed from the expression.
122+
func fetchCheckConstraints(table: String, schema: String?) async throws -> [PluginCheckConstraintInfo] {
123+
let flavor = self.flavor
124+
guard !flavor.isDatabend else {
125+
return try await databendCheckConstraints(table: table, schema: schema)
126+
}
127+
guard MySQLServerVersion.hasCheckConstraints(banner: _serverVersion, flavor: flavor) else {
128+
return []
129+
}
130+
guard !flavor.isTiDB else { return try await tidbCheckConstraints(table: table, schema: schema) }
131+
let database = effectiveSchemaLiteral(schema)
132+
let safeTable = mysqlEscapeStringLiteral(table)
133+
let query: String
134+
if flavor.isMariaDB {
135+
query = """
136+
SELECT CONSTRAINT_NAME, CHECK_CLAUSE
137+
FROM INFORMATION_SCHEMA.CHECK_CONSTRAINTS
138+
WHERE CONSTRAINT_SCHEMA = \'\(database)\' AND TABLE_NAME = \'\(safeTable)\'
139+
ORDER BY CONSTRAINT_NAME
140+
"""
141+
} else {
142+
query = """
143+
SELECT cc.CONSTRAINT_NAME, cc.CHECK_CLAUSE
144+
FROM INFORMATION_SCHEMA.CHECK_CONSTRAINTS cc
145+
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
146+
ON tc.CONSTRAINT_SCHEMA = cc.CONSTRAINT_SCHEMA
147+
AND tc.CONSTRAINT_NAME = cc.CONSTRAINT_NAME
148+
WHERE cc.CONSTRAINT_SCHEMA = \'\(database)\' AND tc.TABLE_NAME = \'\(safeTable)\'
149+
ORDER BY cc.CONSTRAINT_NAME
150+
"""
151+
}
152+
let result = try await execute(query: query)
153+
return result.rows.compactMap { row in
154+
guard let name = row[safe: 0]?.asText,
155+
let clause = row[safe: 1]?.asText else { return nil }
156+
return PluginCheckConstraintInfo(name: name, expression: clause)
157+
}
158+
}
159+
160+
var providesBulkColumnFetch: Bool { true }
161+
162+
/// `GENERATION_EXPRESSION` is projected here rather than looked up per table, because a caller
163+
/// that takes the bulk list has to receive what `fetchColumns` would have given it. Without the
164+
/// column the two reads disagree on generated columns alone, and a schema comparison built on
165+
/// the bulk read reports a changed generation expression as no difference at all.
166+
func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] {
167+
guard !flavor.isDatabend else { return try await databendAllColumns(schema: schema) }
168+
let escapedDb = effectiveSchemaLiteral(schema)
169+
let hasGenerationExpression = MySQLServerVersion.hasGenerationExpression(
170+
banner: _serverVersion, flavor: flavor
171+
)
172+
let generationProjection = hasGenerationExpression ? "GENERATION_EXPRESSION" : "NULL"
173+
let query = """
174+
SELECT
175+
TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLLATION_NAME,
176+
IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT,
177+
\(generationProjection)
178+
FROM INFORMATION_SCHEMA.COLUMNS
179+
WHERE TABLE_SCHEMA = '\(escapedDb)'
180+
ORDER BY TABLE_NAME, ORDINAL_POSITION
181+
"""
182+
183+
let result = try await execute(query: query)
184+
185+
var allColumns: [String: [PluginColumnInfo]] = [:]
186+
for row in result.rows {
187+
guard let tableName = row[safe: 0]?.asText,
188+
let name = row[safe: 1]?.asText,
189+
let dataType = row[safe: 2]?.asText
190+
else { continue }
191+
192+
let collation = row[safe: 3]?.asText
193+
let isNullable = (row[safe: 4]?.asText) == "YES"
194+
let isPrimaryKey = (row[safe: 5]?.asText) == "PRI"
195+
let rawDefault = row[safe: 6]?.asText
196+
let extra = row[safe: 7]?.asText
197+
let comment = row[safe: 8]?.asText
198+
199+
let charset: String? = {
200+
guard let coll = collation, coll != "NULL" else { return nil }
201+
return coll.components(separatedBy: "_").first
202+
}()
203+
204+
let upperType = dataType.uppercased()
205+
let normalizedType = (upperType.hasPrefix("ENUM(") || upperType.hasPrefix("SET("))
206+
? dataType : upperType
207+
let allowedValues = EnumValueParser.parseMySQLEnumOrSet(from: normalizedType)
208+
let defaultValue = mysqlDefaultValueFromCatalog(
209+
rawDefault, extra: extra, dataType: normalizedType, quotesLiterals: catalogQuotesDefaults
210+
)
211+
212+
let column = PluginColumnInfo(
213+
name: name,
214+
dataType: normalizedType,
215+
isNullable: isNullable,
216+
isPrimaryKey: isPrimaryKey,
217+
defaultValue: defaultValue,
218+
extra: extra,
219+
charset: charset,
220+
collation: collation == "NULL" ? nil : collation,
221+
comment: comment?.isEmpty == false ? comment : nil,
222+
identityKind: mysqlIdentityKind(extra: extra),
223+
isGenerated: mysqlColumnIsGenerated(extra: extra),
224+
allowedValues: allowedValues,
225+
generationExpression: row[safe: 9]?.asText?.nilIfEmpty,
226+
generationKind: mysqlGenerationKind(extra: extra)
227+
)
228+
229+
allColumns[tableName, default: []].append(column)
230+
}
231+
232+
return allColumns
233+
}
234+
}

0 commit comments

Comments
 (0)