Skip to content

Commit 0b1cd62

Browse files
authored
Merge pull request #240 from datlechin/fix/stale-filter-on-restore
fix: clear stale filters and tables on database/schema switch
2 parents 1ddbf3c + 5f9b407 commit 0b1cd62

5 files changed

Lines changed: 87 additions & 92 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Stale filter causing repeated errors when restoring tabs after schema/database switch (#237)
13+
- Sidebar showing old tables during database/schema switch instead of loading state
14+
1015
## [0.16.0] - 2026-03-09
1116

1217
### Fixed

TablePro/Models/Query/QueryTab.swift

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,27 @@ struct QueryTab: Identifiable, Equatable {
424424
self.metadataVersion = 0
425425
}
426426

427+
/// Build a clean base query for a table tab (no filters/sort).
428+
/// Used when restoring table tabs from persistence to avoid stale WHERE clauses.
429+
@MainActor static func buildBaseTableQuery(tableName: String, databaseType: DatabaseType) -> String {
430+
let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize
431+
if databaseType == .mongodb {
432+
let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
433+
return "db[\"\(escaped)\"].find({}).limit(\(pageSize))"
434+
} else if databaseType == .redis {
435+
return "SCAN 0 MATCH * COUNT \(pageSize)"
436+
} else if databaseType == .mssql {
437+
let quotedName = databaseType.quoteIdentifier(tableName)
438+
return "SELECT * FROM \(quotedName) ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;"
439+
} else if databaseType == .oracle {
440+
let quotedName = databaseType.quoteIdentifier(tableName)
441+
return "SELECT * FROM \(quotedName) ORDER BY 1 OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;"
442+
} else {
443+
let quotedName = databaseType.quoteIdentifier(tableName)
444+
return "SELECT * FROM \(quotedName) LIMIT \(pageSize);"
445+
}
446+
}
447+
427448
/// Maximum query size to persist (500KB). Queries larger than this are typically
428449
/// imported SQL dumps — serializing them to JSON blocks the main thread.
429450
static let maxPersistableQuerySize = 500_000
@@ -516,22 +537,7 @@ final class QueryTabManager {
516537
}
517538

518539
let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize
519-
let query: String
520-
if databaseType == .mongodb {
521-
let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
522-
query = "db[\"\(escaped)\"].find({}).limit(\(pageSize))"
523-
} else if databaseType == .redis {
524-
query = "SCAN 0 MATCH * COUNT \(pageSize)"
525-
} else if databaseType == .mssql {
526-
let quotedName = databaseType.quoteIdentifier(tableName)
527-
query = "SELECT * FROM \(quotedName) ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;"
528-
} else if databaseType == .oracle {
529-
let quotedName = databaseType.quoteIdentifier(tableName)
530-
query = "SELECT * FROM \(quotedName) ORDER BY 1 OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;"
531-
} else {
532-
let quotedName = databaseType.quoteIdentifier(tableName)
533-
query = "SELECT * FROM \(quotedName) LIMIT \(pageSize);"
534-
}
540+
let query = QueryTab.buildBaseTableQuery(tableName: tableName, databaseType: databaseType)
535541
var newTab = QueryTab(
536542
title: tableName,
537543
query: query,

TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift

Lines changed: 43 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -299,47 +299,45 @@ extension MainContentCoordinator {
299299
isSwitchingDatabase = false
300300
}
301301

302+
// Clear stale filter state from previous database/schema
303+
filterStateManager.clearAll()
304+
302305
guard let driver = DatabaseManager.shared.driver(for: connectionId) else {
303306
return
304307
}
305308

309+
// Snapshot current state for rollback on failure
310+
let previousDatabase = toolbarState.databaseName
311+
312+
// Immediately clear UI state so the sidebar shows a loading spinner
313+
// instead of stale tables from the previous database/schema.
314+
toolbarState.databaseName = database
315+
closeSiblingNativeWindows()
316+
tabManager.tabs = []
317+
tabManager.selectedTabId = nil
318+
DatabaseManager.shared.updateSession(connectionId) { session in
319+
session.tables = []
320+
}
321+
// Yield so SwiftUI renders the empty/loading state before async work begins
322+
await Task.yield()
323+
306324
do {
307325
// For MySQL/MariaDB/ClickHouse, use USE command
308326
if connection.type == .mysql || connection.type == .mariadb || connection.type == .clickhouse {
309327
_ = try await driver.execute(query: "USE `\(database)`")
310328

311-
// Update session with new database
312329
DatabaseManager.shared.updateSession(connectionId) { session in
313330
session.currentDatabase = database
314-
session.tables = [] // triggers SidebarView.loadTables() via onChange
315331
}
316332

317-
// Update toolbar state
318-
toolbarState.databaseName = database
319-
320-
// Close sibling native window-tabs and clear in-app tabs —
321-
// previous database's tables/queries are no longer valid
322-
closeSiblingNativeWindows()
323-
tabManager.tabs = []
324-
tabManager.selectedTabId = nil
325-
326-
// Reload schema for autocomplete.
327-
// session.tables was cleared above, which triggers SidebarView.loadTables() via onChange.
328333
await loadSchema()
329334
} else if connection.type == .postgresql {
330335
DatabaseManager.shared.updateSession(connectionId) { session in
331336
session.connection.database = database
332337
session.currentDatabase = database
333338
session.currentSchema = nil
334-
session.tables = [] // triggers SidebarView.loadTables() via onChange
335339
}
336340

337-
toolbarState.databaseName = database
338-
339-
closeSiblingNativeWindows()
340-
tabManager.tabs = []
341-
tabManager.selectedTabId = nil
342-
343341
await DatabaseManager.shared.reconnectSession(connectionId)
344342

345343
await loadSchema()
@@ -349,42 +347,21 @@ extension MainContentCoordinator {
349347
guard let schemaDriver = driver as? SchemaSwitchable else { return }
350348
try await schemaDriver.switchSchema(to: database)
351349

352-
// Update session
353350
DatabaseManager.shared.updateSession(connectionId) { session in
354351
session.currentSchema = database
355-
session.tables = [] // triggers SidebarView.loadTables() via onChange
356352
}
357353

358-
// Update toolbar state
359-
toolbarState.databaseName = database
360-
361-
// Close sibling native window-tabs and clear in-app tabs —
362-
// previous schema's tables/queries are no longer valid
363-
closeSiblingNativeWindows()
364-
tabManager.tabs = []
365-
tabManager.selectedTabId = nil
366-
367-
// Reload schema for autocomplete
368354
await loadSchema()
369355

370-
// Force sidebar reload — posting .refreshData ensures loadTables() runs
371-
// even when session.tables was already [] (e.g. switching from empty schema back to public)
372356
NotificationCenter.default.post(name: .refreshData, object: nil)
373357
} else if connection.type == .oracle {
374358
guard let schemaDriver = driver as? SchemaSwitchable else { return }
375359
try await schemaDriver.switchSchema(to: database)
376360

377361
DatabaseManager.shared.updateSession(connectionId) { session in
378362
session.currentSchema = database
379-
session.tables = []
380363
}
381364

382-
toolbarState.databaseName = database
383-
384-
closeSiblingNativeWindows()
385-
tabManager.tabs = []
386-
tabManager.selectedTabId = nil
387-
388365
await loadSchema()
389366

390367
NotificationCenter.default.post(name: .refreshData, object: nil)
@@ -396,43 +373,25 @@ extension MainContentCoordinator {
396373
DatabaseManager.shared.updateSession(connectionId) { session in
397374
session.currentDatabase = database
398375
session.currentSchema = "dbo"
399-
session.tables = []
400376
}
401377
AppSettingsStorage.shared.saveLastDatabase(database, for: connectionId)
402378

403-
toolbarState.databaseName = database
404-
405-
closeSiblingNativeWindows()
406-
tabManager.tabs = []
407-
tabManager.selectedTabId = nil
408-
409379
await loadSchema()
410380

411381
NotificationCenter.default.post(name: .refreshData, object: nil)
412382
} else if connection.type == .mongodb {
413-
// MongoDB: update the driver's connection so fetchTables/execute use the new database
414383
if let adapter = driver as? PluginDriverAdapter {
415384
try await adapter.switchDatabase(to: database)
416385
}
417386

418387
DatabaseManager.shared.updateSession(connectionId) { session in
419388
session.currentDatabase = database
420-
session.tables = []
421389
}
422390

423-
toolbarState.databaseName = database
424-
425-
// Close sibling native window-tabs and clear in-app tabs —
426-
// previous database's collections are no longer valid
427-
closeSiblingNativeWindows()
428-
tabManager.tabs = []
429-
tabManager.selectedTabId = nil
430-
431391
await loadSchema()
432392

433393
NotificationCenter.default.post(name: .refreshData, object: nil)
434394
} else if connection.type == .redis {
435-
// Redis: SELECT <db index> to switch logical database
436395
guard let dbIndex = Int(database) else { return }
437396

438397
if let adapter = driver as? PluginDriverAdapter {
@@ -441,20 +400,18 @@ extension MainContentCoordinator {
441400

442401
DatabaseManager.shared.updateSession(connectionId) { session in
443402
session.currentDatabase = database
444-
session.tables = []
445403
}
446404

447-
toolbarState.databaseName = database
448-
449-
closeSiblingNativeWindows()
450-
tabManager.tabs = []
451-
tabManager.selectedTabId = nil
452-
453405
await loadSchema()
454406

455407
NotificationCenter.default.post(name: .refreshData, object: nil)
456408
}
457409
} catch {
410+
// Restore toolbar to previous database on failure
411+
toolbarState.databaseName = previousDatabase
412+
// Reload previous tables so sidebar isn't left empty
413+
NotificationCenter.default.post(name: .refreshData, object: nil)
414+
458415
navigationLogger.error("Failed to switch database: \(error.localizedDescription, privacy: .public)")
459416
AlertHelper.showErrorSheet(
460417
title: String(localized: "Database Switch Failed"),
@@ -469,25 +426,38 @@ extension MainContentCoordinator {
469426
guard connection.type == .postgresql else { return }
470427
guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return }
471428

429+
// Clear stale filter state from previous schema
430+
filterStateManager.clearAll()
431+
432+
// Snapshot current state for rollback on failure
433+
let previousSchema = toolbarState.databaseName
434+
435+
// Immediately clear UI state so sidebar shows loading state
436+
toolbarState.databaseName = schema
437+
closeSiblingNativeWindows()
438+
tabManager.tabs = []
439+
tabManager.selectedTabId = nil
440+
DatabaseManager.shared.updateSession(connectionId) { session in
441+
session.tables = []
442+
}
443+
await Task.yield()
444+
472445
do {
473446
guard let schemaDriver = driver as? SchemaSwitchable else { return }
474447
try await schemaDriver.switchSchema(to: schema)
475448

476449
DatabaseManager.shared.updateSession(connectionId) { session in
477450
session.currentSchema = schema
478-
session.tables = []
479451
}
480452

481-
toolbarState.databaseName = schema
482-
483-
closeSiblingNativeWindows()
484-
tabManager.tabs = []
485-
tabManager.selectedTabId = nil
486-
487453
await loadSchema()
488454

489455
NotificationCenter.default.post(name: .refreshData, object: nil)
490456
} catch {
457+
// Restore toolbar to previous schema on failure
458+
toolbarState.databaseName = previousSchema
459+
NotificationCenter.default.post(name: .refreshData, object: nil)
460+
491461
navigationLogger.error("Failed to switch schema: \(error.localizedDescription, privacy: .public)")
492462
AlertHelper.showErrorSheet(
493463
title: String(localized: "Schema Switch Failed"),

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ final class MainContentCoordinator {
351351

352352
func loadSchema() async {
353353
guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return }
354+
await schemaProvider.invalidateCache()
354355
await schemaProvider.loadSchema(using: driver, connection: connection)
355356
}
356357

TablePro/Views/Main/MainContentView.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,17 +477,30 @@ struct MainContentView: View {
477477
// No existing windows -- restore tabs from storage (first window on connection)
478478
let result = await coordinator.persistence.restoreFromDisk()
479479
if !result.tabs.isEmpty {
480+
// Rebuild base queries for table tabs to strip stale filter/sort WHERE clauses.
481+
// Filter state is not persisted, so the stored query may contain orphaned conditions
482+
// that reference columns from a different schema — causing errors on restore.
483+
var restoredTabs = result.tabs
484+
for i in restoredTabs.indices where restoredTabs[i].tabType == .table {
485+
if let tableName = restoredTabs[i].tableName {
486+
restoredTabs[i].query = QueryTab.buildBaseTableQuery(
487+
tableName: tableName,
488+
databaseType: connection.type
489+
)
490+
}
491+
}
492+
480493
// Find the selected tab, or use the first one
481494
let selectedId = result.selectedTabId
482-
let selectedIndex = result.tabs.firstIndex(where: { $0.id == selectedId }) ?? 0
495+
let selectedIndex = restoredTabs.firstIndex(where: { $0.id == selectedId }) ?? 0
483496

484497
// Keep only the selected tab for this window
485-
let selectedTab = result.tabs[selectedIndex]
498+
let selectedTab = restoredTabs[selectedIndex]
486499
tabManager.tabs = [selectedTab]
487500
tabManager.selectedTabId = selectedTab.id
488501

489502
// Open remaining tabs as new native window-tabs
490-
let remainingTabs = result.tabs.enumerated()
503+
let remainingTabs = restoredTabs.enumerated()
491504
.filter { $0.offset != selectedIndex }
492505
.map(\.element)
493506

0 commit comments

Comments
 (0)