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
8 changes: 4 additions & 4 deletions apple/TablePro/Models/UI/RowInspectorContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ internal struct RowInspectorContext: Equatable {
internal let isEditable: Bool
internal let isRowDeleted: Bool

/// Present only on a tab that actually has a table. The old context handed over
/// `coordinator.tableMetadata` unconditionally, and that slot is latest-wins and cleared only
/// on teardown, so a query tab, a dashboard or an ER diagram showed the statistics of whichever
/// table had been opened last, including after its tab was closed.
/// Present only on a tab that actually has a table. The old context handed over the
/// coordinator's one metadata slot unconditionally, latest-wins and cleared only on teardown, so
/// a query tab, a dashboard or an ER diagram showed the statistics of whichever table had been
/// opened last, including after its tab was closed.
internal let tableMetadata: TableMetadata?

/// The same row the fields mode shows, as raw cell values, because the JSON mode decides from
Expand Down
16 changes: 14 additions & 2 deletions apple/TablePro/Views/Filter/QueryTabFilterPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,26 @@ internal struct QueryTabFilterPanel: View {
.onChange(of: columns) { _ in
refreshRawSQLCompletionProvider()
}
.onChange(of: coordinator.currentTableName) { _ in
.onChange(of: suggestionSource) { _ in
refreshRawSQLCompletionProvider()
}
.task(id: coordinator.currentTableName) {
.task(id: suggestionSource) {
await loadFieldPaths()
}
}

/// What both kinds of suggestion are read from: the table and the tab's scope. The name alone
/// stayed equal across two tabs on `users` in two databases, so the second kept the first one's
/// field paths and completions.
private struct SuggestionSource: Hashable {
let tableName: String?
let scope: DatabaseScope?
}

private var suggestionSource: SuggestionSource {
SuggestionSource(tableName: coordinator.currentTableName, scope: coordinator.selectedTabScope)
}

private var configuration: FilterPanelConfiguration {
FilterPanelConfiguration(
columns: columns,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,9 @@ extension MainContentCoordinator {
let definition = TableFreshness.Change(extent: .definition, at: change.changedAt)
refreshTableTabs(for: definition, excluding: change.originTabId) { showingIds.contains($0.id) }
case .comment:
for tab in showing {
tableMetadataCache.removeValue(forKey: tab.id)
}
if let selected, let tableName = selected.tableContext.tableName {
Task { await loadTableMetadata(tableName: tableName, for: selected) }
markTableMetadataStale(forTabs: showingIds)
if let selected {
Task { await loadTableMetadata(for: selected) }
}
refreshStructure(ofTabs: showing)
case .dropped:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ extension MainContentCoordinator {
tab.title = newName
}
tabManager.markTabRenamed(tabManager.tabs[index].id)
followRename(ofTableMetadataFor: tabManager.tabs[index])
/// The browse query still names the old table, so the next page, sort or filter would
/// run against a name the server no longer has.
rebuildTableQuery(at: index)
Expand All @@ -45,6 +46,7 @@ extension MainContentCoordinator {
tab.tableContext.databaseName = toDatabase
if schema != nil { tab.tableContext.schemaName = toSchema }
}
followRename(ofTableMetadataFor: tabManager.tabs[index])
rebuildTableQuery(at: index)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ import Combine
import Foundation
import os

/// One tab's Table Info numbers, stamped with what they were read against.
/// One tab's Table Info numbers, stamped with the table and the execution they were read against.
struct TableMetadataCacheEntry {
let tableName: String
var table: TableTabIdentity
let lastExecutedAt: Date?
/// The coordinator's `tableMetadataGeneration` when the read started.
let generation: Int
let metadata: TableMetadata
/// Set when the object changed without the tab running again, as a comment edit does. The
/// numbers stay on screen until a refetch replaces them, rather than the panel going empty while
/// it runs and staying empty if it fails.
var isStale = false
}

extension MainContentCoordinator {
Expand Down Expand Up @@ -162,57 +168,93 @@ extension MainContentCoordinator {
}
}

/// Whether the Table Info panel already holds this tab's numbers.
/// The table a tab's Table Info describes, or nil on a tab with no table of its own, such as a
/// query. The load's task key, the load and the read all ask this one question, so they agree on
/// which tabs have Table Info and on when a retarget makes the cached numbers another table's.
func tableInfoIdentity(of tab: QueryTab) -> TableTabIdentity? {
tab.tableIdentity(browsing: browseDatabaseName)
}

/// Whether the cache already holds this tab's numbers.
///
/// The panel used to be backed by one latest-wins slot, so alternating between two tabs missed
/// it on every switch and ran a statistics command (`collStats` on MongoDB) each time. The entry
/// is keyed by tab and stamped with the tab's last execution, so a reload, a page or a filter
/// refetches while a plain switch does not. (#2424)
func hasCurrentTableMetadata(for tab: QueryTab, tableName: String) -> Bool {
guard let entry = tableMetadataCache[tab.id] else { return false }
return entry.tableName == tableName && entry.lastExecutedAt == tab.execution.lastExecutedAt
/// is keyed by tab and stamped with the tab's last execution, so the next load after a reload, a
/// page or a filter reads the numbers again, while a plain switch does not. (#2424)
func hasCurrentTableMetadata(for tab: QueryTab) -> Bool {
guard let entry = tableMetadataCache[tab.id], !entry.isStale else { return false }
return entry.table == tableInfoIdentity(of: tab) && entry.lastExecutedAt == tab.execution.lastExecutedAt
}

/// The numbers loaded for this tab, or nil. Read per tab, never from the latest-wins slot, so a
/// table of the same name in another database cannot show them; a retarget drops the entry.
/// A refresh keeps showing them until new ones arrive.
/// The numbers loaded for this tab's table, or nil. Matched on the whole identity, so a table of
/// the same name in another database or schema never shows them. A refresh keeps showing them
/// until new ones arrive.
func currentTableMetadata(for tab: QueryTab) -> TableMetadata? {
guard let tableName = tab.tableContext.tableName,
let entry = tableMetadataCache[tab.id],
entry.tableName == tableName
else { return nil }
guard let entry = tableMetadataCache[tab.id], entry.table == tableInfoIdentity(of: tab) else { return nil }
return entry.metadata
}

func loadTableMetadata(tableName: String, for tab: QueryTab) async {
if let entry = tableMetadataCache[tab.id],
entry.tableName == tableName,
entry.lastExecutedAt == tab.execution.lastExecutedAt {
tableMetadata = entry.metadata
return
}
// Only the inspector draws these numbers; it reads them when it comes on screen.
guard trailingPaneState?.inspector.isOnScreen == true else { return }
guard let scope = selectedTabScope else {
Self.logger.error("Skipped table metadata load: no database bound to the selected tab")
/// Reads the tab's numbers once its rows have arrived, and only while the inspector, the one
/// view that draws them, is on screen. A read before the rows would be stamped with no execution
/// and read again the moment they land.
func loadTableMetadata(for tab: QueryTab) async {
guard let table = tableInfoIdentity(of: tab),
let executedAt = tab.execution.lastExecutedAt,
trailingPaneState?.inspector.isOnScreen == true,
!hasCurrentTableMetadata(for: tab)
else { return }
guard let scope = scope(for: tab) else {
Self.logger.error("Skipped table metadata load: no database bound to the tab")
return
}
let generation = tableMetadataGeneration
do {
let metadata = try await services.databaseManager.withMetadataDriver(scope: scope) { driver in
try await driver.fetchTableMetadata(tableName: tableName)
try await driver.fetchTableMetadata(tableName: table.table)
}
/// The fetch cannot be cancelled, so it can land after its tab moved onto another table on
/// the same id, or closed, and stored then it would show the numbers of what the tab used
/// to show. A tab that only ran again keeps them, stamped with the run they were read for,
/// so they show and the next load reads again; the load key does not move on a page, and
/// dropping them would leave the panel empty. A read that started later and landed first,
/// after another run or after an invalidation, is kept.
guard let live = tabManager.tabs.first(where: { $0.id == tab.id }),
tableInfoIdentity(of: live) == table
else { return }
if let existing = tableMetadataCache[tab.id], existing.table == table,
let existingStamp = existing.lastExecutedAt,
(existingStamp, existing.generation) > (executedAt, generation) {
return
}
tableMetadataCache[tab.id] = TableMetadataCacheEntry(
tableName: tableName,
lastExecutedAt: tab.execution.lastExecutedAt,
metadata: metadata
table: table,
lastExecutedAt: executedAt,
generation: generation,
metadata: metadata,
isStale: generation != tableMetadataGeneration
)
/// The fetch is not cancellable, so a `.task` the user has already navigated away from
/// still completes. Without this the numbers of the collection they left land in the
/// panel of the one they are looking at, under its name, and stay there.
guard tabManager.selectedTabId == tab.id else { return }
tableMetadata = metadata
} catch is CancellationError {
return
} catch {
Self.logger.error("Failed to load table metadata: \(error.publicLogShape, privacy: .public)")
}
}

/// The object changed under the numbers without the tab running again, so the next load reads
/// them afresh while the panel goes on showing the ones it has.
func markTableMetadataStale(forTabs tabIds: some Sequence<UUID>) {
tableMetadataGeneration &+= 1
for tabId in tabIds {
tableMetadataCache[tabId]?.isStale = true
}
}

/// A rename leaves the tab on the same table under another name, so its numbers still describe
/// it, and the panel draws none of them from the name. Moved onto the tab's new identity, they
/// stay on screen with no read at all.
func followRename(ofTableMetadataFor tab: QueryTab) {
guard var entry = tableMetadataCache[tab.id], let table = tableInfoIdentity(of: tab) else { return }
entry.table = table
tableMetadataCache[tab.id] = entry
}
}
69 changes: 43 additions & 26 deletions apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,26 @@ import TableProPluginKit
extension MainContentView {
// MARK: - Helper Methods

/// Keyed on the tab and the table's whole identity. A name alone stayed equal across
/// `tenant_a.orders` and `tenant_b.orders`, so the second never loaded its own numbers.
/// What the Table Info load waits for, so it runs again once each of them moves.
///
/// The load waits for the tab's rows. Without `hasRows`, a table tab that had just opened ran the
/// load before they arrived and never again, so the panel showed no numbers until the inspector
/// left the screen and came back. Whether rows arrived, not when: a page, a sort or a filter
/// runs the query again without changing the size or the row count, and a read per page would
/// count up to 100,001 rows on SQLite and run `collStats` on MongoDB each time. The whole
/// identity, not the name, because `tenant_a.orders` and `tenant_b.orders` are two tables.
struct TableInfoTaskKey: Hashable {
let tabId: UUID
let table: TableTabIdentity?
}

var tableInfoTaskKey: TableInfoTaskKey? {
currentTab.map { TableInfoTaskKey(tabId: $0.id, table: $0.tableIdentity(browsing: "")) }
}

func loadTableMetadataIfNeeded() async {
guard let tab = currentTab,
let tableName = tab.tableContext.tableName,
!coordinator.hasCurrentTableMetadata(for: tab, tableName: tableName)
else { return }
await coordinator.loadTableMetadata(tableName: tableName, for: tab)
}

func loadTableInfo() async {
guard currentTab?.execution.lastExecutedAt != nil else { return }
await loadTableMetadataIfNeeded()
scheduleInspectorUpdate()
let hasRows: Bool
let isInspectorOnScreen: Bool

init(tab: QueryTab, table: TableTabIdentity?, isInspectorOnScreen: Bool) {
tabId = tab.id
self.table = table
hasRows = tab.execution.lastExecutedAt != nil
self.isInspectorOnScreen = isInspectorOnScreen
}
}

func handleConnectionStatusChange() {
Expand Down Expand Up @@ -125,12 +122,10 @@ extension MainContentView {
)
}

/// Nil on a tab that has no table of its own.
///
/// Read from the entry cached for this tab, never from `coordinator.tableMetadata`, the single
/// latest-wins slot. Gated on the name, the slot showed a query tab, a dashboard, or a table of
/// the same name in another database the numbers of whichever table had loaded last.
private var tableMetadataForCurrentTab: TableMetadata? {
/// Nil on a tab that has no table of its own. Read from the entry cached for this tab: a single
/// latest-wins slot showed a query tab, a dashboard, or a table of the same name in another
/// database the numbers of whichever table had loaded last.
var tableMetadataForCurrentTab: TableMetadata? {
currentTab.flatMap { coordinator.currentTableMetadata(for: $0) }
}

Expand Down Expand Up @@ -195,3 +190,25 @@ extension MainContentView {
return lines.joined(separator: "\n")
}
}

/// Runs the Table Info load where the inspector's on-screen state is observed.
///
/// `MainContentView` does not observe the inspector, whose context changes on every selection, so a
/// key built in its body never saw `isOnScreen` move. Observed here, only this modifier redraws.
internal struct TableInfoLoadModifier: ViewModifier {
@ObservedObject var inspector: RowInspectorState
let tab: QueryTab?
let table: TableTabIdentity?
let load: (QueryTab) async -> Void

func body(content: Content) -> some View {
content.task(id: key) {
guard let tab else { return }
await load(tab)
}
}

private var key: MainContentView.TableInfoTaskKey? {
tab.map { MainContentView.TableInfoTaskKey(tab: $0, table: table, isInspectorOnScreen: inspector.isOnScreen) }
}
}
11 changes: 7 additions & 4 deletions apple/TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,6 @@ final class MainContentCoordinator: ObservableObject {
// MARK: - Published State

@Published var cursorPositions: [CursorPosition] = []
@Published var tableMetadata: TableMetadata?
@Published var activeSheet: ActiveSheet? {
didSet { releaseImportFileUnlessImporting() }
}
Expand All @@ -316,7 +315,13 @@ final class MainContentCoordinator: ObservableObject {
var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:]
var displayOrderCache: [UUID: DisplayOrderCacheEntry] = [:]
var displayStateCache: [UUID: DisplayStateCacheEntry] = [:]
var tableMetadataCache: [UUID: TableMetadataCacheEntry] = [:]
/// Published because the inspector draws from it: a load that lands, a comment refetch and a
/// rename each reach the panel because the view rebuilds its snapshot when this changes.
@Published var tableMetadataCache: [UUID: TableMetadataCacheEntry] = [:]
/// Moves on every Table Info invalidation, so a read in flight across one cannot mark the cache
/// current: its numbers may predate the change. One counter for the window rather than one per
/// tab, because an invalidation can arrive before a tab's first read has made an entry to mark.
var tableMetadataGeneration = 0
var displayStateClock = 0

let schemaColumns = SchemaColumnStore()
Expand Down Expand Up @@ -1023,8 +1028,6 @@ final class MainContentCoordinator: ObservableObject {
changeManager.clearChanges()
changeManager.pluginDriver = nil

tableMetadata = nil

services.schemaProviderRegistry.release(for: connection.id)
services.schemaProviderRegistry.purgeUnused()
Self.lifecycleLogger.info(
Expand Down
20 changes: 13 additions & 7 deletions apple/TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,9 +329,13 @@ struct MainContentView: View {
configureWindow(window)
}
}
.task(id: tableInfoTaskKey) {
await loadTableInfo()
}
.modifier(TableInfoLoadModifier(
inspector: trailingPaneState.inspector,
tab: currentTab,
table: currentTab.flatMap { coordinator.tableInfoIdentity(of: $0) }
) { [coordinator] tab in
await coordinator.loadTableMetadata(for: tab)
})
/// Keyed on the connection alone. The only driver that answers `fetchSessionContexts`
/// is Snowflake, which pays two round trips for it, so this must not reload per query.
/// It lives with the connection's content rather than with the toolbar control it used
Expand All @@ -343,17 +347,19 @@ struct MainContentView: View {
.onChange(of: inspectorTrigger) { _ in
scheduleInspectorUpdate()
}
/// The numbers land after the rows that moved the trigger above, and a comment refetch
/// moves nothing else, so the snapshot follows the cache on its own. Only the snapshot:
/// the edit state does not depend on them, and rebuilding it would end a typed undo run.
.onChange(of: tableMetadataForCurrentTab) { _ in
updateInspectorContext()
}
/// The JSON rendering draws the snapshot the context carries, and an edit made in the
/// fields rendering changes the row under it without moving anything `InspectorTrigger`
/// watches. Rebuilding on the switch is enough: the two renderings are never on screen
/// together, so the stale snapshot is only ever reached by switching to it.
.onValueChange(of: \.viewMode, in: trailingPaneState.inspector) { _, _ in
updateInspectorContext()
}
.onValueChange(of: \.isOnScreen, in: trailingPaneState.inspector) { _, isOnScreen in
guard isOnScreen else { return }
Task { await loadTableInfo() }
}
/// A value window detached from a field goes on writing while the JSON rendering is the
/// one on screen, and it moves nothing the trigger above watches. Debounced, because it
/// commits per keystroke and rebuilding the JSON tree cancels the reader's fetches.
Expand Down
Loading
Loading