diff --git a/CHANGELOG.md b/CHANGELOG.md index a628e2e8a..dc6789bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index bc9c63917..6da10567c 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -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 @@ -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 { @@ -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) diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 2fc9726d7..8ae50d666 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -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 diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index f9ddb107e..0f58c5bb3 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -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) } diff --git a/TablePro/Core/Services/ColumnTypeClassifier.swift b/TablePro/Core/Services/ColumnTypeClassifier.swift index dec247d99..9cfbf59ed 100644 --- a/TablePro/Core/Services/ColumnTypeClassifier.swift +++ b/TablePro/Core/Services/ColumnTypeClassifier.swift @@ -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("[]") { @@ -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?) { diff --git a/TablePro/Core/Services/Query/SchemaColumnStore.swift b/TablePro/Core/Services/Query/SchemaColumnStore.swift index 48a33107f..9541d7b40 100644 --- a/TablePro/Core/Services/Query/SchemaColumnStore.swift +++ b/TablePro/Core/Services/Query/SchemaColumnStore.swift @@ -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. /// @@ -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 } + ) + ) + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index 34ededbb1..998854947 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -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)") @@ -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))) } @@ -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 { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 9c9425088..3a0116320 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -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) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index fcb3a1ecc..4070a85a4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -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 @@ -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 } diff --git a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift index 3da292e53..f310a2f24 100644 --- a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift +++ b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift @@ -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() @@ -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) diff --git a/TableProTests/Core/Services/Query/SchemaColumnStoreCancellationTests.swift b/TableProTests/Core/Services/Query/SchemaColumnStoreCancellationTests.swift index a35ffb834..084f926cc 100644 --- a/TableProTests/Core/Services/Query/SchemaColumnStoreCancellationTests.swift +++ b/TableProTests/Core/Services/Query/SchemaColumnStoreCancellationTests.swift @@ -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) @@ -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 })) @@ -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 diff --git a/TableProTests/Core/Services/Query/SchemaColumnStoreTests.swift b/TableProTests/Core/Services/Query/SchemaColumnStoreTests.swift index 773bd5bbe..75621e37a 100644 --- a/TableProTests/Core/Services/Query/SchemaColumnStoreTests.swift +++ b/TableProTests/Core/Services/Query/SchemaColumnStoreTests.swift @@ -6,6 +6,10 @@ import Testing @Suite("SchemaColumnStore") @MainActor struct SchemaColumnStoreTests { + nonisolated private static func entry(_ columns: [String], primaryKeys: [String] = []) -> SchemaColumnStore.Entry { + SchemaColumnStore.Entry(columns: columns, primaryKeys: primaryKeys, columnTypes: [:]) + } + @Test("load fetches once and caches the entry") func loadFetchesOnceAndCaches() async { let store = SchemaColumnStore() @@ -13,11 +17,11 @@ struct SchemaColumnStoreTests { await store.load("k") { fetchCount += 1 - return (columns: ["id"], primaryKeys: ["id"]) + return Self.entry(["id"], primaryKeys: ["id"]) } await store.load("k") { fetchCount += 1 - return (columns: ["other"], primaryKeys: []) + return Self.entry(["other"]) } #expect(fetchCount == 1) @@ -32,12 +36,12 @@ struct SchemaColumnStoreTests { async let first: Void = store.load("k") { await counter.increment() try? await Task.sleep(for: .milliseconds(50)) - return (columns: ["id"], primaryKeys: ["id"]) + return Self.entry(["id"], primaryKeys: ["id"]) } async let second: Void = store.load("k") { await counter.increment() try? await Task.sleep(for: .milliseconds(50)) - return (columns: ["id"], primaryKeys: ["id"]) + return Self.entry(["id"], primaryKeys: ["id"]) } _ = await (first, second) @@ -58,7 +62,7 @@ struct SchemaColumnStoreTests { await store.load("k") { fetchCount += 1 - return (columns: ["id"], primaryKeys: []) + return Self.entry(["id"]) } #expect(fetchCount == 2) @@ -68,24 +72,61 @@ struct SchemaColumnStoreTests { @Test("removeAll clears entries and allows a fresh fetch") func removeAllClearsAndRefetches() async { let store = SchemaColumnStore() - await store.load("k") { (columns: ["old"], primaryKeys: []) } + await store.load("k") { Self.entry(["old"]) } store.removeAll() #expect(store.cached("k") == nil) - await store.load("k") { (columns: ["new"], primaryKeys: []) } + await store.load("k") { Self.entry(["new"]) } #expect(store.cached("k")?.columns == ["new"]) } @Test("store and cached round-trip") func storeAndCachedRoundTrip() { let store = SchemaColumnStore() - store.store((columns: ["a", "b"], primaryKeys: ["a"]), for: "k") + store.store(Self.entry(["a", "b"], primaryKeys: ["a"]), for: "k") #expect(store.cached("k")?.columns == ["a", "b"]) #expect(store.cached("k")?.primaryKeys == ["a"]) #expect(store.cached("missing") == nil) } + + @Test("An entry built from fetched columns types each column from its declared type") + func fetchedColumnsAreClassified() { + let entry = SchemaColumnStore.Entry(fetchedColumns: [ + TestFixtures.makeColumnInfo(name: "id", dataType: "INT UNSIGNED", isPrimaryKey: true), + TestFixtures.makeColumnInfo(name: "code", dataType: "character varying", isPrimaryKey: false), + TestFixtures.makeColumnInfo(name: "active", dataType: "boolean", isPrimaryKey: false) + ]) + + #expect(entry.columns == ["id", "code", "active"]) + #expect(entry.primaryKeys == ["id"]) + #expect(entry.columnTypes["id"] == .integer(rawType: "INT UNSIGNED")) + #expect(entry.columnTypes["code"] == .text(rawType: "character varying")) + #expect(entry.columnTypes["active"] == .boolean(rawType: "boolean")) + } + + @Test("Aligned types follow the order of the names asked for") + func alignedTypesFollowTheRequestedOrder() { + let entry = SchemaColumnStore.Entry( + columns: ["id", "code"], + primaryKeys: ["id"], + columnTypes: ["id": .integer(rawType: "INT"), "code": .text(rawType: "TEXT")] + ) + + #expect(entry.columnTypes(aligningWith: ["code", "id"]) == [.text(rawType: "TEXT"), .integer(rawType: "INT")]) + } + + @Test("A name the table does not have yields no types rather than a shifted list") + func unknownNameYieldsNoTypes() { + let entry = SchemaColumnStore.Entry( + columns: ["id", "code"], + primaryKeys: ["id"], + columnTypes: ["id": .integer(rawType: "INT"), "code": .text(rawType: "TEXT")] + ) + + #expect(entry.columnTypes(aligningWith: ["missing", "code"]).isEmpty) + } } private actor FetchCounter { diff --git a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift index ebf0e2684..fce1b2dc3 100644 --- a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift +++ b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift @@ -382,7 +382,7 @@ struct CoordinatorColumnVisibilityTests { _ = addTableTab(to: tabManager, tableName: "users") coordinator.hideAllColumns(["a", "b", "c", "d"]) coordinator.schemaColumns.store( - (columns: ["b", "d", "e"], primaryKeys: []), + SchemaColumnStore.Entry(columns: ["b", "d", "e"], primaryKeys: [], columnTypes: [:]), for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) ) @@ -429,7 +429,7 @@ struct CoordinatorColumnVisibilityTests { FileColumnLayoutPersister.shared.saveHiddenColumns(["email"], for: key) coordinator.schemaColumns.store( - (columns: ["id", "name", "email"], primaryKeys: ["id"]), + SchemaColumnStore.Entry(columns: ["id", "name", "email"], primaryKeys: ["id"], columnTypes: [:]), for: coordinator.schemaColumnsKey("users", scope: coordinator.scope(for: createdTab)) ) diff --git a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift index d98b194a3..fd425d176 100644 --- a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift +++ b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift @@ -37,7 +37,7 @@ struct DefaultSortInitialQueryTests { func firstQueryContainsPrimaryKeyOrderBy() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") coordinator.schemaColumns.store( - (columns: ["id", "name", "email"], primaryKeys: ["id"]), + SchemaColumnStore.Entry(columns: ["id", "name", "email"], primaryKeys: ["id"], columnTypes: [:]), for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) ) @@ -56,7 +56,11 @@ struct DefaultSortInitialQueryTests { func compositePrimaryKeySortsAllKeyColumns() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "invoices") coordinator.schemaColumns.store( - (columns: ["customer_uid", "order_uid", "total"], primaryKeys: ["customer_uid", "order_uid"]), + SchemaColumnStore.Entry( + columns: ["customer_uid", "order_uid", "total"], + primaryKeys: ["customer_uid", "order_uid"], + columnTypes: [:] + ), for: coordinator.schemaColumnsKey("invoices", scope: coordinator.selectedTabScope) ) @@ -76,7 +80,7 @@ struct DefaultSortInitialQueryTests { func noPrimaryKeyProducesNoOrderBy() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "logs") coordinator.schemaColumns.store( - (columns: ["message", "level"], primaryKeys: []), + SchemaColumnStore.Entry(columns: ["message", "level"], primaryKeys: [], columnTypes: [:]), for: coordinator.schemaColumnsKey("logs", scope: coordinator.selectedTabScope) ) let originalQuery = tabManager.tabs[index].content.query @@ -153,7 +157,7 @@ struct DefaultSortInitialQueryTests { func userSortSurvivesFirstLoad() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") coordinator.schemaColumns.store( - (columns: ["id", "name"], primaryKeys: ["id"]), + SchemaColumnStore.Entry(columns: ["id", "name"], primaryKeys: ["id"], columnTypes: [:]), for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) ) let userSort = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) @@ -170,7 +174,7 @@ struct DefaultSortInitialQueryTests { func sortsAgainstScopedColumnsWithHiddenColumns() async { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") coordinator.schemaColumns.store( - (columns: ["a", "id", "name"], primaryKeys: ["id"]), + SchemaColumnStore.Entry(columns: ["a", "id", "name"], primaryKeys: ["id"], columnTypes: [:]), for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) ) tabManager.mutate(at: index) { $0.columnLayout.hiddenColumns = ["a"] } diff --git a/TableProTests/Views/Main/FKNavigationTests.swift b/TableProTests/Views/Main/FKNavigationTests.swift index e908c92b0..af02dff69 100644 --- a/TableProTests/Views/Main/FKNavigationTests.swift +++ b/TableProTests/Views/Main/FKNavigationTests.swift @@ -92,6 +92,45 @@ struct FKNavigationTests { #expect(tabManager.selectedTab?.tableContext.tableName == "users") } + /// The target table has no rows yet, so the only thing that can type the value is its schema. + /// Built from the empty buffer instead, `0123` went to a text key as the number `0123`, which + /// MySQL compares numerically and PostgreSQL rejects outright. + @Test("An in-place hop types the reference value from the target table's columns") + @MainActor + func inPlaceHopTypesTheValueFromTheTargetSchema() throws { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + try tabManager.addTableTab( + tableName: "orders", + databaseType: connection.type, + databaseName: coordinator.browseDatabaseName + ) + coordinator.schemaColumns.store( + SchemaColumnStore.Entry( + columns: ["id", "code"], + primaryKeys: ["id"], + columnTypes: ["id": .integer(rawType: "INT"), "code": .text(rawType: "VARCHAR(20)")] + ), + for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) + ) + + let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "code") + coordinator.navigateToFKReference(value: "0123", fkInfo: fkInfo, openInNewTab: false) + + let query = try #require(tabManager.selectedTab?.content.query) + #expect(tabManager.selectedTab?.tableContext.tableName == "users") + #expect(query.contains("'0123'")) + #expect(!query.contains("= 0123")) + } + @Test("FK navigation with no referenced schema resolves the session's current schema") @MainActor func nilReferencedSchemaResolvesActiveSchema() throws { diff --git a/TableProTests/Views/Main/FilterTypingBeforeFirstLoadTests.swift b/TableProTests/Views/Main/FilterTypingBeforeFirstLoadTests.swift new file mode 100644 index 000000000..9f1e649d8 --- /dev/null +++ b/TableProTests/Views/Main/FilterTypingBeforeFirstLoadTests.swift @@ -0,0 +1,171 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Filter values are typed from the schema before a table's first rows load") +@MainActor +struct FilterTypingBeforeFirstLoadTests { + private static let schema = SchemaColumnStore.Entry( + columns: ["id", "code"], + primaryKeys: ["id"], + columnTypes: ["id": .integer(rawType: "INT"), "code": .text(rawType: "VARCHAR(20)")] + ) + + private func makeCoordinator( + filter: TableFilter + ) -> (coordinator: MainContentCoordinator, tabManager: QueryTabManager, tabId: UUID) { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + var tab = QueryTab(title: "items", query: "SELECT * FROM `items` LIMIT 200", tabType: .table) + tab.tableContext.tableName = "items" + tab.filterState = TabFilterState(filters: [filter], commit: .all, isVisible: true, filterLogicMode: .and) + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return (coordinator, tabManager, tab.id) + } + + private func storeSchema(in coordinator: MainContentCoordinator) { + coordinator.schemaColumns.store( + Self.schema, + for: coordinator.schemaColumnsKey("items", scope: coordinator.selectedTabScope) + ) + } + + private func firstLoadQuery( + _ coordinator: MainContentCoordinator, + _ tabManager: QueryTabManager, + tabId: UUID + ) async -> String? { + let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior + AppSettingsManager.shared.dataGrid.defaultSortBehavior = .none + defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } + + guard await coordinator.prepareTableTabFirstLoad(tabId: tabId) else { return nil } + return tabManager.tabs.first { $0.id == tabId }?.content.query + } + + @Test("A numeric-looking value on a text column is quoted in the first query") + func textColumnValueIsQuotedOnFirstLoad() async throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) + + #expect(query.contains("'0123'")) + #expect(!query.contains("= 0123")) + } + + @Test("A numeric value on an integer column stays unquoted in the first query") + func integerColumnValueStaysUnquotedOnFirstLoad() async throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "id", value: "123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) + + #expect(query.contains("`id` = 123")) + #expect(!query.contains("'123'")) + } + + @Test("A TRUE value on a text column is not rewritten into a boolean literal") + func textColumnKeepsBooleanShapedValue() async throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "TRUE") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) + + #expect(query.contains("'TRUE'")) + } + + @Test("A schema that cannot be fetched still dispatches the first load") + func unavailableSchemaStillDispatches() async throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + + let query = try #require(await firstLoadQuery(coordinator, tabManager, tabId: tabId)) + + #expect(query.contains("WHERE")) + } + + @Test("Applied filters make the first load wait for the schema") + func appliedFiltersNeedTheSchema() { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + let previous = AppSettingsManager.shared.dataGrid.defaultSortBehavior + AppSettingsManager.shared.dataGrid.defaultSortBehavior = .none + defer { AppSettingsManager.shared.dataGrid.defaultSortBehavior = previous } + + let filtered = tabManager.tabs.first { $0.id == tabId } + var unfiltered = filtered + unfiltered?.filterState = TabFilterState() + + #expect(filtered.map { coordinator.firstLoadNeedsSchemaColumns(for: $0, hint: .useAppDefault) } == true) + #expect(unfiltered.map { coordinator.firstLoadNeedsSchemaColumns(for: $0, hint: .useAppDefault) } == false) + } + + @Test("The filter preview is typed from the schema before any rows arrive") + func previewIsTypedBeforeRowsArrive() { + let (coordinator, _, _) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let preview = coordinator.filterCoordinator.generateFilterPreviewSQL(databaseType: .mysql) + + #expect(preview.contains("'0123'")) + } + + @Test("Loaded rows decide the types once they exist") + func loadedRowsWinOverTheSchema() throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + coordinator.setActiveTableRows( + TableRows.from(queryRows: [], columns: ["code"], columnTypes: [.integer(rawType: "INT")]), + for: tabId + ) + + let tab = try #require(tabManager.tabs.first { $0.id == tabId }) + let resolved = coordinator.queryColumns(for: tab) + + #expect(resolved.columns == ["code"]) + #expect(resolved.columnTypes == [.integer(rawType: "INT")]) + } + + @Test("Before any rows the schema supplies a type for every query column") + func schemaSuppliesTypesBeforeRows() throws { + let (coordinator, tabManager, tabId) = makeCoordinator( + filter: TestFixtures.makeTableFilter(column: "code", value: "0123") + ) + defer { coordinator.teardown() } + storeSchema(in: coordinator) + + let tab = try #require(tabManager.tabs.first { $0.id == tabId }) + let resolved = coordinator.queryColumns(for: tab) + + #expect(resolved.columns == ["id", "code"]) + #expect(resolved.columnTypes == [.integer(rawType: "INT"), .text(rawType: "VARCHAR(20)")]) + } +}