diff --git a/CHANGELOG.md b/CHANGELOG.md index 750d92f6..d2e43e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Stale filter causing repeated errors when restoring tabs after schema/database switch (#237) +- Sidebar showing old tables during database/schema switch instead of loading state + ## [0.16.0] - 2026-03-09 ### Fixed diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index b5db3f37..4ef7f82e 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -424,6 +424,27 @@ struct QueryTab: Identifiable, Equatable { self.metadataVersion = 0 } + /// Build a clean base query for a table tab (no filters/sort). + /// Used when restoring table tabs from persistence to avoid stale WHERE clauses. + @MainActor static func buildBaseTableQuery(tableName: String, databaseType: DatabaseType) -> String { + let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize + if databaseType == .mongodb { + let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + return "db[\"\(escaped)\"].find({}).limit(\(pageSize))" + } else if databaseType == .redis { + return "SCAN 0 MATCH * COUNT \(pageSize)" + } else if databaseType == .mssql { + let quotedName = databaseType.quoteIdentifier(tableName) + return "SELECT * FROM \(quotedName) ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" + } else if databaseType == .oracle { + let quotedName = databaseType.quoteIdentifier(tableName) + return "SELECT * FROM \(quotedName) ORDER BY 1 OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" + } else { + let quotedName = databaseType.quoteIdentifier(tableName) + return "SELECT * FROM \(quotedName) LIMIT \(pageSize);" + } + } + /// Maximum query size to persist (500KB). Queries larger than this are typically /// imported SQL dumps — serializing them to JSON blocks the main thread. static let maxPersistableQuerySize = 500_000 @@ -516,22 +537,7 @@ final class QueryTabManager { } let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize - let query: String - if databaseType == .mongodb { - let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") - query = "db[\"\(escaped)\"].find({}).limit(\(pageSize))" - } else if databaseType == .redis { - query = "SCAN 0 MATCH * COUNT \(pageSize)" - } else if databaseType == .mssql { - let quotedName = databaseType.quoteIdentifier(tableName) - query = "SELECT * FROM \(quotedName) ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" - } else if databaseType == .oracle { - let quotedName = databaseType.quoteIdentifier(tableName) - query = "SELECT * FROM \(quotedName) ORDER BY 1 OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" - } else { - let quotedName = databaseType.quoteIdentifier(tableName) - query = "SELECT * FROM \(quotedName) LIMIT \(pageSize);" - } + let query = QueryTab.buildBaseTableQuery(tableName: tableName, databaseType: databaseType) var newTab = QueryTab( title: tableName, query: query, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index f8d248cd..8ab1cd70 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -299,47 +299,45 @@ extension MainContentCoordinator { isSwitchingDatabase = false } + // Clear stale filter state from previous database/schema + filterStateManager.clearAll() + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + // Snapshot current state for rollback on failure + let previousDatabase = toolbarState.databaseName + + // Immediately clear UI state so the sidebar shows a loading spinner + // instead of stale tables from the previous database/schema. + toolbarState.databaseName = database + closeSiblingNativeWindows() + tabManager.tabs = [] + tabManager.selectedTabId = nil + DatabaseManager.shared.updateSession(connectionId) { session in + session.tables = [] + } + // Yield so SwiftUI renders the empty/loading state before async work begins + await Task.yield() + do { // For MySQL/MariaDB/ClickHouse, use USE command if connection.type == .mysql || connection.type == .mariadb || connection.type == .clickhouse { _ = try await driver.execute(query: "USE `\(database)`") - // Update session with new database DatabaseManager.shared.updateSession(connectionId) { session in session.currentDatabase = database - session.tables = [] // triggers SidebarView.loadTables() via onChange } - // Update toolbar state - toolbarState.databaseName = database - - // Close sibling native window-tabs and clear in-app tabs — - // previous database's tables/queries are no longer valid - closeSiblingNativeWindows() - tabManager.tabs = [] - tabManager.selectedTabId = nil - - // Reload schema for autocomplete. - // session.tables was cleared above, which triggers SidebarView.loadTables() via onChange. await loadSchema() } else if connection.type == .postgresql { DatabaseManager.shared.updateSession(connectionId) { session in session.connection.database = database session.currentDatabase = database session.currentSchema = nil - session.tables = [] // triggers SidebarView.loadTables() via onChange } - toolbarState.databaseName = database - - closeSiblingNativeWindows() - tabManager.tabs = [] - tabManager.selectedTabId = nil - await DatabaseManager.shared.reconnectSession(connectionId) await loadSchema() @@ -349,26 +347,12 @@ extension MainContentCoordinator { guard let schemaDriver = driver as? SchemaSwitchable else { return } try await schemaDriver.switchSchema(to: database) - // Update session DatabaseManager.shared.updateSession(connectionId) { session in session.currentSchema = database - session.tables = [] // triggers SidebarView.loadTables() via onChange } - // Update toolbar state - toolbarState.databaseName = database - - // Close sibling native window-tabs and clear in-app tabs — - // previous schema's tables/queries are no longer valid - closeSiblingNativeWindows() - tabManager.tabs = [] - tabManager.selectedTabId = nil - - // Reload schema for autocomplete await loadSchema() - // Force sidebar reload — posting .refreshData ensures loadTables() runs - // even when session.tables was already [] (e.g. switching from empty schema back to public) NotificationCenter.default.post(name: .refreshData, object: nil) } else if connection.type == .oracle { guard let schemaDriver = driver as? SchemaSwitchable else { return } @@ -376,15 +360,8 @@ extension MainContentCoordinator { DatabaseManager.shared.updateSession(connectionId) { session in session.currentSchema = database - session.tables = [] } - toolbarState.databaseName = database - - closeSiblingNativeWindows() - tabManager.tabs = [] - tabManager.selectedTabId = nil - await loadSchema() NotificationCenter.default.post(name: .refreshData, object: nil) @@ -396,43 +373,25 @@ extension MainContentCoordinator { DatabaseManager.shared.updateSession(connectionId) { session in session.currentDatabase = database session.currentSchema = "dbo" - session.tables = [] } AppSettingsStorage.shared.saveLastDatabase(database, for: connectionId) - toolbarState.databaseName = database - - closeSiblingNativeWindows() - tabManager.tabs = [] - tabManager.selectedTabId = nil - await loadSchema() NotificationCenter.default.post(name: .refreshData, object: nil) } else if connection.type == .mongodb { - // MongoDB: update the driver's connection so fetchTables/execute use the new database if let adapter = driver as? PluginDriverAdapter { try await adapter.switchDatabase(to: database) } DatabaseManager.shared.updateSession(connectionId) { session in session.currentDatabase = database - session.tables = [] } - toolbarState.databaseName = database - - // Close sibling native window-tabs and clear in-app tabs — - // previous database's collections are no longer valid - closeSiblingNativeWindows() - tabManager.tabs = [] - tabManager.selectedTabId = nil - await loadSchema() NotificationCenter.default.post(name: .refreshData, object: nil) } else if connection.type == .redis { - // Redis: SELECT to switch logical database guard let dbIndex = Int(database) else { return } if let adapter = driver as? PluginDriverAdapter { @@ -441,20 +400,18 @@ extension MainContentCoordinator { DatabaseManager.shared.updateSession(connectionId) { session in session.currentDatabase = database - session.tables = [] } - toolbarState.databaseName = database - - closeSiblingNativeWindows() - tabManager.tabs = [] - tabManager.selectedTabId = nil - await loadSchema() NotificationCenter.default.post(name: .refreshData, object: nil) } } catch { + // Restore toolbar to previous database on failure + toolbarState.databaseName = previousDatabase + // Reload previous tables so sidebar isn't left empty + NotificationCenter.default.post(name: .refreshData, object: nil) + navigationLogger.error("Failed to switch database: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( title: String(localized: "Database Switch Failed"), @@ -469,25 +426,38 @@ extension MainContentCoordinator { guard connection.type == .postgresql else { return } guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + // Clear stale filter state from previous schema + filterStateManager.clearAll() + + // Snapshot current state for rollback on failure + let previousSchema = toolbarState.databaseName + + // Immediately clear UI state so sidebar shows loading state + toolbarState.databaseName = schema + closeSiblingNativeWindows() + tabManager.tabs = [] + tabManager.selectedTabId = nil + DatabaseManager.shared.updateSession(connectionId) { session in + session.tables = [] + } + await Task.yield() + do { guard let schemaDriver = driver as? SchemaSwitchable else { return } try await schemaDriver.switchSchema(to: schema) DatabaseManager.shared.updateSession(connectionId) { session in session.currentSchema = schema - session.tables = [] } - toolbarState.databaseName = schema - - closeSiblingNativeWindows() - tabManager.tabs = [] - tabManager.selectedTabId = nil - await loadSchema() NotificationCenter.default.post(name: .refreshData, object: nil) } catch { + // Restore toolbar to previous schema on failure + toolbarState.databaseName = previousSchema + NotificationCenter.default.post(name: .refreshData, object: nil) + navigationLogger.error("Failed to switch schema: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( title: String(localized: "Schema Switch Failed"), diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 07db829e..e829cc8e 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -351,6 +351,7 @@ final class MainContentCoordinator { func loadSchema() async { guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + await schemaProvider.invalidateCache() await schemaProvider.loadSchema(using: driver, connection: connection) } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index a29b286a..b0b24d0c 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -477,17 +477,30 @@ struct MainContentView: View { // No existing windows -- restore tabs from storage (first window on connection) let result = await coordinator.persistence.restoreFromDisk() if !result.tabs.isEmpty { + // Rebuild base queries for table tabs to strip stale filter/sort WHERE clauses. + // Filter state is not persisted, so the stored query may contain orphaned conditions + // that reference columns from a different schema — causing errors on restore. + var restoredTabs = result.tabs + for i in restoredTabs.indices where restoredTabs[i].tabType == .table { + if let tableName = restoredTabs[i].tableName { + restoredTabs[i].query = QueryTab.buildBaseTableQuery( + tableName: tableName, + databaseType: connection.type + ) + } + } + // Find the selected tab, or use the first one let selectedId = result.selectedTabId - let selectedIndex = result.tabs.firstIndex(where: { $0.id == selectedId }) ?? 0 + let selectedIndex = restoredTabs.firstIndex(where: { $0.id == selectedId }) ?? 0 // Keep only the selected tab for this window - let selectedTab = result.tabs[selectedIndex] + let selectedTab = restoredTabs[selectedIndex] tabManager.tabs = [selectedTab] tabManager.selectedTabId = selectedTab.id // Open remaining tabs as new native window-tabs - let remainingTabs = result.tabs.enumerated() + let remainingTabs = restoredTabs.enumerated() .filter { $0.offset != selectedIndex } .map(\.element)