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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A new database group now appears in the connection list right away instead of only after restarting the app. (#1704)
- The SQL formatter keeps nested indentation for UNION, UNION ALL, INTERSECT, and EXCEPT inside a derived table or CTE, and puts the closing parenthesis of a subquery on its own line instead of collapsing it onto the last SELECT. (#1698)
- Toolbar button tooltips now show each action's real keyboard shortcut and follow your custom bindings, instead of a fixed value. The Switch Connection tooltip showed the wrong shortcut. (#1694)
- Deleting a table from the sidebar now removes it from the tree right away on multi-database servers like MySQL and PostgreSQL. The tree kept showing the dropped table until you refreshed it by hand. (#1714)
- The row detail panel no longer stays blank when a table is opened in a second tab. The panel now shows the selected row right away instead of only after the inspector is toggled.
- The sidebar filter now stays put when you open another tab. Before, opening a second table tab cleared the filter text and reset the table list to show everything.
- Fixed a crash when browsing the database tree on servers with many schemas, such as PostgreSQL. The sidebar tree is now a native outline view that loads each database and schema on demand.
Expand Down
33 changes: 32 additions & 1 deletion TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,37 @@ final class DatabaseTreeMetadataService {
_ = await (tables, routines)
}

func refreshLoadedTables(connectionId: UUID) async {
let keys = tablesState.keys.filter { $0.connectionId == connectionId }
await withTaskGroup(of: Void.self) { group in
for key in keys {
group.addTask { @MainActor in
await self.reloadTablesInPlace(key)
}
}
}
}

private func reloadTablesInPlace(_ key: ObjectsKey) async {
guard isConnected(key.connectionId) else { return }
await tablesDedup.cancel(key: key)
do {
let list = try await tablesDedup.execute(key: key) { [self] in
try await withDriver(connectionId: key.connectionId, database: key.database) { driver in
try await driver.fetchTables(schema: key.schema)
}
}
let next: MetadataLoadState<[TableInfo]> = .loaded(list)
guard tablesState[key] != next else { return }
tablesState[key] = next
} catch is CancellationError {
} catch {
Self.logger.warning(
"tables refresh failed db=\(key.database, privacy: .public) schema=\(key.schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)"
)
Comment on lines +235 to +238

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Surface refresh failures instead of keeping stale tables

When the metadata refetch fails after a successful drop or truncate, this catch only logs and leaves the old .loaded table list in tablesState; because loadTables returns immediately for loaded states, the sidebar continues to present the stale table with no error until the user manually forces an object refresh. Mark this key failed or clear it before refetching so refresh failures do not silently preserve deleted objects.

Useful? React with 👍 / 👎.

}
}

// MARK: - Lifecycle

func handleReconnect(connectionId: UUID) async {
Expand Down Expand Up @@ -292,7 +323,7 @@ final class DatabaseTreeMetadataService {
return ObjectsKey(connectionId: connectionId, database: database, schema: normalized)
}

static func connectionObjectKeys(
nonisolated static func connectionObjectKeys(
tableKeys: some Sequence<ObjectsKey>,
routineKeys: some Sequence<ObjectsKey>,
connectionId: UUID
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 @@ -615,6 +615,7 @@ final class MainContentCoordinator {
} catch {
Self.logger.warning("Schema refresh failed: \(error.localizedDescription, privacy: .public)")
}
await DatabaseTreeMetadataService.shared.refreshLoadedTables(connectionId: connectionId)
await reconcilePostSchemaLoad()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
@testable import TablePro
import TableProPluginKit
import Testing

@Suite("DatabaseTreeMetadataService")
Expand Down Expand Up @@ -53,3 +54,82 @@ struct DatabaseTreeMetadataServiceTests {
#expect(keys == [mine])
}
}

@Suite("DatabaseTreeMetadataService refreshLoadedTables")
@MainActor
struct DatabaseTreeMetadataServiceRefreshTests {
@Test("reload drops previously loaded tables and refetches the current list")
func refreshReloadsLoadedTables() async {
let connection = TestFixtures.makeConnection()
let driver = MockDatabaseDriver(connection: connection)
driver.schemaTablesToReturn = ["public": [TestFixtures.makeTableInfo(name: "users")]]

var session = ConnectionSession(connection: connection, driver: driver)
session.status = .connected
DatabaseManager.shared.injectSession(session, for: connection.id)

let service = DatabaseTreeMetadataService.shared
let database = connection.database

await service.loadTables(connectionId: connection.id, database: database, schema: "public")
let initial = service.tables(connectionId: connection.id, database: database, schema: "public")
#expect(initial.map(\.name) == ["users"])

driver.schemaTablesToReturn = ["public": []]
await service.refreshLoadedTables(connectionId: connection.id)

let refreshed = service.tables(connectionId: connection.id, database: database, schema: "public")
#expect(refreshed.isEmpty)

await service.handleDisconnect(connectionId: connection.id)
DatabaseManager.shared.removeSession(for: connection.id)
}

@Test("reload refetches every loaded schema, not just the one that changed")
func refreshReloadsAllLoadedSchemas() async {
let connection = TestFixtures.makeConnection()
let driver = MockDatabaseDriver(connection: connection)
driver.schemaTablesToReturn = [
"public": [TestFixtures.makeTableInfo(name: "users")],
"sales": [TestFixtures.makeTableInfo(name: "orders")]
]

var session = ConnectionSession(connection: connection, driver: driver)
session.status = .connected
DatabaseManager.shared.injectSession(session, for: connection.id)

let service = DatabaseTreeMetadataService.shared
let database = connection.database

await service.loadTables(connectionId: connection.id, database: database, schema: "public")
await service.loadTables(connectionId: connection.id, database: database, schema: "sales")

driver.schemaTablesToReturn = [
"public": [],
"sales": [TestFixtures.makeTableInfo(name: "orders"), TestFixtures.makeTableInfo(name: "invoices")]
]
await service.refreshLoadedTables(connectionId: connection.id)

let publicTables = service.tables(connectionId: connection.id, database: database, schema: "public")
let salesTables = service.tables(connectionId: connection.id, database: database, schema: "sales")
#expect(publicTables.isEmpty)
#expect(salesTables.map(\.name) == ["orders", "invoices"])

await service.handleDisconnect(connectionId: connection.id)
DatabaseManager.shared.removeSession(for: connection.id)
}

@Test("refresh is a no-op when no tables are loaded for the connection")
func refreshWithoutLoadedTablesIsNoOp() async {
let connection = TestFixtures.makeConnection()

await DatabaseTreeMetadataService.shared.refreshLoadedTables(connectionId: connection.id)

let tables = DatabaseTreeMetadataService.shared.tables(
connectionId: connection.id,
database: connection.database,
schema: "public"
)
#expect(tables.isEmpty)
}
}
Loading