diff --git a/CHANGELOG.md b/CHANGELOG.md index 982c41389..51f2367d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ 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/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..79230b574 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -229,30 +229,43 @@ 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) - 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,32 +282,47 @@ 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]] { 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]] = [:] diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index a1e9067f1..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) } @@ -25,21 +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 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 @@ -85,10 +85,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 +100,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 +113,8 @@ extension QueryExecutionCoordinator { columnDefaults: columnDefaults, columnForeignKeys: columnForeignKeys, columnEnumValues: columnEnumValues, - columnNullable: columnNullable + columnNullable: columnNullable, + foreignKeysFetched: foreignKeysFetched ) parent.setActiveTableRows(newTableRows, for: existingTabId) @@ -239,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 @@ -247,59 +252,79 @@ 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 } - guard capturedGeneration == parent.queryGeneration else { return } if let schema { - applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId) + applySchemaMetadata(schema, tabId: tabId, tableName: tableName) + } + if capturedGeneration == parent.queryGeneration { + resolveRowCount( + tableName: tableName, + tabId: tabId, + capturedGeneration: capturedGeneration, + connectionType: connectionType + ) } - 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() + } + } + private func applyPhase2Metadata(parsed: ParsedSchemaMetadata, tabId: UUID) { guard parent.tabManager.tabs.contains(where: { $0.id == tabId }) else { return } @@ -327,11 +352,13 @@ 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 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=\(refreshed)" + ) } func launchPhase2Count( 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 ec77ab046..c605b49f1 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -15,11 +15,15 @@ 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?] - let columnForeignKeys: [String: ForeignKeyInfo] + let columnForeignKeys: [String: ForeignKeyInfo]? let columnNullable: [String: Bool] let primaryKeyColumns: [String] let approximateRowCount: Int? @@ -117,28 +121,55 @@ 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 + 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)" + ) + 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 = 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) } - static func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata { + private static func fetchForeignKeys(connectionId: UUID, tableName: String) async -> [ForeignKeyInfo]? { + do { + return try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + try await driver.fetchForeignKeys(table: tableName) + } + } catch { + queryExecutorLog.error( + "[fk] FK fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + return nil + } + } + + static func parseSchemaMetadata(_ schema: FetchedTableSchema) -> ParsedSchemaMetadata { var defaults: [String: String?] = [:] - var fks: [String: ForeignKeyInfo] = [:] var nullable: [String: Bool] = [:] - for col in schema.columnInfo { + for col in schema.columns { 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 foreignKeys = schema.foreignKeys { + var byColumn: [String: ForeignKeyInfo] = [:] + 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 } @@ -147,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 ) @@ -165,7 +196,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/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 2803d8ab0..d48235daf 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1084,12 +1084,17 @@ 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 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/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index ee3ace610..75f9850e8 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 @@ -630,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, @@ -689,6 +693,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 } diff --git a/TableProTests/Core/Services/Query/QueryExecutorTests.swift b/TableProTests/Core/Services/Query/QueryExecutorTests.swift index 5f074f157..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) @@ -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) } @@ -201,21 +201,29 @@ 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) #expect(parsed.columnEnumValues["status"] == ["open", "closed", "archived"]) } + + @Test("parseSchemaMetadata keeps a failed foreign key fetch distinguishable from zero foreign keys") + func parseSchemaMetadataNilForeignKeys() { + 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) #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) + } }