Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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] = []
Expand Down
2 changes: 1 addition & 1 deletion Plugins/MongoDBDriverPlugin/MongoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down
22 changes: 22 additions & 0 deletions Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift
Original file line number Diff line number Diff line change
@@ -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<UInt32> = [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)
}
}
25 changes: 20 additions & 5 deletions Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
)
)
}
}

Expand Down Expand Up @@ -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
)
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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." : {

Expand Down Expand Up @@ -36591,6 +36594,9 @@
}
}
}
},
"Quit & Reopen" : {

},
"Quit Anyway" : {
"localizations" : {
Expand Down Expand Up @@ -37689,6 +37695,9 @@
},
"Relational" : {

},
"Relaunch Failed" : {

},
"Reload" : {
"localizations" : {
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
83 changes: 83 additions & 0 deletions TableProTests/Plugins/MariaDBFieldClassifierTests.swift
Original file line number Diff line number Diff line change
@@ -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
Loading