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 @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Tables and views outside the connection's default schema now open and save correctly when reached through the Quick Switcher, a restored tab, or the MCP table-open tool. These paths used to send unqualified queries, which failed with "Invalid object name" on SQL Server or silently targeted the wrong table. (#1774)
- Quick Switcher opens again on macOS Sequoia. Since 0.51.0 the panel came up invisible on macOS 15, and the toolbar button and keyboard shortcut appeared to do nothing. (#1806)
- Quick Switcher keyboard navigation (Ctrl-J/K and arrow shortcuts) no longer goes dead after the switcher has been opened and closed repeatedly. (#1806)
- Restored table tabs no longer reload all at once or flood failure dialogs on launch. Only the frontmost tab loads immediately; other restored tabs load when you switch to them, and a load failure now shows inline in the tab instead of a dialog. (#1796)
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ final class DatabaseManager {
activeSessions[connection.id]?.activeDatabase ?? connection.database
}

/// Authoritative schema for a table identity when the caller has no explicit
/// schema. Explicit schemas pass through unchanged; nil resolves to the live
/// session's current schema and stays nil for schema-less engines.
func resolvedSchemaName(_ schemaName: String?, for connectionId: UUID) -> String? {
schemaName ?? activeSessions[connectionId]?.currentSchema
}

/// Current connection status
var status: ConnectionStatus {
currentSession?.status ?? .disconnected
Expand Down
10 changes: 8 additions & 2 deletions TablePro/Core/Database/FilterSQLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ extension FilterSQLGenerator {
/// Generate a preview-friendly query string (for display, not execution)
func generatePreviewSQL(
tableName: String,
schemaName: String? = nil,
filters: [TableFilter],
logicMode: FilterLogicMode = .and,
limit: Int = 1_000,
Expand All @@ -333,7 +334,7 @@ extension FilterSQLGenerator {
return (filter.columnName, filter.filterOperator.rawValue, value)
}
if let result = pluginDriver.buildFilteredQuery(
table: tableName, filters: filterTuples,
table: tableName, schema: schemaName, filters: filterTuples,
logicMode: logicMode == .and ? "and" : "or",
sortColumns: [], columns: [],
limit: limit, offset: 0
Expand All @@ -342,7 +343,12 @@ extension FilterSQLGenerator {
}
}

let quotedTable = quoteIdentifierFn(tableName)
let quotedTable: String
if let schemaName, !schemaName.isEmpty {
quotedTable = "\(quoteIdentifierFn(schemaName)).\(quoteIdentifierFn(tableName))"
} else {
quotedTable = quoteIdentifierFn(tableName)
}
var sql = "SELECT * FROM \(quotedTable)"

let whereClause = generateWhereClause(from: filters, logicMode: logicMode)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,21 +90,24 @@ enum SessionStateFactory {
switch payload.tabType {
case .table:
toolbarSt.isTableTab = true
let resolvedSchemaName = DatabaseManager.shared.resolvedSchemaName(
payload.schemaName, for: connectionId
)
Comment on lines +93 to +95

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 Resolve schema after switching to the payload database

When a table payload such as MCP open_table_tab provides a databaseName different from the live session and omits schemaName, this resolves nil against the pre-switch session schema. initializeAndRestoreTabs later switches to selectedTab.tableContext.databaseName, but the tab has already been stamped and its query built with the old schema, so opening db_b.users while the session is on db_a.sales can query db_b.sales.users instead of the target database's default/current schema. Defer this fallback until after the database switch, or skip it when the payload database is not the active database.

Useful? React with 👍 / 👎.

if let tableName = payload.tableName {
do {
if payload.isPreview {
try tabMgr.addPreviewTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? activeDatabaseName,
schemaName: payload.schemaName
schemaName: resolvedSchemaName
)
} else {
try tabMgr.addTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? activeDatabaseName,
schemaName: payload.schemaName
schemaName: resolvedSchemaName
)
}
} catch {
Expand All @@ -113,7 +116,7 @@ enum SessionStateFactory {
if let index = tabMgr.selectedTabIndex {
tabMgr.tabs[index].tableContext.isView = payload.isView
tabMgr.tabs[index].tableContext.isEditable = !payload.isView
tabMgr.tabs[index].tableContext.schemaName = payload.schemaName
tabMgr.tabs[index].tableContext.schemaName = resolvedSchemaName
if payload.showStructure {
tabMgr.tabs[index].display.resultsViewMode = .structure
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ extension MainContentCoordinator {

let currentDatabase = activeDatabaseName

let targetSchema = fkInfo.referencedSchema ?? DatabaseManager.shared.session(for: connectionId)?.currentSchema
let targetSchema = DatabaseManager.shared.resolvedSchemaName(fkInfo.referencedSchema, for: connectionId)

if !openInNewTab,
let current = tabManager.selectedTab,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ extension MainContentCoordinator {
currentDatabase = activeDatabaseName
}

let resolvedSchema = schema
let resolvedSchema = DatabaseManager.shared.resolvedSchemaName(schema, for: connectionId)
let createAsPreview = !forceNonPreview && !forceNewWindowTab
&& AppSettingsManager.shared.tabs.enablePreviewTabs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@ extension MainContentCoordinator {
@discardableResult
func prepareTableTabFirstLoad(tabId: UUID) async -> Bool {
guard tabManager.selectedTabId == tabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
var tab = tabManager.tabs.first(where: { $0.id == tabId }),
tab.tabType == .table,
let tableName = tab.tableContext.tableName, !tableName.isEmpty else { return false }

if resolveTableTabSchemaIfNeeded(tabId: tabId),
let resolvedTab = tabManager.tabs.first(where: { $0.id == tabId }) {
tab = resolvedTab
}

let hint = PluginManager.shared.defaultSortHint(for: connection.type, table: tableName)
guard firstLoadNeedsSchemaColumns(for: tab, hint: hint) else {
if let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) {
Expand All @@ -42,6 +47,19 @@ extension MainContentCoordinator {
return true
}

@discardableResult
func resolveTableTabSchemaIfNeeded(tabId: UUID) -> Bool {
guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }),
tabManager.tabs[index].tabType == .table,
tabManager.tabs[index].tableContext.schemaName == nil,
let resolvedSchema = DatabaseManager.shared.resolvedSchemaName(nil, for: connectionId)
else { return false }

tabManager.mutate(at: index) { $0.tableContext.schemaName = resolvedSchema }
filterCoordinator.rebuildTableQuery(at: index)
return true
}

func firstLoadNeedsSchemaColumns(for tab: QueryTab, hint: DefaultSortHint) -> Bool {
wantsDefaultSort(for: tab, hint: hint)
|| !tab.columnLayout.hiddenColumns.isEmpty
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Main/Extensions/MainContentView+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ extension MainContentView {
}

private func consumePendingLoad(trigger: TableLoadTrigger, session: ConnectionSession) {
Comment on lines 43 to 45

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 Stamp restored tab schemas after the database switch

When a pending load resumes for a restored nil-schema table whose saved database differs from the connected session, this call stamps the schema before the code below switches to the tab's database. After the switch, lazyLoadCurrentTabIfNeeded sees a non-nil schema and prepareTableTabFirstLoad will not re-resolve it, so the first query is qualified with the schema from the previous database. Move the schema resolution after the database switch so legacy restored tabs use the schema for their own database.

Useful? React with 👍 / 👎.

if let tabId = tabManager.selectedTab?.id {
coordinator.resolveTableTabSchemaIfNeeded(tabId: tabId)
}
if let selectedTab = tabManager.selectedTab,
!selectedTab.tableContext.databaseName.isEmpty,
selectedTab.tableContext.databaseName != session.activeDatabase
Expand Down
36 changes: 36 additions & 0 deletions TableProTests/Core/Database/DatabaseManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,40 @@ struct DatabaseManagerSessionTests {
let session = DatabaseManager.shared.activeSessions[unknownId]
#expect(session == nil)
}

@Test("resolvedSchemaName keeps an explicit schema over the session's current schema")
func resolvedSchemaNameKeepsExplicitSchema() {
let connection = TestFixtures.makeConnection()
var session = ConnectionSession(connection: connection)
session.currentSchema = "sales"
DatabaseManager.shared.injectSession(session, for: connection.id)
defer { DatabaseManager.shared.removeSession(for: connection.id) }

#expect(DatabaseManager.shared.resolvedSchemaName("audit", for: connection.id) == "audit")
}

@Test("resolvedSchemaName falls back to the session's current schema")
func resolvedSchemaNameFallsBackToSessionSchema() {
let connection = TestFixtures.makeConnection()
var session = ConnectionSession(connection: connection)
session.currentSchema = "sales"
DatabaseManager.shared.injectSession(session, for: connection.id)
defer { DatabaseManager.shared.removeSession(for: connection.id) }

#expect(DatabaseManager.shared.resolvedSchemaName(nil, for: connection.id) == "sales")
}

@Test("resolvedSchemaName stays nil without a session")
func resolvedSchemaNameStaysNilWithoutSession() {
#expect(DatabaseManager.shared.resolvedSchemaName(nil, for: UUID()) == nil)
}

@Test("resolvedSchemaName stays nil for a schema-less session")
func resolvedSchemaNameStaysNilForSchemaLessSession() {
let connection = TestFixtures.makeConnection()
DatabaseManager.shared.injectSession(ConnectionSession(connection: connection), for: connection.id)
defer { DatabaseManager.shared.removeSession(for: connection.id) }

#expect(DatabaseManager.shared.resolvedSchemaName(nil, for: connection.id) == nil)
}
}
18 changes: 18 additions & 0 deletions TableProTests/Core/Database/FilterSQLGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,24 @@ struct FilterSQLGeneratorTests {
#expect(result.contains("LIMIT 1000"))
}

@Test("Preview SQL qualifies the table with the schema")
func testPreviewSQLWithSchema() {
let generator = FilterSQLGenerator(dialect: Self.mysqlDialect)
let result = generator.generatePreviewSQL(
tableName: "users", schemaName: "sales", filters: [], limit: 1_000
)
#expect(result.contains("SELECT * FROM `sales`.`users`"))
}

@Test("Preview SQL with an empty schema stays unqualified")
func testPreviewSQLWithEmptySchema() {
let generator = FilterSQLGenerator(dialect: Self.mysqlDialect)
let result = generator.generatePreviewSQL(
tableName: "users", schemaName: "", filters: [], limit: 1_000
)
#expect(result.contains("SELECT * FROM `users`"))
}

// MARK: - Edge Cases

@Test("Between with missing secondValue returns nil")
Expand Down
31 changes: 31 additions & 0 deletions TableProTests/Views/Main/FKNavigationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,37 @@ struct FKNavigationTests {
#expect(tabManager.selectedTab?.tableContext.tableName == "users")
}

@Test("FK navigation with no referenced schema resolves the session's current schema")
@MainActor
func nilReferencedSchemaResolvesActiveSchema() throws {
let connection = TestFixtures.makeConnection(database: "db_a", type: .postgresql)
var session = ConnectionSession(connection: connection)
session.currentSchema = "sales"
DatabaseManager.shared.injectSession(session, for: connection.id)
defer { DatabaseManager.shared.removeSession(for: connection.id) }

let tabManager = QueryTabManager()
let coordinator = MainContentCoordinator(
connection: connection,
tabManager: tabManager,
changeManager: DataChangeManager(),
toolbarState: ConnectionToolbarState()
)
defer { coordinator.teardown() }

try tabManager.addTableTab(
tableName: "orders",
databaseType: connection.type,
databaseName: coordinator.activeDatabaseName
)

let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id")
coordinator.navigateToFKReference(value: "42", fkInfo: fkInfo, openInNewTab: false)

#expect(tabManager.selectedTab?.tableContext.tableName == "users")
#expect(tabManager.selectedTab?.tableContext.schemaName == "sales")
}

@Test("Metadata is not cached until foreign keys were fetched")
@MainActor
func metadataCacheRequiresFetchedForeignKeys() throws {
Expand Down
67 changes: 67 additions & 0 deletions TableProTests/Views/Main/OpenTableTabTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,73 @@ struct OpenTableTabTests {
#expect(tabManager.selectedTab?.tableContext.tableName == "users")
}

// MARK: - Schema identity resolution (issue #1774)

@Test("Opening a bare table name stamps the session's current schema")
@MainActor
func bareTableNameResolvesActiveSchema() {
let connection = TestFixtures.makeConnection(type: .postgresql)
var session = ConnectionSession(connection: connection)
session.currentSchema = "sales"
DatabaseManager.shared.injectSession(session, for: connection.id)
defer { DatabaseManager.shared.removeSession(for: connection.id) }

let tabManager = QueryTabManager()
let coordinator = MainContentCoordinator(
connection: connection,
tabManager: tabManager,
changeManager: DataChangeManager(),
toolbarState: ConnectionToolbarState()
)
defer { coordinator.teardown() }

coordinator.openTableTab("routes")

#expect(tabManager.selectedTab?.tableContext.tableName == "routes")
#expect(tabManager.selectedTab?.tableContext.schemaName == "sales")
}

@Test("Opening with an explicit schema wins over the session's current schema")
@MainActor
func explicitSchemaWinsOverActiveSchema() {
let connection = TestFixtures.makeConnection(type: .postgresql)
var session = ConnectionSession(connection: connection)
session.currentSchema = "sales"
DatabaseManager.shared.injectSession(session, for: connection.id)
defer { DatabaseManager.shared.removeSession(for: connection.id) }

let tabManager = QueryTabManager()
let coordinator = MainContentCoordinator(
connection: connection,
tabManager: tabManager,
changeManager: DataChangeManager(),
toolbarState: ConnectionToolbarState()
)
defer { coordinator.teardown() }

coordinator.openTableTab("routes", schema: "audit")

#expect(tabManager.selectedTab?.tableContext.schemaName == "audit")
}

@Test("Opening without a session leaves the schema nil")
@MainActor
func noSessionLeavesSchemaNil() {
let connection = TestFixtures.makeConnection(database: "db_a")
let tabManager = QueryTabManager()
let coordinator = MainContentCoordinator(
connection: connection,
tabManager: tabManager,
changeManager: DataChangeManager(),
toolbarState: ConnectionToolbarState()
)
defer { coordinator.teardown() }

coordinator.openTableTab("routes")

#expect(tabManager.selectedTab?.tableContext.schemaName == nil)
}

// MARK: - isActiveTabReusable

@Test("A preview table tab is reusable")
Expand Down
45 changes: 45 additions & 0 deletions TableProTests/Views/Main/SessionStateFactoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct SessionStateFactoryTests {
tabType: TabType = .query,
tableName: String? = nil,
databaseName: String? = nil,
schemaName: String? = nil,
initialQuery: String? = nil,
isView: Bool = false,
showStructure: Bool = false
Expand All @@ -29,6 +30,7 @@ struct SessionStateFactoryTests {
tabType: tabType,
tableName: tableName,
databaseName: databaseName,
schemaName: schemaName,
initialQuery: initialQuery,
isView: isView,
showStructure: showStructure
Expand Down Expand Up @@ -113,6 +115,49 @@ struct SessionStateFactoryTests {
#expect(tab.tableContext.isEditable == false)
}

@Test("Table payload without a schema resolves the session's current schema")
@MainActor
func tablePayloadWithoutSchema_resolvesActiveSchema() {
let conn = TestFixtures.makeConnection(type: .postgresql)
var session = ConnectionSession(connection: conn)
session.currentSchema = "sales"
DatabaseManager.shared.injectSession(session, for: conn.id)
defer { DatabaseManager.shared.removeSession(for: conn.id) }

let payload = makePayload(connectionId: conn.id, tabType: .table, tableName: "users")
let state = SessionStateFactory.create(connection: conn, payload: payload)

#expect(state.tabManager.tabs.first?.tableContext.schemaName == "sales")
}

@Test("Table payload with an explicit schema keeps it over the session's current schema")
@MainActor
func tablePayloadWithExplicitSchema_keepsIt() {
let conn = TestFixtures.makeConnection(type: .postgresql)
var session = ConnectionSession(connection: conn)
session.currentSchema = "sales"
DatabaseManager.shared.injectSession(session, for: conn.id)
defer { DatabaseManager.shared.removeSession(for: conn.id) }

let payload = makePayload(
connectionId: conn.id, tabType: .table, tableName: "users", schemaName: "audit"
)
let state = SessionStateFactory.create(connection: conn, payload: payload)

#expect(state.tabManager.tabs.first?.tableContext.schemaName == "audit")
}

@Test("Table payload without a session leaves the schema nil")
@MainActor
func tablePayloadWithoutSession_leavesSchemaNil() {
let conn = TestFixtures.makeConnection()
let payload = makePayload(connectionId: conn.id, tabType: .table, tableName: "users")

let state = SessionStateFactory.create(connection: conn, payload: payload)

#expect(state.tabManager.tabs.first?.tableContext.schemaName == nil)
}

@Test("Nil payload creates empty tab manager")
@MainActor
func nilPayload_createsEmptyTabManager() {
Expand Down
Loading
Loading