Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 22 additions & 16 deletions TablePro/Models/Query/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
116 changes: 43 additions & 73 deletions TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +311 to +314

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Defer tab/session reset until switch succeeds

switchDatabase now clears UI/session state (toolbarState.databaseName, open tabs, sibling windows, and filter state) before any of the throwing switch operations run. If USE, switchDatabase, switchSchema, or reconnectSession fails, the catch path only shows an error and does not restore prior state, so users can lose their open tabs/windows and see the toolbar pointing at a database that was never actually switched to; this is a regression in failure scenarios and the reset should be moved after success (the same failure pattern is present in switchSchema).

Useful? React with 👍 / 👎.

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()
Expand All @@ -349,42 +347,21 @@ 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 }
try await schemaDriver.switchSchema(to: database)

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)
Expand All @@ -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 <db index> to switch logical database
guard let dbIndex = Int(database) else { return }

if let adapter = driver as? PluginDriverAdapter {
Expand All @@ -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"),
Expand All @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ final class MainContentCoordinator {

func loadSchema() async {
guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return }
await schemaProvider.invalidateCache()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove unconditional cache invalidation from loadSchema

Calling invalidateCache() at the start of every loadSchema() introduces a race when schema loads are triggered close together (for example, PostgreSQL switch calls loadSchema() and reconnectSession also emits .databaseDidConnect, whose handler calls coordinator.loadSchema()). If one load is in flight, a second call can clear cachedDriver via invalidateCache() and then return early because isLoading is true, leaving schema tables loaded but column fetches/autocomplete broken until another reload.

Useful? React with 👍 / 👎.

await schemaProvider.loadSchema(using: driver, connection: connection)
}

Expand Down
19 changes: 16 additions & 3 deletions TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down