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 @@ -100,6 +100,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Vim Replace mode writing an invisible character for Backspace and keypad Enter. (#2717)
- Option and Control chords editing text in Vim Normal and Visual mode, and Vim's Ctrl commands never running. (#2717)
- Line and paragraph separators (U+2028, U+2029) shown as line breaks the database does not see. (#2717)
- Numeric-looking filter values sent unquoted to text columns when a table first opens or after a foreign key jump.

## [0.73.0] - 2026-09-09

Expand Down
17 changes: 7 additions & 10 deletions TablePro/Core/Coordinators/FilterCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ final class FilterCoordinator {
parent.tabManager.mutate(at: capturedTabIndex) { $0.pagination.reset() }

let tab = parent.tabManager.tabs[capturedTabIndex]
let buffer = parent.tabSessionRegistry.tableRows(for: tab.id)
let queryColumns = parent.queryColumns(for: tab)
let newQuery = parent.queryBuilder.buildFilteredQuery(
tableName: capturedTableName,
schemaName: tab.tableContext.schemaName,
filters: capturedFilters,
logicMode: tab.filterState.filterLogicMode,
sortState: tab.sortState,
columns: buffer.columns,
columnTypes: buffer.columnTypes,
columns: queryColumns.columns,
columnTypes: queryColumns.columnTypes,
selectColumns: parent.selectColumns(for: tab),
limit: tab.pagination.pageSize,
offset: tab.pagination.currentOffset
Expand Down Expand Up @@ -174,11 +174,8 @@ final class FilterCoordinator {
let tableName = parent.tabManager.tabs[tabIndex].tableContext.tableName else { return }

let tab = parent.tabManager.tabs[tabIndex]
let buffer = parent.tabSessionRegistry.tableRows(for: tab.id)
let hasFilters = tab.filterState.hasAppliedFilters
let hasBufferedColumns = !buffer.columns.isEmpty
let columns = hasBufferedColumns ? buffer.columns : parent.effectiveResultColumns(for: tab)
let columnTypes = hasBufferedColumns ? buffer.columnTypes : []
let (columns, columnTypes) = parent.queryColumns(for: tab)

let newQuery: String
if usesBrowseSearch, tab.filterState.hasActiveBrowseSearch {
Expand Down Expand Up @@ -632,11 +629,11 @@ final class FilterCoordinator {
guard let dialect = PluginManager.shared.sqlDialect(for: databaseType) else {
return "-- Filters are applied natively"
}
let buffer = parent.tabManager.selectedTab.map { parent.tabSessionRegistry.tableRows(for: $0.id) }
let queryColumns = parent.tabManager.selectedTab.map { parent.queryColumns(for: $0) }
let generator = FilterSQLGenerator(
dialect: dialect,
columns: buffer?.columns ?? [],
columnTypes: buffer?.columnTypes ?? []
columns: queryColumns?.columns ?? [],
columnTypes: queryColumns?.columnTypes ?? []
)
let filtersToPreview = filtersForPreview(in: state)

Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/Coordinators/PaginationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,10 @@ final class PaginationCoordinator {
let filters = tab.filterState.hasAppliedFilters ? tab.filterState.appliedFilters : []
let logicMode = tab.filterState.filterLogicMode
let isNonSQL = PluginManager.shared.editorLanguage(for: parent.connection.type) != .sql
let buffer = parent.tabSessionRegistry.tableRows(for: tabId)
let queryColumns = parent.queryColumns(for: tab)
let countSQL = isNonSQL ? nil : parent.queryBuilder.buildFilteredCountQuery(
tableName: tableName, schemaName: schemaName, filters: filters, logicMode: logicMode,
columns: buffer.columns, columnTypes: buffer.columnTypes
columns: queryColumns.columns, columnTypes: queryColumns.columnTypes
)

/// Taking the task slot supersedes whatever automatic count held it, so this claims that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -641,14 +641,14 @@ extension QueryExecutionCoordinator {
threshold: AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan
)
guard case let .exactCount(filtered) = plan else { return (plan, nil, scope) }
let buffer = parent.tabSessionRegistry.tableRows(for: tabId)
let queryColumns = parent.queryColumns(for: tab)
let sql = parent.queryBuilder.buildFilteredCountQuery(
tableName: tableName,
schemaName: tab.tableContext.schemaName,
filters: filtered ? tab.filterState.appliedFilters : [],
logicMode: tab.filterState.filterLogicMode,
columns: buffer.columns,
columnTypes: buffer.columnTypes
columns: queryColumns.columns,
columnTypes: queryColumns.columnTypes
)
return (plan, sql, scope)
}
Expand Down
14 changes: 13 additions & 1 deletion TablePro/Core/Services/ColumnTypeClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Foundation

struct ColumnTypeClassifier {
func classify(rawTypeName: String) -> ColumnType {
let stripped = stripWrappers(rawTypeName)
let stripped = stripTrailingAttributes(stripWrappers(rawTypeName))
let (base, params) = extractBaseAndParams(stripped)

if base.hasSuffix("[]") {
Expand Down Expand Up @@ -53,6 +53,18 @@ struct ColumnTypeClassifier {
return value
}

/// MySQL's catalog spells a column `INT UNSIGNED` or `INT(10) UNSIGNED ZEROFILL`, while its
/// result metadata says `INT`. Without this the catalog spelling fell through to text.
private func stripTrailingAttributes(_ value: String) -> String {
var stripped = value
while let attribute = Self.trailingAttributes.first(where: { stripped.uppercased().hasSuffix(" \($0)") }) {
stripped = String(stripped.dropLast(attribute.count)).trimmingCharacters(in: .whitespaces)
}
return stripped
}

private static let trailingAttributes = ["UNSIGNED", "SIGNED", "ZEROFILL"]

// MARK: - Base / Params Extraction

private func extractBaseAndParams(_ value: String) -> (base: String, params: String?) {
Expand Down
29 changes: 28 additions & 1 deletion TablePro/Core/Services/Query/SchemaColumnStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@ import Foundation

@MainActor
final class SchemaColumnStore {
typealias Entry = (columns: [String], primaryKeys: [String])
struct Entry: Equatable, Sendable {
let columns: [String]
let primaryKeys: [String]
let columnTypes: [String: ColumnType]

/// Positional, because `FilterSQLGenerator` pairs names and types by index. A name this
/// table does not have returns no types at all, since a shorter list would type every
/// value after the gap against the wrong column.
func columnTypes(aligningWith names: [String]) -> [ColumnType] {
let aligned = names.compactMap { columnTypes[$0] }
return aligned.count == names.count ? aligned : []
}
}

/// One fetch shared by every caller asking for the same key while it is in flight.
///
Expand Down Expand Up @@ -105,3 +117,18 @@ final class SchemaColumnStore {
load.task.cancel()
}
}

extension SchemaColumnStore.Entry {
/// Classified by the same `ColumnTypeClassifier` the result path uses, so a filter typed from
/// the schema before any rows load reads a column the way it will once they have.
init(fetchedColumns: [ColumnInfo], classifier: ColumnTypeClassifier = ColumnTypeClassifier()) {
self.init(
columns: fetchedColumns.map(\.name),
primaryKeys: fetchedColumns.filter(\.isPrimaryKey).map(\.name),
columnTypes: Dictionary(
fetchedColumns.map { ($0.name, classifier.classify(rawTypeName: $0.dataType)) },
uniquingKeysWith: { first, _ in first }
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ extension MainContentCoordinator {
columnScopeLog.error("loadSchemaColumns: 0 columns for table=\(tableName, privacy: .public); cannot scope")
return nil
}
return (columns.map(\.name), columns.filter(\.isPrimaryKey).map(\.name))
return SchemaColumnStore.Entry(fetchedColumns: columns)
} catch {
guard !DatabaseCancellationDiagnosis.isCancellation(error) else { return nil }
columnScopeLog.error("loadSchemaColumns: fetchColumns failed for table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)")
Expand All @@ -90,7 +90,7 @@ extension MainContentCoordinator {
return schema.columns
}

func cachedSchemaColumns(for tab: QueryTab) -> (columns: [String], primaryKeys: [String])? {
func cachedSchemaColumns(for tab: QueryTab) -> SchemaColumnStore.Entry? {
guard let tableName = tab.tableContext.tableName else { return nil }
return schemaColumns.cached(schemaColumnsKey(tableName, scope: scope(for: tab)))
}
Expand All @@ -99,6 +99,16 @@ extension MainContentCoordinator {
selectColumns(for: tab) ?? cachedSchemaColumns(for: tab)?.columns ?? []
}

/// The columns a table tab's SQL is built against, each paired with its type. Loaded rows are
/// the authority once they exist. Before that the table's schema answers, because an untyped
/// filter guesses from the value's text and sends `0123` to a text column as a number.
func queryColumns(for tab: QueryTab) -> (columns: [String], columnTypes: [ColumnType]) {
let buffer = tabSessionRegistry.tableRows(for: tab.id)
guard buffer.columns.isEmpty else { return (buffer.columns, buffer.columnTypes) }
let columns = effectiveResultColumns(for: tab)
return (columns, cachedSchemaColumns(for: tab)?.columnTypes(aligningWith: columns) ?? [])
}

/// Built entirely from the tab's scope. Keying it on where the user is browsing
/// makes two tabs on same-named tables in different databases share one entry.
func schemaColumnsKey(_ tableName: String, scope: DatabaseScope?) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,29 +226,14 @@ extension MainContentCoordinator {
return
}

let tabId = replacedTab.id
cancelTableLoad(for: tabId)
toolbarState.isTableTab = true
setActiveTableRows(TableRows(), for: tabId)
tabManager.mutate(at: tabIndex) { $0.pagination.reset() }
/// The load goes through the first-load path like every other retarget, because the new
/// table has no rows to type the filter value from and that path waits for its schema.
cancelTableLoad(for: replacedTab.id)
discardRowsForRetarget()
restoreLastHiddenColumnsForTable()

guard let pagination = tabManager.selectedTab?.pagination else { return }
let tableRows = tabSessionRegistry.tableRows(for: tabId)
let filteredQuery = queryBuilder.buildFilteredQuery(
tableName: referencedTable,
schemaName: schemaName,
filters: [filter],
columns: tableRows.columns,
columnTypes: tableRows.columnTypes,
limit: pagination.pageSize,
offset: pagination.currentOffset
)
tabManager.mutate(at: tabIndex) { $0.content.query = filteredQuery }

updateFilterState(filter, for: referencedTable)

runQuery()
rebuildTableQuery(at: tabIndex)
lazyLoadCurrentTabIfNeeded()
}

private func applyFKFilter(_ filter: TableFilter, for tableName: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ extension MainContentCoordinator {

let restoreApplied = applyPendingRestoredViewState(at: index)
let sortApplied = restoreApplied ? false : applyResolvedDefaultSort(at: index, hint: hint)
if restoreApplied || sortApplied || !tabManager.tabs[index].columnLayout.hiddenColumns.isEmpty {
let loadedTab = tabManager.tabs[index]
if restoreApplied || sortApplied
|| !loadedTab.columnLayout.hiddenColumns.isEmpty
|| loadedTab.filterState.hasAppliedFilters {
filterCoordinator.rebuildTableQuery(at: index)
}
return true
Expand All @@ -80,9 +83,13 @@ extension MainContentCoordinator {
return true
}

/// Applied filters wait for the schema because no rows have arrived to type their values, and
/// an untyped value is guessed from its text: `123` goes to a text column as a number, which
/// PostgreSQL rejects and MySQL answers by comparing numerically.
func firstLoadNeedsSchemaColumns(for tab: QueryTab, hint: DefaultSortHint) -> Bool {
wantsDefaultSort(for: tab, hint: hint)
|| !tab.columnLayout.hiddenColumns.isEmpty
|| tab.filterState.hasAppliedFilters
|| tab.pendingRestoredSort != nil
|| tab.restoredPage != nil
}
Expand Down
18 changes: 17 additions & 1 deletion TableProTests/Core/Services/ColumnTypeClassifierTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@

import Foundation
import TableProPluginKit
@testable import TablePro
import Testing

@testable import TablePro

@Suite("Column Type Classifier")
struct ColumnTypeClassifierTests {
private let classifier = ColumnTypeClassifier()
Expand Down Expand Up @@ -193,6 +194,21 @@ struct ColumnTypeClassifierTests {
#expect(isInteger(classifier.classify(rawTypeName: "SMALLINT")))
}

@Test("The catalog's INT UNSIGNED classifies as integer and keeps its raw spelling")
func intUnsignedIsInteger() {
#expect(classifier.classify(rawTypeName: "INT UNSIGNED") == .integer(rawType: "INT UNSIGNED"))
}

@Test("Trailing UNSIGNED, SIGNED and ZEROFILL never decide the type")
func trailingAttributesAreIgnored() {
#expect(isInteger(classifier.classify(rawTypeName: "BIGINT UNSIGNED")))
#expect(isInteger(classifier.classify(rawTypeName: "int(10) unsigned zerofill")))
#expect(isInteger(classifier.classify(rawTypeName: "TINYINT SIGNED")))
#expect(isDecimal(classifier.classify(rawTypeName: "DECIMAL(10,2) UNSIGNED")))
#expect(isDecimal(classifier.classify(rawTypeName: "DOUBLE UNSIGNED")))
#expect(classifier.classify(rawTypeName: "TINYINT(1) UNSIGNED").isBooleanType)
}

@Test("ENUM('a','b','c') classifies as enum")
func enumType() {
#expect(classifier.classify(rawTypeName: "ENUM('a','b','c')").isEnumType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ struct SchemaColumnStoreCancellationTests {
await store.load("users", fetch: probe.fetch)
#expect(store.cached("users") == nil)

probe.result = (["id"], ["id"])
probe.result = SchemaColumnStore.Entry(columns: ["id"], primaryKeys: ["id"], columnTypes: [:])
await store.load("users", fetch: probe.fetch)

#expect(probe.startCount == 2)
Expand Down Expand Up @@ -180,7 +180,7 @@ struct SchemaColumnStoreCancellationTests {
func supersededFetchCannotOverwriteNewerResult() async {
let store = SchemaColumnStore()
let stale = FetchProbe()
stale.result = (["stale"], [])
stale.result = SchemaColumnStore.Entry(columns: ["stale"], primaryKeys: [], columnTypes: [:])

let abandoned = Task { await store.load("users", fetch: stale.fetch) }
#expect(await Self.wait(until: { stale.startCount == 1 }))
Expand Down Expand Up @@ -214,7 +214,11 @@ private final class FetchProbe {
private(set) var startCount = 0
private(set) var cancelCount = 0
private(set) var finishCount = 0
var result: SchemaColumnStore.Entry? = (["id", "name"], ["id"])
var result: SchemaColumnStore.Entry? = SchemaColumnStore.Entry(
columns: ["id", "name"],
primaryKeys: ["id"],
columnTypes: [:]
)

private var isReleased = false

Expand Down
Loading
Loading