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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SSH port field accepting invalid values
- DROP TABLE and TRUNCATE TABLE sidebar operations producing no SQL for plugin-based drivers
- Foreign key navigation arrows not appearing after switching databases with Cmd+K on MySQL
- Sidebar not refreshing after creating or dropping tables
- Dropping a table disconnecting the database when the dropped table's tab was active

## [0.19.1] - 2026-03-16

Expand Down
15 changes: 15 additions & 0 deletions TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,21 @@ actor SQLSchemaProvider {
cachedDriver = nil
}

func invalidateTables() {
tables.removeAll()
}

func updateTables(_ newTables: [TableInfo]) {
tables = newTables
}

func fetchFreshTables() async throws -> [TableInfo]? {
guard let driver = cachedDriver else { return nil }
let fresh = try await driver.fetchTables()
tables = fresh
return fresh
}

/// Find table name from alias
func resolveAlias(_ aliasOrName: String, in references: [TableReference]) -> String? {
// First check if it's an alias
Expand Down
28 changes: 17 additions & 11 deletions TablePro/ViewModels/SidebarViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import SwiftUI

/// Abstraction over table fetching for testability
protocol TableFetcher: Sendable {
func fetchTables() async throws -> [TableInfo]
func fetchTables(force: Bool) async throws -> [TableInfo]
}

/// Production implementation that uses DatabaseManager, with optional schema provider cache
Expand All @@ -26,17 +26,23 @@ struct LiveTableFetcher: TableFetcher {
self.schemaProvider = schemaProvider
}

func fetchTables() async throws -> [TableInfo] {
func fetchTables(force: Bool) async throws -> [TableInfo] {
if let provider = schemaProvider {
let cached = await provider.getTables()
if !cached.isEmpty {
return cached
if force {
if let fresh = try await provider.fetchFreshTables() { return fresh }
} else {
let cached = await provider.getTables()
if !cached.isEmpty { return cached }
}
}
guard let driver = await DatabaseManager.shared.driver(for: connectionId) else {
return []
}
return try await driver.fetchTables()
let fetched = try await driver.fetchTables()
if let provider = schemaProvider {
await provider.updateTables(fetched)
}
return fetched
}
}

Expand Down Expand Up @@ -145,27 +151,27 @@ final class SidebarViewModel {

// MARK: - Table Loading

func loadTables() {
func loadTables(force: Bool = false) {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
loadTask = Task {
await loadTablesAsync()
await loadTablesAsync(force: force)
}
}

func forceLoadTables() {
loadTask?.cancel()
loadTask = nil
isLoading = false
loadTables()
loadTables(force: true)
}

private func loadTablesAsync() async {
private func loadTablesAsync(force: Bool = false) async {
let previousSelectedName: String? = tables.isEmpty ? nil : selectedTables.first?.name

do {
let fetchedTables = try await tableFetcher.fetchTables()
let fetchedTables = try await tableFetcher.fetchTables(force: force)
tables = fetchedTables

// Clean up stale entries for tables that no longer exist
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,24 @@ extension MainContentCoordinator {
}

if clearTableOps {
// Close tabs for deleted tables
// Remove tabs for deleted tables
if !deletedTables.isEmpty {
if let currentTab = tabManager.selectedTab,
let tableName = currentTab.tableName,
deletedTables.contains(tableName) {
NSApp.keyWindow?.close()
let tabIdsToRemove = Set(
tabManager.tabs
.filter { $0.tabType == .table && deletedTables.contains($0.tableName ?? "") }
.map(\.id)
)

if !tabIdsToRemove.isEmpty {
let firstRemovedIndex = tabManager.tabs
.firstIndex { tabIdsToRemove.contains($0.id) } ?? 0
tabManager.tabs.removeAll { tabIdsToRemove.contains($0.id) }
if !tabManager.tabs.isEmpty {
let neighborIndex = min(firstRemovedIndex, tabManager.tabs.count - 1)
tabManager.selectedTabId = tabManager.tabs[neighborIndex].id
} else {
tabManager.selectedTabId = nil
}
}
}

Expand Down
5 changes: 4 additions & 1 deletion TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,10 @@ final class MainContentCoordinator {
}

func reloadSidebar() {
sidebarViewModel?.forceLoadTables()
Task { @MainActor in
await schemaProvider.invalidateTables()
sidebarViewModel?.forceLoadTables()
}
}

/// Explicit cleanup called from `onDisappear`. Releases schema provider
Expand Down
61 changes: 57 additions & 4 deletions TableProTests/ViewModels/LiveTableFetcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ struct LiveTableFetcherTests {
#expect(initialCallCount == 1)

let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)
let result = try await fetcher.fetchTables()
let result = try await fetcher.fetchTables(force: false)

#expect(result.count == 3)
#expect(result.map(\.name) == ["users", "orders", "products"])
Expand All @@ -102,15 +102,15 @@ struct LiveTableFetcherTests {
let provider = SQLSchemaProvider()

let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)
let result = try await fetcher.fetchTables()
let result = try await fetcher.fetchTables(force: false)

#expect(result.isEmpty)
}

@Test("works without schema provider using direct driver fetch")
func worksWithoutSchemaProvider() async throws {
let fetcher = LiveTableFetcher(connectionId: UUID())
let result = try await fetcher.fetchTables()
let result = try await fetcher.fetchTables(force: false)

#expect(result.isEmpty)
}
Expand All @@ -131,11 +131,64 @@ struct LiveTableFetcherTests {
let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)

for _ in 0..<3 {
let result = try await fetcher.fetchTables()
let result = try await fetcher.fetchTables(force: false)
#expect(result.count == 2)
#expect(result.map(\.name) == ["accounts", "transactions"])
}

#expect(mockDriver.fetchTablesCallCount == 1)
}

@Test("force: true bypasses schema provider cache and hits driver")
func forceBypassesCache() async throws {
let initialTables = [
TestFixtures.makeTableInfo(name: "users"),
TestFixtures.makeTableInfo(name: "orders")
]

let mockDriver = MockDatabaseDriver()
mockDriver.tablesToReturn = initialTables

let provider = SQLSchemaProvider()
await provider.loadSchema(using: mockDriver)

let freshTables = [
TestFixtures.makeTableInfo(name: "users"),
TestFixtures.makeTableInfo(name: "orders"),
TestFixtures.makeTableInfo(name: "new_table")
]
mockDriver.tablesToReturn = freshTables

let callCountBefore = mockDriver.fetchTablesCallCount

let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)
let result = try await fetcher.fetchTables(force: true)

#expect(result.count == 3)
#expect(result.map(\.name) == ["users", "orders", "new_table"])
#expect(mockDriver.fetchTablesCallCount == callCountBefore + 1)
}

@Test("force: true writes fresh tables back into schema provider")
func forcedFetchUpdatesSchemaProvider() async throws {
let initialTables = [TestFixtures.makeTableInfo(name: "old_table")]

let mockDriver = MockDatabaseDriver()
mockDriver.tablesToReturn = initialTables

let provider = SQLSchemaProvider()
await provider.loadSchema(using: mockDriver)

let freshTables = [
TestFixtures.makeTableInfo(name: "alpha"),
TestFixtures.makeTableInfo(name: "beta")
]
mockDriver.tablesToReturn = freshTables

let fetcher = LiveTableFetcher(connectionId: UUID(), schemaProvider: provider)
_ = try await fetcher.fetchTables(force: true)

let cached = await provider.getTables()
#expect(cached.map(\.name).sorted() == ["alpha", "beta"])
}
}
2 changes: 1 addition & 1 deletion TableProTests/ViewModels/SidebarViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ private struct MockTableFetcher: TableFetcher {
var tables: [TableInfo]
var error: Error?

func fetchTables() async throws -> [TableInfo] {
func fetchTables(force: Bool) async throws -> [TableInfo] {
if let error { throw error }
return tables
}
Expand Down
14 changes: 9 additions & 5 deletions TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ import Testing
private final class FetchTrackingTableFetcher: TableFetcher, @unchecked Sendable {
private let lock = NSLock()
private var _fetchCount = 0
private var _forceCount = 0

var fetchCount: Int {
lock.withLock { _fetchCount }
}
var fetchCount: Int { lock.withLock { _fetchCount } }
var forceCount: Int { lock.withLock { _forceCount } }

func fetchTables() async throws -> [TableInfo] {
lock.withLock { _fetchCount += 1 }
func fetchTables(force: Bool) async throws -> [TableInfo] {
lock.withLock {
_fetchCount += 1
if force { _forceCount += 1 }
}
return []
}
}
Expand Down Expand Up @@ -77,6 +80,7 @@ struct CoordinatorReloadSidebarTests {
try? await Task.sleep(nanoseconds: 100_000_000)

#expect(mockFetcher.fetchCount > 0)
#expect(mockFetcher.forceCount > 0)
}

@Test("reloadSidebar is safe when sidebarViewModel is nil")
Expand Down
2 changes: 1 addition & 1 deletion TableProTests/Views/SwitchDatabaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import Testing
private struct MockTableFetcher: TableFetcher {
var tables: [TableInfo]

func fetchTables() async throws -> [TableInfo] {
func fetchTables(force: Bool) async throws -> [TableInfo] {
tables
}
}
Expand Down
Loading