From ef3ff65a493f3dd42f32a05c0887052b99376125 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: Fri, 12 Jun 2026 12:23:19 +0700 Subject: [PATCH 1/5] fix(datagrid): restore FK jump arrows lost after sort, filter, or paginate --- CHANGELOG.md | 1 + .../QueryExecutionCoordinator+Helpers.swift | 19 +++++--- .../Core/Services/Query/QueryExecutor.swift | 35 +++++++++++---- TablePro/Models/Query/TableRows.swift | 20 ++++++--- TablePro/Resources/Localizable.xcstrings | 15 +++++++ .../Services/Query/QueryExecutorTests.swift | 14 ++++-- .../Models/Query/TableRowsTests.swift | 43 +++++++++++++++++++ .../Views/Main/FKNavigationTests.swift | 37 ++++++++++++++++ 8 files changed, 161 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 982c41389..9ab67239e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format Query can now be undone with Cmd+Z; the formatting is applied as a single editor edit instead of clearing the undo history. (#1645) - Format Query now formats only the selected text when a selection is active, and the full query when nothing is selected. (#1656) +- Foreign key jump arrows no longer disappear after sorting, filtering, or paginating a table, and a failed foreign key lookup is retried on the next load instead of hiding the arrows for the whole session. - Sorting a query result no longer overwrites the SQL editor text or the contents of an opened `.sql` file; the sort runs as a separate query and the editor keeps what you wrote. (#1645) - iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads. - Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633) diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index a1e9067f1..684ed65cc 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -27,7 +27,8 @@ extension QueryExecutionCoordinator { let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id) guard tab.tableContext.tableName == tableName, !tableRows.columnDefaults.isEmpty, - !tab.tableContext.primaryKeyColumns.isEmpty else { + !tab.tableContext.primaryKeyColumns.isEmpty, + tableRows.foreignKeysFetched else { return false } let enumSetColumnNames: [String] = tableRows.columns.enumerated().compactMap { i, name in @@ -85,10 +86,13 @@ extension QueryExecutionCoordinator { } } + var foreignKeysFetched = false + if let metadata { columnDefaults = metadata.columnDefaults - columnForeignKeys = metadata.columnForeignKeys + columnForeignKeys = metadata.columnForeignKeys ?? [:] columnNullable = metadata.columnNullable + foreignKeysFetched = metadata.columnForeignKeys != nil for (col, vals) in metadata.columnEnumValues { columnEnumValues[col] = vals } @@ -97,6 +101,7 @@ extension QueryExecutionCoordinator { columnDefaults = existing.columnDefaults columnForeignKeys = existing.columnForeignKeys columnNullable = existing.columnNullable + foreignKeysFetched = existing.foreignKeysFetched for (col, vals) in existing.columnEnumValues where columnEnumValues[col] == nil { columnEnumValues[col] = vals } @@ -109,7 +114,8 @@ extension QueryExecutionCoordinator { columnDefaults: columnDefaults, columnForeignKeys: columnForeignKeys, columnEnumValues: columnEnumValues, - columnNullable: columnNullable + columnNullable: columnNullable, + foreignKeysFetched: foreignKeysFetched ) parent.setActiveTableRows(newTableRows, for: existingTabId) @@ -250,10 +256,13 @@ extension QueryExecutionCoordinator { await MainActor.run { [weak self] in guard let self else { return } - guard capturedGeneration == parent.queryGeneration else { return } - if let schema { + if let schema, + parent.tabManager.tabs.contains(where: { + $0.id == tabId && $0.tableContext.tableName == tableName + }) { applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId) } + guard capturedGeneration == parent.queryGeneration else { return } resolveRowCount( tableName: tableName, tabId: tabId, diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index ec77ab046..e598cfadf 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -15,11 +15,11 @@ struct QueryFetchResult { let resultColumnMeta: [ResultColumnMeta]? } -typealias SchemaResult = (columnInfo: [ColumnInfo], fkInfo: [ForeignKeyInfo], approximateRowCount: Int?) +typealias SchemaResult = (columnInfo: [ColumnInfo], fkInfo: [ForeignKeyInfo]?, approximateRowCount: Int?) struct ParsedSchemaMetadata { let columnDefaults: [String: String?] - let columnForeignKeys: [String: ForeignKeyInfo] + let columnForeignKeys: [String: ForeignKeyInfo]? let columnNullable: [String: Bool] let primaryKeyColumns: [String] let approximateRowCount: Int? @@ -118,24 +118,41 @@ final class QueryExecutor { // MARK: - Schema fetch + parse static func fetchTableSchema(connectionId: UUID, tableName: String) async throws -> SchemaResult { - try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + let (columns, approximateRowCount) = try await DatabaseManager.shared.withMetadataDriver( + connectionId: connectionId + ) { driver in let columns = try await driver.fetchColumns(table: tableName) - let foreignKeys = try await driver.fetchForeignKeys(table: tableName) let approximateRowCount = try? await driver.fetchApproximateRowCount(table: tableName) - return (columnInfo: columns, fkInfo: foreignKeys, approximateRowCount: approximateRowCount) + return (columns, approximateRowCount) } + let foreignKeys: [ForeignKeyInfo]? + do { + foreignKeys = try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchForeignKeys(table: tableName) + } + } catch { + queryExecutorLog.error( + "[fetchTableSchema] FK fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + foreignKeys = nil + } + return (columnInfo: columns, fkInfo: foreignKeys, approximateRowCount: approximateRowCount) } static func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata { var defaults: [String: String?] = [:] - var fks: [String: ForeignKeyInfo] = [:] var nullable: [String: Bool] = [:] for col in schema.columnInfo { defaults[col.name] = col.defaultValue nullable[col.name] = col.isNullable } - for fk in schema.fkInfo { - fks[fk.column] = fk + var fks: [String: ForeignKeyInfo]? + if let fkInfo = schema.fkInfo { + var byColumn: [String: ForeignKeyInfo] = [:] + for fk in fkInfo { + byColumn[fk.column] = fk + } + fks = byColumn } var enumValues: [String: [String]] = [:] for col in schema.columnInfo { @@ -165,7 +182,7 @@ final class QueryExecutor { } return ParsedSchemaMetadata( columnDefaults: [:], - columnForeignKeys: [:], + columnForeignKeys: nil, columnNullable: nullable, primaryKeyColumns: primaryKeys, approximateRowCount: nil, diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index 8ba906680..f6c329728 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -15,6 +15,7 @@ struct TableRows: Sendable { var columnForeignKeys: [String: ForeignKeyInfo] var columnEnumValues: [String: [String]] var columnNullable: [String: Bool] + var foreignKeysFetched: Bool init( rows: ContiguousArray = [], @@ -23,7 +24,8 @@ struct TableRows: Sendable { columnDefaults: [String: String?] = [:], columnForeignKeys: [String: ForeignKeyInfo] = [:], columnEnumValues: [String: [String]] = [:], - columnNullable: [String: Bool] = [:] + columnNullable: [String: Bool] = [:], + foreignKeysFetched: Bool = false ) { self.rows = rows self.indexByID = Self.buildIndex(for: rows) @@ -33,6 +35,7 @@ struct TableRows: Sendable { self.columnForeignKeys = columnForeignKeys self.columnEnumValues = columnEnumValues self.columnNullable = columnNullable + self.foreignKeysFetched = foreignKeysFetched } var count: Int { rows.count } @@ -166,9 +169,12 @@ struct TableRows: Sendable { self.columnDefaults = columnDefaults didChange = true } - if let columnForeignKeys, columnForeignKeys != self.columnForeignKeys { - self.columnForeignKeys = columnForeignKeys - didChange = true + if let columnForeignKeys { + if columnForeignKeys != self.columnForeignKeys { + self.columnForeignKeys = columnForeignKeys + didChange = true + } + foreignKeysFetched = true } if let columnEnumValues, columnEnumValues != self.columnEnumValues { self.columnEnumValues = columnEnumValues @@ -188,7 +194,8 @@ struct TableRows: Sendable { columnDefaults: [String: String?] = [:], columnForeignKeys: [String: ForeignKeyInfo] = [:], columnEnumValues: [String: [String]] = [:], - columnNullable: [String: Bool] = [:] + columnNullable: [String: Bool] = [:], + foreignKeysFetched: Bool = false ) -> TableRows { var rows = ContiguousArray() rows.reserveCapacity(queryRows.count) @@ -203,7 +210,8 @@ struct TableRows: Sendable { columnDefaults: columnDefaults, columnForeignKeys: columnForeignKeys, columnEnumValues: columnEnumValues, - columnNullable: columnNullable + columnNullable: columnNullable, + foreignKeysFetched: foreignKeysFetched ) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 98a656216..a785be35a 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -34738,6 +34738,9 @@ } } } + }, + "Load Query" : { + }, "Load Table Template" : { "extractionState" : "stale", @@ -39880,6 +39883,9 @@ } } } + }, + "No results for \"%@\"" : { + }, "No rows" : { "localizations" : { @@ -54337,6 +54343,9 @@ } } } + }, + "Show all results" : { + }, "Show All Rows" : { "localizations" : { @@ -57980,6 +57989,9 @@ } } } + }, + "Switch" : { + }, "switch %@" : { @@ -58281,6 +58293,9 @@ } } } + }, + "Switch to Tab" : { + }, "Switch to this database before executing" : { "localizations" : { diff --git a/TableProTests/Core/Services/Query/QueryExecutorTests.swift b/TableProTests/Core/Services/Query/QueryExecutorTests.swift index 5f074f157..b7ecdbf54 100644 --- a/TableProTests/Core/Services/Query/QueryExecutorTests.swift +++ b/TableProTests/Core/Services/Query/QueryExecutorTests.swift @@ -187,7 +187,7 @@ struct QueryExecutorTests { #expect(parsed.columnDefaults["name"] == .some("guest")) #expect(parsed.columnNullable["id"] == false) #expect(parsed.columnNullable["name"] == true) - #expect(parsed.columnForeignKeys["role_id"]?.referencedTable == "roles") + #expect(parsed.columnForeignKeys?["role_id"]?.referencedTable == "roles") #expect(parsed.approximateRowCount == 1_234) } @@ -208,6 +208,14 @@ struct QueryExecutorTests { #expect(parsed.columnEnumValues["status"] == ["open", "closed", "archived"]) } + + @Test("parseSchemaMetadata keeps a failed foreign key fetch distinguishable from zero foreign keys") + func parseSchemaMetadataNilForeignKeys() { + let schema: SchemaResult = (columnInfo: [], fkInfo: nil, approximateRowCount: nil) + let parsed = QueryExecutor.parseSchemaMetadata(schema) + #expect(parsed.columnForeignKeys == nil) + } + @Test("parseSchemaMetadata returns empty containers when input is empty") func parseSchemaMetadataEmpty() { let schema: SchemaResult = (columnInfo: [], fkInfo: [], approximateRowCount: nil) @@ -215,7 +223,7 @@ struct QueryExecutorTests { #expect(parsed.primaryKeyColumns.isEmpty) #expect(parsed.columnDefaults.isEmpty) #expect(parsed.columnNullable.isEmpty) - #expect(parsed.columnForeignKeys.isEmpty) + #expect(parsed.columnForeignKeys?.isEmpty == true) #expect(parsed.columnEnumValues.isEmpty) #expect(parsed.approximateRowCount == nil) } @@ -233,7 +241,7 @@ struct QueryExecutorTests { #expect(parsed.columnNullable["id"] == false) #expect(parsed.columnNullable["name"] == true) #expect(parsed.columnDefaults.isEmpty) - #expect(parsed.columnForeignKeys.isEmpty) + #expect(parsed.columnForeignKeys == nil) #expect(parsed.approximateRowCount == nil) } diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift index 2e96cb3a0..51cf0c3e6 100644 --- a/TableProTests/Models/Query/TableRowsTests.swift +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -627,3 +627,46 @@ struct TableRowsMetadataPreservationTests { Self.assertMetadataPreserved(table) } } + +@Suite("TableRows - foreignKeysFetched") +struct TableRowsForeignKeysFetchedTests { + @Test("Defaults to false on init and factory") + func defaultsToFalse() { + #expect(TableRows().foreignKeysFetched == false) + #expect(TestFixtures.makeTableRows().foreignKeysFetched == false) + } + + @Test("Applying a foreign key dictionary marks foreign keys as fetched") + func applyingForeignKeysMarksFetched() { + var table = TestFixtures.makeTableRows() + _ = table.updateDisplayMetadata(columnForeignKeys: ["user_id": TestFixtures.makeForeignKeyInfo()]) + #expect(table.foreignKeysFetched) + #expect(table.columnForeignKeys.count == 1) + } + + @Test("Applying an empty dictionary still marks fetched for tables without foreign keys") + func emptyDictionaryMarksFetched() { + var table = TestFixtures.makeTableRows() + _ = table.updateDisplayMetadata(columnForeignKeys: [:]) + #expect(table.foreignKeysFetched) + #expect(table.columnForeignKeys.isEmpty) + } + + @Test("Updating other metadata leaves the flag untouched") + func otherMetadataLeavesFlag() { + var table = TestFixtures.makeTableRows() + _ = table.updateDisplayMetadata(columnDefaults: ["id": nil]) + #expect(table.foreignKeysFetched == false) + } + + @Test("Factory preserves an explicit fetched flag") + func factoryPreservesFlag() { + let table = TableRows.from( + queryRows: [], + columns: ["id"], + columnTypes: [], + foreignKeysFetched: true + ) + #expect(table.foreignKeysFetched) + } +} diff --git a/TableProTests/Views/Main/FKNavigationTests.swift b/TableProTests/Views/Main/FKNavigationTests.swift index bca1cbdf0..70b0d4ed9 100644 --- a/TableProTests/Views/Main/FKNavigationTests.swift +++ b/TableProTests/Views/Main/FKNavigationTests.swift @@ -91,4 +91,41 @@ struct FKNavigationTests { #expect(tabManager.selectedTab?.id == tabId) #expect(tabManager.selectedTab?.tableContext.tableName == "users") } + + @Test("Metadata is not cached until foreign keys were fetched") + @MainActor + func metadataCacheRequiresFetchedForeignKeys() throws { + let connection = TestFixtures.makeConnection(database: "db") + 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.activeDatabaseName + ) + let tabId = tabManager.tabs[0].id + tabManager.mutate(at: 0) { $0.tableContext.primaryKeyColumns = ["id"] } + + var rows = TableRows.from( + queryRows: [], + columns: ["id"], + columnTypes: [], + columnDefaults: ["id": nil] + ) + coordinator.setActiveTableRows(rows, for: tabId) + let cachedBefore = coordinator.queryExecutionCoordinator.isMetadataCached(tabId: tabId, tableName: "orders") + #expect(cachedBefore == false) + + _ = rows.updateDisplayMetadata(columnForeignKeys: [:]) + coordinator.setActiveTableRows(rows, for: tabId) + let cachedAfter = coordinator.queryExecutionCoordinator.isMetadataCached(tabId: tabId, tableName: "orders") + #expect(cachedAfter) + } } From a907afc6b3ef33ee5692a44f89b5cb671c32ac10 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: Fri, 12 Jun 2026 12:37:39 +0700 Subject: [PATCH 2/5] fix(datagrid): trace the FK metadata pipeline with structured logging --- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 4 +- .../PostgreSQLPluginDriver.swift | 4 +- .../QueryExecutionCoordinator+Helpers.swift | 47 +++++++++++-------- .../Core/Services/Query/QueryExecutor.swift | 7 +++ .../Views/Main/MainContentCoordinator.swift | 5 ++ .../Views/Results/DataGridCoordinator.swift | 8 ++++ 6 files changed, 54 insertions(+), 21 deletions(-) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 55c3ef223..1a28e3e74 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -363,7 +363,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let result = try await execute(query: query) - return result.rows.compactMap { row in + let foreignKeys: [PluginForeignKeyInfo] = result.rows.compactMap { row in guard let name = row[safe: 0]?.asText, let column = row[safe: 1]?.asText, let refTable = row[safe: 2]?.asText, @@ -378,6 +378,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { onUpdate: (row[safe: 6]?.asText) ?? "NO ACTION" ) } + Self.logger.info("[fk] mysql fetchForeignKeys db=\(dbName, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(foreignKeys.count)") + return foreignKeys } func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 854af3174..bf9ff9023 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -252,7 +252,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { ORDER BY tc.constraint_name """ let result = try await execute(query: query) - return result.rows.compactMap { row -> PluginForeignKeyInfo? in + let foreignKeys: [PluginForeignKeyInfo] = result.rows.compactMap { row -> PluginForeignKeyInfo? in guard row.count >= 7, let name = row[0].asText, let column = row[1].asText, @@ -269,6 +269,8 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { onUpdate: row[6].asText ?? "NO ACTION" ) } + Self.logger.info("[fk] postgres fetchForeignKeys schema=\(self.core.currentSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(foreignKeys.count)") + return foreignKeys } func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 684ed65cc..c9f46d27a 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -25,22 +25,21 @@ extension QueryExecutionCoordinator { } let tab = parent.tabManager.tabs[idx] let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id) - guard tab.tableContext.tableName == tableName, - !tableRows.columnDefaults.isEmpty, - !tab.tableContext.primaryKeyColumns.isEmpty, - tableRows.foreignKeysFetched else { - return false - } let enumSetColumnNames: [String] = tableRows.columns.enumerated().compactMap { i, name in guard i < tableRows.columnTypes.count, tableRows.columnTypes[i].isEnumType || tableRows.columnTypes[i].isSetType else { return nil } return name } - if !enumSetColumnNames.isEmpty, - !enumSetColumnNames.allSatisfy({ tableRows.columnEnumValues[$0] != nil }) { - return false - } - return true + let enumsReady = enumSetColumnNames.allSatisfy { tableRows.columnEnumValues[$0] != nil } + let cached = tab.tableContext.tableName == tableName + && !tableRows.columnDefaults.isEmpty + && !tab.tableContext.primaryKeyColumns.isEmpty + && tableRows.foreignKeysFetched + && enumsReady + helpersLogger.info( + "[fk] cache check table=\(tableName, privacy: .public) defaults=\(tableRows.columnDefaults.count) pks=\(tab.tableContext.primaryKeyColumns.count) fkFetched=\(tableRows.foreignKeysFetched) fks=\(tableRows.columnForeignKeys.count) enumsReady=\(enumsReady) cached=\(cached)" + ) + return cached } func applyPhase1Result( // swiftlint:disable:this function_parameter_count @@ -253,14 +252,20 @@ extension QueryExecutionCoordinator { guard !parent.isTearingDown else { return } let schema = try? await schemaTask?.value + if schemaTask != nil, schema == nil { + helpersLogger.error("[fk] phase2 schema fetch failed or cancelled table=\(tableName, privacy: .public)") + } await MainActor.run { [weak self] in guard let self else { return } - if let schema, - parent.tabManager.tabs.contains(where: { - $0.id == tabId && $0.tableContext.tableName == tableName - }) { - applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId) + if let schema { + if parent.tabManager.tabs.contains(where: { + $0.id == tabId && $0.tableContext.tableName == tableName + }) { + applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId) + } else { + helpersLogger.info("[fk] phase2 apply skipped, tab closed or table changed table=\(tableName, privacy: .public)") + } } guard capturedGeneration == parent.queryGeneration else { return } resolveRowCount( @@ -336,11 +341,15 @@ extension QueryExecutionCoordinator { parent.changeManager.setPrimaryKeyColumns(parsed.primaryKeyColumns) } - if let activeIdx = parent.tabManager.selectedTabIndex, - activeIdx < parent.tabManager.tabs.count, - parent.tabManager.tabs[activeIdx].id == tabId { + let isActiveTab = parent.tabManager.selectedTabIndex.map { activeIdx in + activeIdx < parent.tabManager.tabs.count && parent.tabManager.tabs[activeIdx].id == tabId + } ?? false + if isActiveTab { parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns() } + helpersLogger.info( + "[fk] phase2 applied tab=\(tabId, privacy: .public) fks=\(parsed.columnForeignKeys?.count ?? -1) defaults=\(parsed.columnDefaults.count) activeTabRefreshed=\(isActiveTab)" + ) } func launchPhase2Count( diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index e598cfadf..1a02b353c 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -118,6 +118,10 @@ final class QueryExecutor { // MARK: - Schema fetch + parse static func fetchTableSchema(connectionId: UUID, tableName: String) async throws -> SchemaResult { + let session = DatabaseManager.shared.session(for: connectionId) + queryExecutorLog.info( + "[fk] schema fetch start table=\(tableName, privacy: .public) db=\(session?.currentDatabase ?? "default", privacy: .public) schema=\(session?.currentSchema ?? "default", privacy: .public)" + ) let (columns, approximateRowCount) = try await DatabaseManager.shared.withMetadataDriver( connectionId: connectionId ) { driver in @@ -136,6 +140,9 @@ final class QueryExecutor { ) foreignKeys = nil } + queryExecutorLog.info( + "[fk] schema fetch done table=\(tableName, privacy: .public) columns=\(columns.count) fks=\(foreignKeys.map { String($0.count) } ?? "failed", privacy: .public)" + ) return (columnInfo: columns, fkInfo: foreignKeys, approximateRowCount: approximateRowCount) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 2803d8ab0..eff95cf97 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1084,6 +1084,11 @@ final class MainContentCoordinator { } else { needsMetadataFetch = false } + if let tableName { + Self.logger.info( + "[fk] metadata decision table=\(tableName, privacy: .public) isEditable=\(isEditable) needsFetch=\(needsMetadataFetch)" + ) + } let connId = connectionId currentQueryTask = Task { [weak self] in diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index ee3ace610..4029e742c 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -1,8 +1,11 @@ import AppKit import Combine +import os import SwiftUI import TableProPluginKit +private let fkTraceLogger = Logger(subsystem: "com.TablePro", category: "DataGrid") + // MARK: - Coordinator @MainActor @@ -689,6 +692,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } enumOrSetColumns = enumSet + if fkSet != fkColumns { + fkTraceLogger.info( + "[fk] grid columns=\(columns.count) fkColumns=\(fkSet.count) fkMeta=\(fkKeys.count)" + ) + } fkColumns = fkSet } From 784a40558861cf1ae8a1260bf91abdcd2937ab0a 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: Fri, 12 Jun 2026 13:17:50 +0700 Subject: [PATCH 3/5] fix(plugin-postgresql): read foreign keys from pg_catalog so non-owner roles see them --- CHANGELOG.md | 1 + .../PostgreSQLPluginDriver.swift | 110 +++++++++++------- 2 files changed, 69 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab67239e..51f2367d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format Query can now be undone with Cmd+Z; the formatting is applied as a single editor edit instead of clearing the undo history. (#1645) - Format Query now formats only the selected text when a selection is active, and the full query when nothing is selected. (#1656) - Foreign key jump arrows no longer disappear after sorting, filtering, or paginating a table, and a failed foreign key lookup is retried on the next load instead of hiding the arrows for the whole session. +- PostgreSQL foreign keys are now read from the system catalogs, so FK jump arrows appear even when the connected role does not own the referenced tables. - Sorting a query result no longer overwrites the SQL editor text or the contents of an opened `.sql` file; the sort runs as a separate query and the editor keeps what you wrote. (#1645) - iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads. - Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index bf9ff9023..79230b574 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -229,27 +229,40 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { let query = """ SELECT - tc.constraint_name, - kcu.column_name, - ccu.table_name AS referenced_table, - ccu.column_name AS referenced_column, - ccu.table_schema AS referenced_schema, - rc.delete_rule, - rc.update_rule - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.referential_constraints rc - ON tc.constraint_name = rc.constraint_name - AND tc.constraint_schema = rc.constraint_schema - JOIN information_schema.constraint_column_usage ccu - ON rc.unique_constraint_name = ccu.constraint_name - AND rc.unique_constraint_schema = ccu.constraint_schema - WHERE tc.table_name = '\(escapeLiteral(table))' - AND tc.table_schema = '\(escapedSchema)' - AND tc.constraint_type = 'FOREIGN KEY' - ORDER BY tc.constraint_name + con.conname, + src_col.attname, + ref_cl.relname AS referenced_table, + ref_col.attname AS referenced_column, + ref_ns.nspname AS referenced_schema, + CASE con.confdeltype + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + WHEN 'r' THEN 'RESTRICT' + ELSE 'NO ACTION' + END AS delete_rule, + CASE con.confupdtype + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + WHEN 'r' THEN 'RESTRICT' + ELSE 'NO ACTION' + END AS update_rule + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class src_cl ON src_cl.oid = con.conrelid + JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src_cl.relnamespace + JOIN pg_catalog.pg_class ref_cl ON ref_cl.oid = con.confrelid + JOIN pg_catalog.pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace + CROSS JOIN LATERAL unnest(con.conkey, con.confkey) + WITH ORDINALITY AS cols(src_attnum, ref_attnum, ord) + JOIN pg_catalog.pg_attribute src_col + ON src_col.attrelid = con.conrelid AND src_col.attnum = cols.src_attnum + JOIN pg_catalog.pg_attribute ref_col + ON ref_col.attrelid = con.confrelid AND ref_col.attnum = cols.ref_attnum + WHERE con.contype = 'f' + AND src_cl.relname = '\(escapeLiteral(table))' + AND src_ns.nspname = '\(escapedSchema)' + ORDER BY con.conname, cols.ord """ let result = try await execute(query: query) let foreignKeys: [PluginForeignKeyInfo] = result.rows.compactMap { row -> PluginForeignKeyInfo? in @@ -276,27 +289,40 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { let query = """ SELECT - tc.table_name, - tc.constraint_name, - kcu.column_name, - ccu.table_name AS referenced_table, - ccu.column_name AS referenced_column, - ccu.table_schema AS referenced_schema, - rc.delete_rule, - rc.update_rule - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.referential_constraints rc - ON tc.constraint_name = rc.constraint_name - AND tc.constraint_schema = rc.constraint_schema - JOIN information_schema.constraint_column_usage ccu - ON rc.unique_constraint_name = ccu.constraint_name - AND rc.unique_constraint_schema = ccu.constraint_schema - WHERE tc.table_schema = '\(escapedSchema)' - AND tc.constraint_type = 'FOREIGN KEY' - ORDER BY tc.table_name, tc.constraint_name + src_cl.relname AS table_name, + con.conname, + src_col.attname, + ref_cl.relname AS referenced_table, + ref_col.attname AS referenced_column, + ref_ns.nspname AS referenced_schema, + CASE con.confdeltype + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + WHEN 'r' THEN 'RESTRICT' + ELSE 'NO ACTION' + END AS delete_rule, + CASE con.confupdtype + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + WHEN 'r' THEN 'RESTRICT' + ELSE 'NO ACTION' + END AS update_rule + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class src_cl ON src_cl.oid = con.conrelid + JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src_cl.relnamespace + JOIN pg_catalog.pg_class ref_cl ON ref_cl.oid = con.confrelid + JOIN pg_catalog.pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace + CROSS JOIN LATERAL unnest(con.conkey, con.confkey) + WITH ORDINALITY AS cols(src_attnum, ref_attnum, ord) + JOIN pg_catalog.pg_attribute src_col + ON src_col.attrelid = con.conrelid AND src_col.attnum = cols.src_attnum + JOIN pg_catalog.pg_attribute ref_col + ON ref_col.attrelid = con.confrelid AND ref_col.attnum = cols.ref_attnum + WHERE con.contype = 'f' + AND src_ns.nspname = '\(escapedSchema)' + ORDER BY src_cl.relname, con.conname, cols.ord """ let result = try await execute(query: query) var grouped: [String: [PluginForeignKeyInfo]] = [:] From 6276f02ac3315d04b55f2f64888afa49014a35a4 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: Fri, 12 Jun 2026 13:34:59 +0700 Subject: [PATCH 4/5] fix(datagrid): rebuild FK column kinds before reloading cells after metadata arrives --- TablePro/Views/Results/DataGridCoordinator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 4029e742c..75f9850e8 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -633,6 +633,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func refreshForeignKeyColumns() { guard let tableView else { return } let tableRows = tableRowsProvider() + rebuildKindSets(from: tableRows) let fkColumnIndices = IndexSet( tableView.tableColumns.enumerated().compactMap { displayIndex, tableColumn in guard tableColumn.identifier != ColumnIdentitySchema.rowNumberIdentifier, From 94a05ef68fb7d94c0031e81219d2e60320113827 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: Fri, 12 Jun 2026 13:46:38 +0700 Subject: [PATCH 5/5] refactor(coordinator): clean up the FK metadata pipeline --- .../QueryExecutionCoordinator+Helpers.swift | 103 ++++++++++-------- ...QueryExecutionCoordinator+Parameters.swift | 2 +- .../Core/Services/Query/QueryExecutor.swift | 39 ++++--- .../MainContentCoordinator+QueryHelpers.swift | 4 +- .../Views/Main/MainContentCoordinator.swift | 2 +- .../Services/Query/QueryExecutorTests.swift | 8 +- 6 files changed, 87 insertions(+), 71 deletions(-) diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index c9f46d27a..de8be7003 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -15,7 +15,7 @@ extension QueryExecutionCoordinator { QueryExecutor.resolveRowCap(sql: sql, tabType: tabType, databaseType: parent.connection.type) } - func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata { + func parseSchemaMetadata(_ schema: FetchedTableSchema) -> ParsedSchemaMetadata { QueryExecutor.parseSchemaMetadata(schema) } @@ -244,7 +244,7 @@ extension QueryExecutionCoordinator { tabId: UUID, capturedGeneration: Int, connectionType: DatabaseType, - schemaTask: Task? + schemaTask: Task? ) { let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql Task(priority: .utility) { [weak self, parent] in @@ -259,58 +259,69 @@ extension QueryExecutionCoordinator { await MainActor.run { [weak self] in guard let self else { return } if let schema { - if parent.tabManager.tabs.contains(where: { - $0.id == tabId && $0.tableContext.tableName == tableName - }) { - applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId) - } else { - helpersLogger.info("[fk] phase2 apply skipped, tab closed or table changed table=\(tableName, privacy: .public)") - } + applySchemaMetadata(schema, tabId: tabId, tableName: tableName) + } + if capturedGeneration == parent.queryGeneration { + resolveRowCount( + tableName: tableName, + tabId: tabId, + capturedGeneration: capturedGeneration, + connectionType: connectionType + ) } - guard capturedGeneration == parent.queryGeneration else { return } - resolveRowCount( - tableName: tableName, - tabId: tabId, - capturedGeneration: capturedGeneration, - connectionType: connectionType - ) } guard !isNonSQL, let schema else { return } let columnEnumValues = await parent.fetchEnumValues( - columnInfo: schema.columnInfo, + columnInfo: schema.columns, tableName: tableName, connectionType: connectionType ) + guard !columnEnumValues.isEmpty else { return } - guard !columnEnumValues.isEmpty else { - return - } await MainActor.run { [weak self] in - guard let self else { return } - guard capturedGeneration == parent.queryGeneration else { return } - guard !Task.isCancelled else { return } - guard parent.tabManager.tabs.contains(where: { $0.id == tabId }) else { return } - let existing = parent.tabSessionRegistry.tableRows(for: tabId) - let hasNewValues = columnEnumValues.contains { key, value in - existing.columnEnumValues[key] != value - } - if hasNewValues { - parent.mutateActiveTableRows(for: tabId) { rows in - for (col, vals) in columnEnumValues { - rows.columnEnumValues[col] = vals - } - return .columnsReplaced - } - parent.tabManager.mutate(tabId: tabId) { $0.metadataVersion += 1 } - if let activeIdx = parent.tabManager.selectedTabIndex, - activeIdx < parent.tabManager.tabs.count, - parent.tabManager.tabs[activeIdx].id == tabId { - parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns() - } - } + guard let self, !Task.isCancelled else { return } + applyEnumValues(columnEnumValues, tabId: tabId, tableName: tableName) + } + } + } + + private func tabShowsTable(_ tabId: UUID, _ tableName: String) -> Bool { + parent.tabManager.tabs.contains { $0.id == tabId && $0.tableContext.tableName == tableName } + } + + private func isActiveTab(_ tabId: UUID) -> Bool { + guard let activeIdx = parent.tabManager.selectedTabIndex, + activeIdx < parent.tabManager.tabs.count else { return false } + return parent.tabManager.tabs[activeIdx].id == tabId + } + + private func applySchemaMetadata(_ schema: FetchedTableSchema, tabId: UUID, tableName: String) { + guard tabShowsTable(tabId, tableName) else { + helpersLogger.info("[fk] phase2 apply skipped, tab closed or table changed table=\(tableName, privacy: .public)") + return + } + applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId) + } + + private func applyEnumValues(_ values: [String: [String]], tabId: UUID, tableName: String) { + guard tabShowsTable(tabId, tableName) else { return } + let existing = parent.tabSessionRegistry.tableRows(for: tabId) + let hasNewValues = values.contains { key, value in + existing.columnEnumValues[key] != value + } + guard hasNewValues else { return } + + parent.mutateActiveTableRows(for: tabId) { rows in + for (col, vals) in values { + rows.columnEnumValues[col] = vals } + return .columnsReplaced + } + parent.tabManager.mutate(tabId: tabId) { $0.metadataVersion += 1 } + if isActiveTab(tabId) { + parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns() } } @@ -341,14 +352,12 @@ extension QueryExecutionCoordinator { parent.changeManager.setPrimaryKeyColumns(parsed.primaryKeyColumns) } - let isActiveTab = parent.tabManager.selectedTabIndex.map { activeIdx in - activeIdx < parent.tabManager.tabs.count && parent.tabManager.tabs[activeIdx].id == tabId - } ?? false - if isActiveTab { + let refreshed = isActiveTab(tabId) + if refreshed { parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns() } helpersLogger.info( - "[fk] phase2 applied tab=\(tabId, privacy: .public) fks=\(parsed.columnForeignKeys?.count ?? -1) defaults=\(parsed.columnDefaults.count) activeTabRefreshed=\(isActiveTab)" + "[fk] phase2 applied tab=\(tabId, privacy: .public) fks=\(parsed.columnForeignKeys?.count ?? -1) defaults=\(parsed.columnDefaults.count) activeTabRefreshed=\(refreshed)" ) } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index c24246c13..f8b1f42af 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -99,7 +99,7 @@ extension QueryExecutionCoordinator { parent.currentQueryTask = Task { [weak self, parent] in guard let self else { return } - let schemaTask: Task? + let schemaTask: Task? if needsMetadataFetch, let tableName { schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) } } else { diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index 1a02b353c..c605b49f1 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -15,7 +15,11 @@ struct QueryFetchResult { let resultColumnMeta: [ResultColumnMeta]? } -typealias SchemaResult = (columnInfo: [ColumnInfo], fkInfo: [ForeignKeyInfo]?, approximateRowCount: Int?) +struct FetchedTableSchema { + let columns: [ColumnInfo] + let foreignKeys: [ForeignKeyInfo]? + let approximateRowCount: Int? +} struct ParsedSchemaMetadata { let columnDefaults: [String: String?] @@ -117,7 +121,7 @@ final class QueryExecutor { // MARK: - Schema fetch + parse - static func fetchTableSchema(connectionId: UUID, tableName: String) async throws -> SchemaResult { + static func fetchTableSchema(connectionId: UUID, tableName: String) async throws -> FetchedTableSchema { let session = DatabaseManager.shared.session(for: connectionId) queryExecutorLog.info( "[fk] schema fetch start table=\(tableName, privacy: .public) db=\(session?.currentDatabase ?? "default", privacy: .public) schema=\(session?.currentSchema ?? "default", privacy: .public)" @@ -129,40 +133,43 @@ final class QueryExecutor { let approximateRowCount = try? await driver.fetchApproximateRowCount(table: tableName) return (columns, approximateRowCount) } - let foreignKeys: [ForeignKeyInfo]? + let foreignKeys = await fetchForeignKeys(connectionId: connectionId, tableName: tableName) + queryExecutorLog.info( + "[fk] schema fetch done table=\(tableName, privacy: .public) columns=\(columns.count) fks=\(foreignKeys.map { String($0.count) } ?? "failed", privacy: .public)" + ) + return FetchedTableSchema(columns: columns, foreignKeys: foreignKeys, approximateRowCount: approximateRowCount) + } + + private static func fetchForeignKeys(connectionId: UUID, tableName: String) async -> [ForeignKeyInfo]? { do { - foreignKeys = try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + return try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchForeignKeys(table: tableName) } } catch { queryExecutorLog.error( - "[fetchTableSchema] FK fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" + "[fk] FK fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" ) - foreignKeys = nil + return nil } - queryExecutorLog.info( - "[fk] schema fetch done table=\(tableName, privacy: .public) columns=\(columns.count) fks=\(foreignKeys.map { String($0.count) } ?? "failed", privacy: .public)" - ) - return (columnInfo: columns, fkInfo: foreignKeys, approximateRowCount: approximateRowCount) } - static func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata { + static func parseSchemaMetadata(_ schema: FetchedTableSchema) -> ParsedSchemaMetadata { var defaults: [String: String?] = [:] var nullable: [String: Bool] = [:] - for col in schema.columnInfo { + for col in schema.columns { defaults[col.name] = col.defaultValue nullable[col.name] = col.isNullable } var fks: [String: ForeignKeyInfo]? - if let fkInfo = schema.fkInfo { + if let foreignKeys = schema.foreignKeys { var byColumn: [String: ForeignKeyInfo] = [:] - for fk in fkInfo { + for fk in foreignKeys { byColumn[fk.column] = fk } fks = byColumn } var enumValues: [String: [String]] = [:] - for col in schema.columnInfo { + for col in schema.columns { if let values = col.allowedValues, !values.isEmpty { enumValues[col.name] = values } @@ -171,7 +178,7 @@ final class QueryExecutor { columnDefaults: defaults, columnForeignKeys: fks, columnNullable: nullable, - primaryKeyColumns: schema.columnInfo.filter { $0.isPrimaryKey }.map(\.name), + primaryKeyColumns: schema.columns.filter { $0.isPrimaryKey }.map(\.name), approximateRowCount: schema.approximateRowCount, columnEnumValues: enumValues ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 7a053f6e5..051ed3a64 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -11,7 +11,7 @@ extension MainContentCoordinator { queryExecutionCoordinator.resolveRowCap(sql: sql, tabType: tabType) } - func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata { + func parseSchemaMetadata(_ schema: FetchedTableSchema) -> ParsedSchemaMetadata { queryExecutionCoordinator.parseSchemaMetadata(schema) } @@ -60,7 +60,7 @@ extension MainContentCoordinator { tabId: UUID, capturedGeneration: Int, connectionType: DatabaseType, - schemaTask: Task? + schemaTask: Task? ) { queryExecutionCoordinator.launchPhase2Work( tableName: tableName, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index eff95cf97..d48235daf 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1094,7 +1094,7 @@ final class MainContentCoordinator { currentQueryTask = Task { [weak self] in guard let self else { return } - let schemaTask: Task? + let schemaTask: Task? if needsMetadataFetch, let tableName { schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) } } else { diff --git a/TableProTests/Core/Services/Query/QueryExecutorTests.swift b/TableProTests/Core/Services/Query/QueryExecutorTests.swift index b7ecdbf54..1c522d21b 100644 --- a/TableProTests/Core/Services/Query/QueryExecutorTests.swift +++ b/TableProTests/Core/Services/Query/QueryExecutorTests.swift @@ -178,7 +178,7 @@ struct QueryExecutorTests { referencedTable: "roles", referencedColumn: "id" ) ] - let schema: SchemaResult = (columnInfo: columns, fkInfo: fks, approximateRowCount: 1_234) + let schema = FetchedTableSchema(columns: columns, foreignKeys: fks, approximateRowCount: 1_234) let parsed = QueryExecutor.parseSchemaMetadata(schema) @@ -201,7 +201,7 @@ struct QueryExecutorTests { defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil ) ] - let schema: SchemaResult = (columnInfo: columns, fkInfo: [], approximateRowCount: nil) + let schema = FetchedTableSchema(columns: columns, foreignKeys: [], approximateRowCount: nil) let parsed = QueryExecutor.parseSchemaMetadata(schema) @@ -211,14 +211,14 @@ struct QueryExecutorTests { @Test("parseSchemaMetadata keeps a failed foreign key fetch distinguishable from zero foreign keys") func parseSchemaMetadataNilForeignKeys() { - let schema: SchemaResult = (columnInfo: [], fkInfo: nil, approximateRowCount: nil) + let schema = FetchedTableSchema(columns: [], foreignKeys: nil, approximateRowCount: nil) let parsed = QueryExecutor.parseSchemaMetadata(schema) #expect(parsed.columnForeignKeys == nil) } @Test("parseSchemaMetadata returns empty containers when input is empty") func parseSchemaMetadataEmpty() { - let schema: SchemaResult = (columnInfo: [], fkInfo: [], approximateRowCount: nil) + let schema = FetchedTableSchema(columns: [], foreignKeys: [], approximateRowCount: nil) let parsed = QueryExecutor.parseSchemaMetadata(schema) #expect(parsed.primaryKeyColumns.isEmpty) #expect(parsed.columnDefaults.isEmpty)