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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Query results appear as soon as the database returns rows. Column defaults, foreign keys, and row counts now load in the background instead of holding up the grid, which removes a multi-second wait on remote databases whose system tables are slow to query. (#1574)
- MySQL and MariaDB queries are ready to edit right away. Primary key and nullability come back with the rows, so an editable query no longer waits on a separate metadata query. (#1574)
- Pagination and other status bar buttons no longer get blocked by the window resize zone in the bottom-right corner. (#1569)
- VoiceOver now reads clear labels for the Columns button, Filters toggle, and loading indicators in the results status bar. (#1569)
- The custom rows-per-page popover now points at the page-size menu instead of the center of the pagination controls. (#1569)
Expand Down
51 changes: 35 additions & 16 deletions Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,26 @@
import TableProPluginKit

// MySQL/MariaDB field flag and charset constants
internal let mysqlNotNullFlag: UInt = 0x0001
internal let mysqlPriKeyFlag: UInt = 0x0002
internal let mysqlBinaryFlag: UInt = 0x0080
internal let mysqlEnumFlag: UInt = 0x0100
internal let mysqlAutoIncrementFlag: UInt = 0x0200
internal let mysqlSetFlag: UInt = 0x0800
internal let mysqlBinaryCharset: UInt32 = 63

private let logger = Logger(subsystem: "com.TablePro", category: "MariaDBPluginConnection")

internal func makeColumnMeta(name: String, typeName: String, flags: UInt) -> PluginColumnInfo {
PluginColumnInfo(
name: name,
dataType: typeName,
isNullable: (flags & mysqlNotNullFlag) == 0,
isPrimaryKey: (flags & mysqlPriKeyFlag) != 0,
identityKind: (flags & mysqlAutoIncrementFlag) != 0 ? .byDefault : nil
)
}

// MARK: - Error Types

struct MariaDBPluginError: Error {
Expand All @@ -44,6 +57,7 @@
let affectedRows: UInt64
let insertId: UInt64
let isTruncated: Bool
let columnMeta: [PluginColumnInfo]
}

// MARK: - SSL Configuration
Expand All @@ -53,7 +67,7 @@
func mysqlTypeToString(_ fieldPtr: UnsafePointer<MYSQL_FIELD>) -> String {
let field = fieldPtr.pointee
let flags = UInt(field.flags)
let length = field.length

Check warning on line 70 in Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

initialization of immutable value 'length' was never used; consider replacing with assignment to '_' or removing it

// MariaDB extended metadata: detect JSON stored as LONGTEXT.
// `MARIADB_CONST_STRING` is length-prefixed (not null-terminated), so we must read
Expand Down Expand Up @@ -461,7 +475,8 @@
let insertId = mysql_insert_id(mysql)
return MariaDBPluginQueryResult(
columns: [], columnTypes: [], columnTypeNames: [],
rows: [], affectedRows: affected, insertId: insertId, isTruncated: false
rows: [], affectedRows: affected, insertId: insertId, isTruncated: false,
columnMeta: []
)
} else {
throw self.getError()
Expand All @@ -473,31 +488,32 @@
var columnTypes: [UInt32] = []
var columnTypeNames: [String] = []
var columnIsBinary: [Bool] = []
var columnMeta: [PluginColumnInfo] = []
columns.reserveCapacity(numFields)
columnTypes.reserveCapacity(numFields)
columnTypeNames.reserveCapacity(numFields)
columnIsBinary.reserveCapacity(numFields)
columnMeta.reserveCapacity(numFields)

if let fields = mysql_fetch_fields(resultPtr) {
for i in 0..<numFields {
let field = fields[i]
if let namePtr = field.name {
columns.append(String(cString: namePtr))
} else {
columns.append("column_\(i)")
}
let columnName = field.name.map { String(cString: $0) } ?? "column_\(i)"
columns.append(columnName)
let fieldFlags = UInt(field.flags)
var fieldType = field.type.rawValue
if (fieldFlags & mysqlEnumFlag) != 0 { fieldType = 247 }
if (fieldFlags & mysqlSetFlag) != 0 { fieldType = 248 }
columnTypes.append(fieldType)
columnTypeNames.append(mysqlTypeToString(fields + i))
let typeName = mysqlTypeToString(fields + i)
columnTypeNames.append(typeName)
columnIsBinary.append(
MariaDBFieldClassifier.isBinary(
typeRaw: field.type.rawValue,
charset: field.charsetnr
)
)
columnMeta.append(makeColumnMeta(name: columnName, typeName: typeName, flags: fieldFlags))
}
}

Expand Down Expand Up @@ -576,7 +592,8 @@

return MariaDBPluginQueryResult(
columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames,
rows: rows, affectedRows: UInt64(rows.count), insertId: 0, isTruncated: truncated
rows: rows, affectedRows: UInt64(rows.count), insertId: 0, isTruncated: truncated,
columnMeta: columnMeta
)
}

Expand Down Expand Up @@ -815,7 +832,8 @@
let insertId = mysql_stmt_insert_id(stmt)
return MariaDBPluginQueryResult(
columns: [], columnTypes: [], columnTypeNames: [],
rows: [], affectedRows: UInt64(affected), insertId: UInt64(insertId), isTruncated: false
rows: [], affectedRows: UInt64(affected), insertId: UInt64(insertId), isTruncated: false,
columnMeta: []
)
}

Expand All @@ -831,28 +849,28 @@
var columnTypes: [UInt32] = []
var columnTypeNames: [String] = []
var columnIsBinary: [Bool] = []
var columnMeta: [PluginColumnInfo] = []
let numFields = Int(mysql_num_fields(metadata))

if let fields = mysql_fetch_fields(metadata) {
for i in 0..<numFields {
let field = fields[i]
if let namePtr = field.name {
columns.append(String(cString: namePtr))
} else {
columns.append("column_\(i)")
}
let columnName = field.name.map { String(cString: $0) } ?? "column_\(i)"
columns.append(columnName)
let fieldFlags = UInt(field.flags)
var fieldType = field.type.rawValue
if (fieldFlags & mysqlEnumFlag) != 0 { fieldType = 247 }
if (fieldFlags & mysqlSetFlag) != 0 { fieldType = 248 }
columnTypes.append(fieldType)
columnTypeNames.append(mysqlTypeToString(fields + i))
let typeName = mysqlTypeToString(fields + i)
columnTypeNames.append(typeName)
columnIsBinary.append(
MariaDBFieldClassifier.isBinary(
typeRaw: field.type.rawValue,
charset: field.charsetnr
)
)
columnMeta.append(makeColumnMeta(name: columnName, typeName: typeName, flags: fieldFlags))
}
}

Expand All @@ -865,7 +883,8 @@
return MariaDBPluginQueryResult(
columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames,
rows: fetchResult.rows, affectedRows: UInt64(fetchResult.rows.count),
insertId: 0, isTruncated: fetchResult.isTruncated
insertId: 0, isTruncated: fetchResult.isTruncated,
columnMeta: columnMeta
)
}

Expand Down
7 changes: 4 additions & 3 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
rows: result.rows,
rowsAffected: Int(result.affectedRows),
executionTime: Date().timeIntervalSince(startTime),
isTruncated: result.isTruncated
isTruncated: result.isTruncated,
columnMeta: result.columnMeta
)
}

Expand Down Expand Up @@ -165,7 +166,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
rows: result.rows,
rowsAffected: Int(result.affectedRows),
executionTime: Date().timeIntervalSince(startTime),
isTruncated: result.isTruncated
isTruncated: result.isTruncated,
columnMeta: result.columnMeta
)
} catch let error as MariaDBPluginError where !isRetry && isConnectionLostError(error) {
try await reconnect()
Expand Down Expand Up @@ -962,5 +964,4 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
return columns
}

}
5 changes: 4 additions & 1 deletion Plugins/TableProPluginKit/PluginQueryResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public struct PluginQueryResult: Codable, Sendable {
public let executionTime: TimeInterval
public let isTruncated: Bool
public let statusMessage: String?
public let columnMeta: [PluginColumnInfo]?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bump PluginKit for the PluginQueryResult ABI change

For user-installed drivers built against PluginKit 18 before this change, the version gate still accepts them (currentPluginKitVersion and minimumCompatiblePluginKitVersion remain 18), but their binary was compiled against the old PluginQueryResult layout/initializer. Adding this stored property changes the Swift ABI, so those plugins can fail when returning query results instead of being rejected with an update message; please bump the PluginKit version or add a compatibility wrapper with this ABI change.

Useful? React with 👍 / 👎.


public init(
columns: [String],
Expand All @@ -16,7 +17,8 @@ public struct PluginQueryResult: Codable, Sendable {
rowsAffected: Int,
executionTime: TimeInterval,
isTruncated: Bool = false,
statusMessage: String? = nil
statusMessage: String? = nil,
columnMeta: [PluginColumnInfo]? = nil
) {
self.columns = columns
self.columnTypeNames = columnTypeNames
Expand All @@ -25,6 +27,7 @@ public struct PluginQueryResult: Codable, Sendable {
self.executionTime = executionTime
self.isTruncated = isTruncated
self.statusMessage = statusMessage
self.columnMeta = columnMeta
}

public static let empty = PluginQueryResult(
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ final class DataChangeManager: ChangeManaging {
}
}

func setPrimaryKeyColumns(_ primaryKeyColumns: [String]) {
self.primaryKeyColumns = primaryKeyColumns
}

// MARK: - Change Tracking

func recordCellChange(
Expand Down
80 changes: 52 additions & 28 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,6 @@ extension QueryExecutionCoordinator {
QueryExecutor.parseSchemaMetadata(schema)
}

func awaitSchemaResult(
parallelTask: Task<SchemaResult, Error>?,
tableName: String
) async -> SchemaResult? {
await QueryExecutor.awaitSchemaResult(
connectionId: parent.connectionId,
parallelTask: parallelTask,
tableName: tableName
)
}

func isMetadataCached(tabId: UUID, tableName: String) -> Bool {
guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else {
return false
Expand Down Expand Up @@ -301,32 +290,33 @@ extension QueryExecutionCoordinator {
tabId: UUID,
capturedGeneration: Int,
connectionType: DatabaseType,
schemaResult: SchemaResult?
schemaTask: Task<SchemaResult, Error>?
) {
resolveRowCount(
tableName: tableName,
tabId: tabId,
capturedGeneration: capturedGeneration,
connectionType: connectionType
)

let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql
guard !isNonSQL else { return }
Task(priority: .utility) { [weak self, parent] in
guard let self else { return }
guard !parent.isTearingDown else { return }

let columnInfo: [ColumnInfo]
if let schema = schemaResult {
columnInfo = schema.columnInfo
} else {
columnInfo = (try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId) { driver in
try await driver.fetchColumns(table: tableName)
}) ?? []
let schema = try? await schemaTask?.value

await MainActor.run { [weak self] in
guard let self else { return }
guard capturedGeneration == parent.queryGeneration else { return }
if let schema {
applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId)
}
resolveRowCount(
tableName: tableName,
tabId: tabId,
capturedGeneration: capturedGeneration,
connectionType: connectionType
)
}

guard !isNonSQL, let schema else { return }

let columnEnumValues = await parent.fetchEnumValues(
columnInfo: columnInfo,
columnInfo: schema.columnInfo,
tableName: tableName,
connectionType: connectionType
)
Expand Down Expand Up @@ -361,6 +351,40 @@ extension QueryExecutionCoordinator {
}
}

private func applyPhase2Metadata(parsed: ParsedSchemaMetadata, tabId: UUID) {
guard parent.tabManager.tabs.contains(where: { $0.id == tabId }) else { return }

parent.mutateActiveTableRows(for: tabId) { rows in
rows.updateDisplayMetadata(
columnDefaults: parsed.columnDefaults,
columnForeignKeys: parsed.columnForeignKeys,
columnNullable: parsed.columnNullable
)
Comment on lines +357 to +362
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply phase 2 metadata to the originating result set

When the schema fetch finishes after the user has switched the active result set (for example to a pinned result set), this mutates the tab's currently active TableRows, not necessarily the result set created by the query; switchActiveResultSet swaps the session rows on selection. That lets defaults, foreign keys, and nullability from the newly executed table be written onto a different result set, producing incorrect edit metadata until the rows are reloaded. Carry the originating result-set id through phase 2 or skip the update if the active result set changed.

Useful? React with 👍 / 👎.

}

parent.tabManager.mutate(tabId: tabId) { tab in
if !parsed.primaryKeyColumns.isEmpty {
tab.tableContext.primaryKeyColumns = parsed.primaryKeyColumns
}
if let approxCount = parsed.approximateRowCount, approxCount > 0,
!tab.filterState.hasAppliedFilters {
tab.pagination.totalRowCount = approxCount
tab.pagination.isApproximateRowCount = true
}
tab.metadataVersion += 1
}

if parent.tabManager.selectedTabId == tabId, !parsed.primaryKeyColumns.isEmpty {
parent.changeManager.setPrimaryKeyColumns(parsed.primaryKeyColumns)
}

if let activeIdx = parent.tabManager.selectedTabIndex,
activeIdx < parent.tabManager.tabs.count,
parent.tabManager.tabs[activeIdx].id == tabId {
parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns()
}
}

func launchPhase2Count(
tableName: String,
tabId: UUID,
Expand Down
Loading
Loading