From 0c73b4c37bf5ea23338c623eee15123149daa6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 11 May 2026 13:24:25 +0700 Subject: [PATCH] fix(plugins): MySQL numeric column rendering and Query cancelled alert race --- CHANGELOG.md | 1 + Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 4 +- .../MongoDBConnection.swift | 2 +- .../MariaDBFieldClassifier.swift | 22 +++++ .../MariaDBPluginConnection.swift | 25 ++++-- ...yExecutionCoordinator+MultiStatement.swift | 4 +- ...QueryExecutionCoordinator+Parameters.swift | 1 + TablePro/Resources/Localizable.xcstrings | 9 ++ .../Views/Main/MainContentCoordinator.swift | 1 + .../Plugins/MariaDBFieldClassifierTests.swift | 83 +++++++++++++++++++ 10 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift create mode 100644 TableProTests/Plugins/MariaDBFieldClassifierTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5341b53..7cfc803fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Plugin manager's `waitForInitialLoad` no longer leaks a continuation when its 10-second timeout fires before initial load completes - Multi-bundle ZIP archives are now rejected with a clear error instead of silently installing only the last bundle - MySQL/MariaDB: numeric, decimal, date and time columns display their values again instead of rendering as hex bytes (e.g. `1` instead of `0x31`, `12.50` instead of `0x31322E3530`). The typed `PluginCellValue` refactor in #1190 routed any column with charset 63 to the binary path, but MariaDB reports charset 63 for every non-character type. The binary check now also requires a BLOB or BINARY/VARBINARY field type +- "Query Execution Failed: Query cancelled" alert no longer appears when a query is cancelled by tab supersession, refresh, or teardown. The MySQL, MSSQL, and MongoDB drivers now throw the standard `CancellationError`, and the query executor's catch path filters cancellation errors before surfacing them - Structure tab: double-click and Return on dropdown / type-picker columns (Nullable, Primary Key, Auto Increment, Unique, On Delete, On Update, Type) now enter inline text edit so the user can type a value the picker doesn't list (e.g. a custom column type, a numeric default). Previously these gestures appeared to do nothing because `editEligibility` blocked dropdown/type-picker columns from inline edit. The chevron button continues to open the picker; the cell body opens the text editor. - Structure tab: pressing Cmd+Shift+N (or any path that adds a column / index / foreign key while changes already exist) now displays the new row in the grid. Previously the row was added to the change manager and the SQL Preview reflected it, but the grid kept the old row count because `TableStructureView` only observed `hasChanges` (which had already been true). The view now also observes `reloadVersion` and re-evaluates the grid snapshot on every mutation. - Structure tab: pressing Delete on a row now turns the entire row red, not just the focused cell. `StructureRowViewWithMenu` inherits from `DataGridRowView` so it gets the deleted-row tint, layer-backing optimization, and the cell-emphasis invalidation that all data-tab rows already had. The grid's `tableView(_:rowViewForRow:)` also now applies the visual state to delegate-provided row views, so recycled rows pick up state changes. `StructureGridDelegate.dataGridDeleteRows` calls `tableView.reloadData(forRowIndexes:columnIndexes:)` for visible rows so the soft-deleted row repaints (existing-column deletes leave the row in place with a red tint, so row count alone doesn't trigger a reload). diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 5d4e69084..b7e918388 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -329,7 +329,7 @@ private final class FreeTDSConnection: @unchecked Sendable { if cancelledBetweenResults { _isCancelled = false } lock.unlock() if cancelledBetweenResults { - throw MSSQLPluginError.queryFailed("Query cancelled") + throw CancellationError() } let resCode = dbresults(proc) @@ -367,7 +367,7 @@ private final class FreeTDSConnection: @unchecked Sendable { if cancelled { _isCancelled = false } lock.unlock() if cancelled { - throw MSSQLPluginError.queryFailed("Query cancelled") + throw CancellationError() } var row: [PluginCellValue] = [] diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index 06d6568c8..1e3dac335 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -368,7 +368,7 @@ final class MongoDBConnection: @unchecked Sendable { if cancelled { _isCancelled = false } stateLock.unlock() if cancelled { - throw MongoDBError(code: 0, message: String(localized: "Query cancelled")) + throw CancellationError() } } diff --git a/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift b/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift new file mode 100644 index 000000000..52a6f9fc6 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift @@ -0,0 +1,22 @@ +// +// MariaDBFieldClassifier.swift +// MySQLDriverPlugin +// + +import Foundation + +internal enum MariaDBFieldClassifier { + private static let bitType: UInt32 = 16 + private static let binaryCharset: UInt32 = 63 + private static let blobOrStringTypes: Set = [249, 250, 251, 252, 253, 254] + + static func isBinary(typeRaw: UInt32, charset: UInt32) -> Bool { + if typeRaw == bitType { + return true + } + guard charset == binaryCharset else { + return false + } + return blobOrStringTypes.contains(typeRaw) + } +} diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index bd1a76f8f..8ec6668be 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -462,7 +462,12 @@ final class MariaDBPluginConnection: @unchecked Sendable { if (fieldFlags & mysqlSetFlag) != 0 { fieldType = 248 } columnTypes.append(fieldType) columnTypeNames.append(mysqlTypeToString(fields + i)) - columnIsBinary.append(field.charsetnr == mysqlBinaryCharset) + columnIsBinary.append( + MariaDBFieldClassifier.isBinary( + typeRaw: field.type.rawValue, + charset: field.charsetnr + ) + ) } } @@ -488,7 +493,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { sqlState: nil) } mysql_free_result(resultPtr) - throw MariaDBPluginError(code: 0, message: "Query cancelled", sqlState: nil) + throw CancellationError() } if rows.count >= maxRows { @@ -672,7 +677,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { if shouldCancel { _isCancelled = false } stateLock.unlock() if shouldCancel { - throw MariaDBPluginError(code: 0, message: "Query cancelled", sqlState: nil) + throw CancellationError() } if rows.count >= maxRows { @@ -808,7 +813,12 @@ final class MariaDBPluginConnection: @unchecked Sendable { if (fieldFlags & mysqlSetFlag) != 0 { fieldType = 248 } columnTypes.append(fieldType) columnTypeNames.append(mysqlTypeToString(fields + i)) - columnIsBinary.append(field.charsetnr == mysqlBinaryCharset) + columnIsBinary.append( + MariaDBFieldClassifier.isBinary( + typeRaw: field.type.rawValue, + charset: field.charsetnr + ) + ) } } @@ -907,7 +917,12 @@ final class MariaDBPluginConnection: @unchecked Sendable { if (fieldFlags & mysqlSetFlag) != 0 { fieldType = 248 } columnTypes.append(fieldType) columnTypeNames.append(mysqlTypeToString(fields + i)) - columnIsBinary.append(field.charsetnr == mysqlBinaryCharset) + columnIsBinary.append( + MariaDBFieldClassifier.isBinary( + typeRaw: field.type.rawValue, + charset: field.charsetnr + ) + ) } } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index 923726bf7..952507f1b 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -137,7 +137,9 @@ extension QueryExecutionCoordinator { } } - if capturedGeneration != parent.queryGeneration { + if capturedGeneration != parent.queryGeneration + || error is CancellationError + || Task.isCancelled { await MainActor.run { [weak self] in guard let self else { return } parent.tabManager.mutate(tabId: tabId) { $0.execution.isExecuting = false } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index 648aaa97c..42d57679f 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -162,6 +162,7 @@ extension QueryExecutionCoordinator { } parent.currentQueryTask = nil parent.toolbarState.setExecuting(false) + if error is CancellationError || Task.isCancelled { return } guard capturedGeneration == parent.queryGeneration else { return } handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 31b9b2917..7bbf6c726 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -12852,6 +12852,9 @@ }, "Could not save the connection. Check disk space and permissions, then try again." : { + }, + "Could not start a new TablePro instance. Quit and reopen manually." : { + }, "Could not write to file. Check that the file is writable." : { @@ -36591,6 +36594,9 @@ } } } + }, + "Quit & Reopen" : { + }, "Quit Anyway" : { "localizations" : { @@ -37689,6 +37695,9 @@ }, "Relational" : { + }, + "Relaunch Failed" : { + }, "Reload" : { "localizations" : { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index fb8668322..fcc0b691a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1113,6 +1113,7 @@ final class MainContentCoordinator { } currentQueryTask = nil toolbarState.setExecuting(false) + if error is CancellationError || Task.isCancelled { return } guard capturedGeneration == queryGeneration else { return } handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn) } diff --git a/TableProTests/Plugins/MariaDBFieldClassifierTests.swift b/TableProTests/Plugins/MariaDBFieldClassifierTests.swift new file mode 100644 index 000000000..4a045bb68 --- /dev/null +++ b/TableProTests/Plugins/MariaDBFieldClassifierTests.swift @@ -0,0 +1,83 @@ +// +// MariaDBFieldClassifierTests.swift +// TableProTests +// + +#if canImport(MySQLDriverPlugin) +import Testing + +@testable import MySQLDriverPlugin + +@Suite("MariaDBFieldClassifier") +struct MariaDBFieldClassifierTests { + @Test("BIT routes to binary regardless of charset") + func bitIsBinary() { + #expect(MariaDBFieldClassifier.isBinary(typeRaw: 16, charset: 63)) + #expect(MariaDBFieldClassifier.isBinary(typeRaw: 16, charset: 33)) + } + + @Test("BLOB family with binary charset routes to binary") + func blobFamilyBinary() { + for typeRaw: UInt32 in [249, 250, 251, 252] { + #expect(MariaDBFieldClassifier.isBinary(typeRaw: typeRaw, charset: 63)) + } + } + + @Test("VAR_STRING and STRING route to binary only with charset 63") + func varStringBinaryOnlyWithBinaryCharset() { + #expect(MariaDBFieldClassifier.isBinary(typeRaw: 253, charset: 63)) + #expect(MariaDBFieldClassifier.isBinary(typeRaw: 254, charset: 63)) + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 253, charset: 33)) + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 254, charset: 255)) + } + + @Test("TEXT family with non-binary charset routes to text") + func textFamilyIsText() { + for typeRaw: UInt32 in [249, 250, 251, 252] { + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: typeRaw, charset: 33)) + } + } + + @Test("Numeric types never route to binary even with binary charset") + func numericTypesNeverBinary() { + let numericTypes: [UInt32] = [ + 0, // DECIMAL + 1, // TINY + 2, // SHORT + 3, // LONG (INT) + 4, // FLOAT + 5, // DOUBLE + 8, // LONGLONG (BIGINT) + 9, // INT24 (MEDIUMINT) + 246 // NEWDECIMAL + ] + for typeRaw in numericTypes { + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: typeRaw, charset: 63)) + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: typeRaw, charset: 33)) + } + } + + @Test("Temporal types never route to binary") + func temporalTypesNeverBinary() { + let temporalTypes: [UInt32] = [ + 7, // TIMESTAMP + 10, // DATE + 11, // TIME + 12, // DATETIME + 13, // YEAR + 14 // NEWDATE + ] + for typeRaw in temporalTypes { + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: typeRaw, charset: 63)) + } + } + + @Test("JSON, ENUM, SET, GEOMETRY route to text") + func miscTextTypes() { + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 245, charset: 63)) // JSON + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 247, charset: 33)) // ENUM + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 248, charset: 33)) // SET + #expect(!MariaDBFieldClassifier.isBinary(typeRaw: 255, charset: 63)) // GEOMETRY (handled upstream) + } +} +#endif